Bibliothek ordnen (Navigation Phase 3): Hörbuch-Reihen und -Ordner, Zeitschriften je Heft
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 8m58s
Test / e2e (push) Successful in 1m57s

- Hörbücher: Reihen werden aus den Titeln erkannt („Das Kapital – Erster
  Band“, „…: Teil 3“, „… – Folge 12“, „Der Zauberberg. Erster Band“) und als
  Stapel gezeigt, darin die Bände in Reihenfolge. Ordner je Hörbuch
  (PodcastFeed.folder, Migration 0020), im Buch über ⋮ → Ordner zuweisen.
- Zeitschriften: ein Heft wird als Zeitschrift markiert (⋮ → Als Zeitschrift
  führen, im verschlüsselten Meta); unmarkiert entscheidet weiter der Ordner
  „Zeitschriften“. Die Hefte stehen nach Zeitschrift gruppiert, neueste zuerst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-16 15:45:14 +02:00
parent 143950df94
commit d7930fcdb9
13 changed files with 246 additions and 27 deletions

View file

@ -83,5 +83,8 @@ A tab/rail button calls `DioraNav.go(id)`; the active state comes from
1. Navigation (this document), Hörbücher as their own panel, Zeitschriften by folder.
2. "Abos & Quellen": podcasts, YouTube channels, news sources, blogs and the
audiobook mark in one place; the everyday views without managing.
3. Library: audiobook series (e.g. Das Kapital, Band 1–3) and folders,
magazines marked per book.
3. Library (done): audiobook series are found in the titles ("Das Kapital –
Erster Band" …) and shown as one stack; audiobooks have folders
(`PodcastFeed.folder`); a book is marked as a magazine in its encrypted
meta (`magazine: true/false`; unset: the "Zeitschriften" folder decides), and
Zeitschriften groups the issues by periodical.

View file

@ -87,6 +87,7 @@ def _book(feed, chapters, last_played_at):
nxt = _next(chapters)
return {
'feed_id': feed.id, 'title': feed.title, 'author': feed.author, 'artwork_url': feed.artwork_url,
'folder': feed.folder,
'chapters': len(chapters), 'done': done, 'total_seconds': total, 'heard_seconds': heard,
'next': nxt, 'last_played_at': last_played_at.isoformat() if last_played_at else None,
}

View file

@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-10-16 13:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0019_audiobooks'),
]
operations = [
migrations.AddField(
model_name='podcastfeed',
name='folder',
field=models.CharField(blank=True, max_length=100),
),
]

View file

@ -75,6 +75,8 @@ class PodcastFeed(models.Model):
# Skip silence in this podcast's episodes: null = as the device is set.
skip_silence = models.BooleanField(null=True, blank=True)
kind = models.CharField(max_length=10, choices=KINDS, default=PODCAST)
# An audiobook's folder in the library ('' = none; one level, like the e-books' folders).
folder = models.CharField(max_length=100, blank=True)
# The vocabulary of episode_sort and episode_filter: the podcast page's
# sort menu and filter chips (native-podcast.js EP_SORTS and FILTERS).

View file

@ -187,3 +187,14 @@ class UnplayedTests(TestCase):
content_type='application/json')
self.assertEqual(EpisodeProgress.objects.get(episode=ep).position_seconds, 0)
self.assertEqual(EpisodeProgress.objects.get(episode=half).position_seconds, 200) # its place stays
class AudiobookFolderTests(TestCase):
def test_a_book_has_a_folder(self):
user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(user)
feed = PodcastFeed.objects.create(user=user, rss_url='https://example.org/b', title='B', kind='audiobook')
r = self.client.post(f'/podcasts/feeds/{feed.pk}/settings/', json.dumps({'folder': ' Marx und Engels '}),
content_type='application/json')
self.assertEqual(r.json()['folder'], 'Marx und Engels')
self.assertEqual(self.client.get('/podcasts/audiobooks/').json()['books'][0]['folder'], 'Marx und Engels')

View file

@ -1074,6 +1074,7 @@ FEED_DEFAULTS = {
'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox',
'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False,
'notify': False, 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast',
'folder': '',
}
@ -1100,7 +1101,7 @@ class FeedSettingsTests(TestCase):
'feed_id': self.feed.id, 'playback_speed': 1.5, 'skip_intro': 30, 'skip_outro': 600, 'volume': 2.0,
'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10,
'auto_queue': False, 'auto_download': False, 'notify': False,
'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast',
'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', 'folder': '',
})
self.feed.refresh_from_db()
self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration),

View file

@ -787,7 +787,7 @@ def feed_list(request):
FEED_SETTING_FIELDS = (
'playback_speed', 'skip_intro', 'skip_outro', 'volume',
'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download',
'notify', 'episode_sort', 'episode_filter', 'skip_silence', 'kind',
'notify', 'episode_sort', 'episode_filter', 'skip_silence', 'kind', 'folder',
)
@ -1025,6 +1025,7 @@ FEED_SETTING_PARSERS = {
'episode_sort': _episode_sort,
'episode_filter': _episode_filter,
'kind': _kind,
'folder': lambda v: ' '.join(str(v or '').split())[:100],
'skip_silence': _optional_boolean,
}

View file

@ -41,8 +41,8 @@ async function loadBookList() {
try {
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0};
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine};
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
} catch (e) {
bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''};
decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
@ -59,7 +59,7 @@ async function loadBookList() {
const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress);
if (cachedBooks.length) {
cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || ''));
for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0};
for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0, magazine: b.magazine};
const uploadArea = $('book-upload-area');
if (uploadArea) uploadArea.style.display = 'none';
renderBookList(cachedBooks);
@ -145,6 +145,7 @@ function repairBook(bookId) {
isbn: isbn || prevMeta.isbn || '',
folder: prevMeta.folder || '',
shelfTag: prevMeta.shelfTag || '',
magazine: prevMeta.magazine,
}));
const [metaEnc, dataEnc] = await Promise.all([
encryptBytes(key, metaJson),
@ -205,11 +206,33 @@ async function toggleBookRead(bookId, currentlyRead) {
// free in /api/sync/. One level only: folders don't nest.
let _currentBookFolder = null; // null = root view (folder tiles + unfiled books)
// Bücher and Zeitschriften are two sections of Lesen (static/js/nav.js), one list: the
// magazines are the books in a folder called "Zeitschriften" (or "Magazine").
// Bücher and Zeitschriften are two sections of Lesen (static/js/nav.js), one list: a
// magazine is an issue marked as one (⋮ → Als Zeitschrift, kept in the encrypted meta),
// or, unmarked, a book in a folder called "Zeitschriften" (or "Magazine").
let _bookSection = 'books';
const _MAGAZINE_FOLDER = /^(zeitschriften|magazine|magazines)$/i;
const _isMagazine = b => _MAGAZINE_FOLDER.test((b.folder || '').trim());
const _isMagazine = b => b.magazine === true || (b.magazine !== false && _MAGAZINE_FOLDER.test((b.folder || '').trim()));
window._isMagazine = _isMagazine;
// The periodical an issue belongs to: its title up to the issue's number ("Jacobin #24",
// "New Left Review 157", "LuXemburg 1/2026"), else its author ("Vol 9 – Issue 2–3" by Catalyst).
function _periodicalOf(b) {
const m = /^(.*?)\s*(?:#|\bno\.|\bnr\.|\bheft\b|\bausgabe\b|\bissue\b|\bvol\b|\d)/i.exec(b.title || '');
const name = (m ? m[1] : '').replace(/[\s–—:,-]+$/, '').trim();
return name.length >= 3 ? name : (b.author || '').trim() || (b.title || '').trim();
}
async function toggleBookMagazine(bookId) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return;
try {
await _updateBookMeta(bookId, {magazine: !_isMagazine(book)});
renderBookList(_lastBookListData);
} catch (e) {
customAlert('Das ließ sich nicht speichern: ' + e.message);
}
}
window.toggleBookMagazine = toggleBookMagazine;
// The books of the section shown (all of them where there are no sections).
function _bookSectionFilter(books) {
@ -254,6 +277,7 @@ function _renderBookItemHtml(b) {
${broken ? `<button class="book-menu-item book-menu-item--danger book-broken-btn" onclick="repairBook(${b.id})">🔧 Reparieren (Datei erneut hochladen)</button>` : ''}
${b.has_highlights ? `<button class="book-menu-item" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳ Markierungen &amp; Notizen herunterladen</button>` : ''}
<button class="book-menu-item" onclick="assignBookFolder(${b.id})">📁 Ordner zuweisen</button>
<button class="book-menu-item" onclick="toggleBookMagazine(${b.id})">${_isMagazine(b) ? '▤ Als Buch führen' : '📰 Als Zeitschrift führen'}</button>
<button class="book-menu-item" onclick="lookupBookMetadata(${b.id})">🏷️ Metadaten abrufen (Regal)</button>
<button class="book-menu-item" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺ Als ungelesen markieren' : '✓ Als gelesen markieren'}</button>
<button class="book-menu-item book-menu-item--danger" onclick="deleteBook(${b.id})">🗑 Löschen</button>
@ -295,7 +319,7 @@ function renderBookList(allBooks) {
if (!books.length) {
listEl.innerHTML = window.DioraNav && allBooks.length
? (_bookSection === 'magazines'
? '<p class="muted">Noch keine Zeitschriften. Hefte, die in einem Ordner „Zeitschriften“ liegen, stehen hier (⋮ → Ordner zuweisen).</p>'
? '<p class="muted">Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.</p>'
: '<p class="muted">Alle Bücher liegen bei den Zeitschriften.</p>')
: '';
return;
@ -324,11 +348,22 @@ function renderBookList(allBooks) {
}
const grouped = new Map();
const magazines = _bookSection === 'magazines' && !!window.DioraNav;
for (const b of visible) {
const key = _bookSection === 'magazines' && _isMagazine(b) ? '' : (b.folder || '');
const key = magazines ? _periodicalOf(b) : (b.folder || '');
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(b);
}
if (magazines) {
// A periodical with one issue is no tile; issues newest first.
const loose = grouped.get('') || [];
for (const [k, list] of [...grouped]) {
if (k !== '' && list.length < 2) { loose.push(...list); grouped.delete(k); }
}
grouped.set('', loose);
const issue = (a, b) => b.title.localeCompare(a.title, 'de', {numeric: true});
for (const list of grouped.values()) list.sort(issue);
}
// Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root.
if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) {
_currentBookFolder = null;
@ -339,7 +374,7 @@ function renderBookList(allBooks) {
if (folderNames.length) {
html += '<div class="book-folder-grid">'
+ folderNames.map(name => `<button class="book-folder-tile" data-folder="${escapeHtml(name)}">
<span class="book-folder-tile-icon">📁</span>
<span class="book-folder-tile-icon">${magazines ? '📰' : '📁'}</span>
<span class="book-folder-tile-name">${escapeHtml(name)}</span>
<span class="book-folder-tile-count">${grouped.get(name).length}</span>
</button>`).join('')
@ -375,6 +410,7 @@ async function _updateBookMeta(bookId, overrides) {
folder: cached.folder || book.folder || '',
shelfTag: cached.shelfTag || book.shelfTag || '',
pages: cached.pages || book.pages || 0,
magazine: cached.magazine !== undefined ? cached.magazine : book.magazine,
...overrides,
};
const key = await getOrCreateEncKey();

View file

@ -28,6 +28,8 @@
picking: false, // choosing a podcast to make an audiobook
pickQuery: '',
now: null, // playerState() of the last poll
folder: null, // the folder shown (null: all)
series: null, // the series shown (its key), inside the folder
};
const shelf = document.createElement('div');
@ -189,13 +191,7 @@
html += '<div class="ab-head"><h3 class="ab-h">Hörbücher</h3>' +
(pods.length ? '<button type="button" class="ab-link" data-ab="pick">+ Hörbuch</button>' : '') + '</div>';
if (books.length) {
html += '<div class="ab-grid">' + books.map(b =>
'<button type="button" class="ab-card" data-ab-open="' + b.feed_id + '">' +
cover(b.artwork_url, b.title, 'ab-cover') +
'<span class="ab-card-text"><span class="ab-title">' + esc(b.title) + '</span>' +
(b.author ? '<span class="ab-meta">' + esc(b.author) + '</span>' : '') +
'<span class="ab-meta">' + esc(progressText(b)) + '</span>' +
'<span class="ab-bar"><span style="width:' + pct(b) + '%"></span></span></span></button>').join('') + '</div>';
html += gridHtml(books);
} else {
html += '<p class="ab-empty">Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier ' +
'wie ein Buch hören: in Kapitelreihenfolge, mit Lesezeichen. „+ Hörbuch“ wählt einen aus.</p>';
@ -206,6 +202,131 @@
if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; }
}
// ---- organising: folders (set per book) and series (found in the titles) ----------------
// "Das Kapital – Erster Band", "Harry Potter: Teil 3", "Die drei ??? – Folge 12":
// the series' name and the volume's number, or null.
const VOLUME_WORD = /\b(band|bd\.|teil|buch|folge|staffel|episode|vol\.?|volume|part|book)\b/i;
const ORDINALS = ['erst', 'zweit', 'dritt', 'viert', 'fünft', 'sechst', 'siebt', 'acht', 'neunt', 'zehnt',
'elft', 'zwölft'];
const ORDINALS_EN = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth'];
const ROMAN = {i: 1, ii: 2, iii: 3, iv: 4, v: 5, vi: 6, vii: 7, viii: 8, ix: 9, x: 10, xi: 11, xii: 12};
function volumeNumber(text) {
const t = text.toLowerCase();
const digits = /(\d+)/.exec(t);
if (digits) return parseInt(digits[1], 10);
for (const word of t.split(/[^a-zäöüß]+/)) {
if (ROMAN[word]) return ROMAN[word];
for (let i = 0; i < ORDINALS.length; i++) if (word.indexOf(ORDINALS[i]) === 0) return i + 1;
for (let i = 0; i < ORDINALS_EN.length; i++) if (word === ORDINALS_EN[i]) return i + 1;
}
return 999;
}
function seriesOf(b) {
const m = /^(.+?)\s+[–—-]\s+(.+)$/.exec(b.title) || /^(.+?):\s+(.+)$/.exec(b.title) ||
/^(.+?)\.\s+(.+)$/.exec(b.title); // "Der Zauberberg. Erster Band"
if (!m || !(VOLUME_WORD.test(m[2]) || /^\s*\d+\s*$/.test(m[2]))) return null;
const name = m[1].trim();
return {key: (name + '|' + (b.author || '')).toLowerCase(), name, number: volumeNumber(m[2]), volume: m[2].trim()};
}
// The books of one level (all, or one folder) as cards: series of two or more volumes
// become one stack; folders come first (at the top level).
function entries(books) {
const groups = new Map();
const single = [];
books.forEach(b => {
const sr = seriesOf(b);
if (!sr) { single.push({kind: 'book', b, sort: b.title}); return; }
if (!groups.has(sr.key)) groups.set(sr.key, {kind: 'series', key: sr.key, name: sr.name, books: [], sort: sr.name});
groups.get(sr.key).books.push(Object.assign({}, b, {_volume: sr.volume, _number: sr.number}));
});
const out = single;
groups.forEach(g => {
if (g.books.length < 2) { out.push({kind: 'book', b: g.books[0], sort: g.books[0].title}); return; }
g.books.sort((x, y) => x._number - y._number || x.title.localeCompare(y.title));
out.push(g);
});
// The last listened first (as the list comes), then the rest by name.
const recent = e => {
const list = e.kind === 'series' ? e.books : [e.b];
return list.map(x => x.last_played_at || '').sort().pop() || '';
};
return out.sort((x, y) => (recent(y) > recent(x) ? 1 : recent(y) < recent(x) ? -1 : 0) ||
x.sort.localeCompare(y.sort, 'de'));
}
function bookCard(b, label) {
return '<button type="button" class="ab-card" data-ab-open="' + b.feed_id + '">' +
cover(b.artwork_url, b.title, 'ab-cover') +
'<span class="ab-card-text"><span class="ab-title">' + esc(label || b.title) + '</span>' +
(b.author && !label ? '<span class="ab-meta">' + esc(b.author) + '</span>' : '') +
'<span class="ab-meta">' + esc(progressText(b)) + '</span>' +
'<span class="ab-bar"><span style="width:' + pct(b) + '%"></span></span></span></button>';
}
function seriesCard(g) {
const total = g.books.reduce((n, b) => n + (b.total_seconds || 0), 0);
const heard = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0);
const going = g.books.filter(b => b.next && b.last_played_at)[0];
const done = g.books.filter(b => !b.next).length;
const meta = g.books.length + ' Bände' + (going ? ' · bei ' + going._volume
: done === g.books.length ? ' · fertig gehört' : done ? ' · ' + done + ' gehört' : '');
return '<button type="button" class="ab-card ab-stack" data-ab-series="' + esc(g.key) + '">' +
'<span class="ab-stack-covers">' + cover(g.books[0].artwork_url, g.name, 'ab-cover') + '</span>' +
'<span class="ab-card-text"><span class="ab-title">' + esc(g.name) + '</span>' +
(g.books[0].author ? '<span class="ab-meta">' + esc(g.books[0].author) + '</span>' : '') +
'<span class="ab-meta">' + esc(meta) + '</span>' +
'<span class="ab-bar"><span style="width:' + (total ? Math.round(100 * heard / total) : 0) + '%"></span></span></span></button>';
}
function gridHtml(books) {
const folders = new Map();
books.forEach(b => { if (b.folder) folders.set(b.folder, (folders.get(b.folder) || 0) + 1); });
if (st.folder != null && !folders.has(st.folder)) st.folder = null;
const here = books.filter(b => st.folder == null ? !b.folder : b.folder === st.folder);
const list = entries(here);
let html = '';
if (st.series != null) {
const g = list.filter(e => e.kind === 'series' && e.key === st.series)[0];
if (g) {
return '<div class="ab-place"><button type="button" class="ab-back" data-ab="up">‹ ' +
esc(st.folder == null ? 'Alle Hörbücher' : st.folder) + '</button><strong>' + esc(g.name) + '</strong></div>' +
'<div class="ab-grid">' + g.books.map(b => bookCard(b, b._volume)).join('') + '</div>';
}
st.series = null;
}
if (st.folder != null) {
html += '<div class="ab-place"><button type="button" class="ab-back" data-ab="up">‹ Alle Hörbücher</button>' +
'<strong>' + esc(st.folder) + '</strong></div>';
} else if (folders.size) {
html += '<div class="ab-folders">' + Array.from(folders.keys()).sort((a, b) => a.localeCompare(b, 'de')).map(f =>
'<button type="button" class="ab-folder" data-ab-folder="' + esc(f) + '"><span class="ab-folder-icon">▤</span>' +
'<span class="ab-title">' + esc(f) + '</span><span class="ab-meta">' + folders.get(f) + '</span></button>').join('') + '</div>';
}
return html + '<div class="ab-grid">' + list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '</div>';
}
// Into a folder ('' = none): the server keeps it with the subscription.
async function assignFolder(feedId) {
const b = (st.books || []).filter(x => x.feed_id === feedId)[0];
const names = Array.from(new Set((st.books || []).map(x => x.folder).filter(Boolean))).sort();
const ask = 'Ordner für „' + (b ? b.title : 'Hörbuch') + '“' + (names.length ? ' (vorhanden: ' + names.join(', ') + ')' : '') +
' – leer lassen für keinen:';
const name = typeof customPrompt === 'function' ? await customPrompt(ask, b ? b.folder || '' : '') : window.prompt(ask, b ? b.folder || '' : '');
if (name === null || name === undefined) return;
try {
await api('/podcasts/feeds/' + feedId + '/settings/', {folder: String(name)});
toast(String(name).trim() ? 'In „' + String(name).trim() + '“' : 'Ohne Ordner');
} catch (e) {
toast('Das braucht eine Verbindung.');
}
loadList();
if (st.open === feedId) loadDetail(feedId);
}
function pickHtml(pods) {
const q = st.pickQuery.trim().toLowerCase();
const found = pods.filter(f => !q || (f.title + ' ' + (f.author || '')).toLowerCase().indexOf(q) >= 0)
@ -273,6 +394,7 @@
'<div class="ab-menu" hidden>' +
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
'<button type="button" data-ab="played">Alles als gehört markieren</button>' +
'<button type="button" data-ab="folder">Ordner zuweisen …</button>' +
'<button type="button" data-ab="podcast">Wieder als Podcast führen</button></div>' +
'<div class="ab-hero">' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') +
'<div class="ab-hero-text"><h2 class="ab-book-title">' + esc(b.title) + '</h2>' +
@ -453,6 +575,7 @@
else if (act === 'mark') addBookmark();
else if (act === 'unplayed') markAll(false);
else if (act === 'played') markAll(true);
else if (act === 'folder') assignFolder(st.open);
else if (act === 'podcast') {
const ok = typeof customConfirm === 'function'
? await customConfirm('„' + st.detail.book.title + '“ wieder als Podcast führen? Es verlässt die Bücher; Stellen und Lesezeichen bleiben.')
@ -473,6 +596,13 @@
if (!t) return;
if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); return; }
if (t.dataset.abOpen != null) { openBook(Number(t.dataset.abOpen)); return; }
if (t.dataset.abFolder != null) { st.folder = t.dataset.abFolder; st.series = null; renderShelf(); return; }
if (t.dataset.abSeries != null) { st.series = t.dataset.abSeries; renderShelf(); return; }
if (t.dataset.ab === 'up') {
if (st.series != null) st.series = null; else st.folder = null;
renderShelf();
return;
}
if (t.dataset.abMark != null) {
const id = Number(t.dataset.abMark);
st.picking = false;
@ -549,6 +679,11 @@
return true;
}
if (st.picking && tab.style.display !== 'none') { st.picking = false; renderShelf(); return true; }
if ((st.series != null || st.folder != null) && tab.style.display !== 'none') {
if (st.series != null) st.series = null; else st.folder = null;
renderShelf();
return true;
}
return back ? back() : false;
};
document.addEventListener('keydown', e => {
@ -616,6 +751,17 @@
'.ab-chapter.ab-done .ab-title { color: var(--muted, #9a9a9a); }',
'.ab-chapter.ab-now .ab-title, .ab-chapter.ab-now .ab-num { color: var(--accent, #e63946); }',
'.ab-mark, .ab-chrow { display: flex; align-items: center; }',
'.ab-place { display: flex; align-items: center; gap: 12px; margin: 0 0 10px; }',
'.ab-place .ab-back { padding: 6px 0; }',
'.ab-folders { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin-bottom: 14px; }',
'.ab-folder { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); color: inherit; font: inherit; cursor: pointer; text-align: left; }',
'.ab-folder .ab-title { flex: 1; min-width: 0; }',
'.ab-folder-icon { color: var(--muted, #9a9a9a); }',
'.ab-stack-covers { position: relative; display: block; }',
'.ab-stack-covers::before, .ab-stack-covers::after { content: ""; position: absolute; left: 6%; right: 6%; height: 100%; border-radius: 6px; border: 1px solid var(--border-strong, #444); background: var(--panel, #161616); }',
'.ab-stack-covers::before { top: -8px; left: 12%; right: 12%; }',
'.ab-stack-covers::after { top: -4px; }',
'.ab-stack-covers .ab-cover { position: relative; z-index: 1; }',
'.ab-chrow .ab-row { flex: 1; min-width: 0; }',
'.ab-chrow { border-bottom: 1px solid var(--border, #2a2a2a); }',
'.ab-chrow .ab-row { border-bottom: none; }',

View file

@ -76,7 +76,7 @@
const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed'];
const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume',
'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_download', 'notify',
'episode_sort', 'episode_filter', 'skip_silence', 'kind'];
'episode_sort', 'episode_filter', 'skip_silence', 'kind', 'folder'];
// The account's playback settings (diora-web PodcastSettings) and what each may be.
const SKIPS = [5, 10, 15, 20, 30, 45, 60];
const MIN_SPEED = 0.5;

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v117';
const CACHE = 'diora-v118';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',

View file

@ -1,4 +1,4 @@
// Copied from diora-android 50c6f9c 2026-10-16: web-native/static/native-podcast.js
// Copied from diora-android 3eed256 2026-10-16: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js.
@ -1801,7 +1801,7 @@
const items = podcastFeeds().slice().sort((a, b) => sort.cmp(a, b) || byTitle(a, b));
const books = feeds().length - items.length;
const booksHint = () => el('div', 'nui-pod-hint',
`${books === 1 ? 'Ein Hörbuch' : books + ' Hörbücher'}: in Bücher → Hörbücher`);
`${books === 1 ? 'Ein Hörbuch' : books + ' Hörbücher'}: unter Hören → Hörbücher`);
if (!items.length) {
empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
if (books) root.append(booksHint());
@ -2005,8 +2005,8 @@
// An audiobook: its episodes are chapters, listened to in the library (diora-web docs/audiobooks.md).
if ('kind' in live && N.podcastFeedSettings) {
const book = isBook(live);
items.push({icon: '▤', label: book ? 'Hörbuch (in Bücher)' : 'Als Hörbuch',
hint: book ? 'tippen: wieder als Podcast' : 'Folgen als Kapitel in der Bibliothek, nicht in „Neu“',
items.push({icon: '▤', label: book ? 'Hörbuch (unter Hören → Hörbücher)' : 'Als Hörbuch',
hint: book ? 'tippen: wieder als Podcast' : 'Folgen als Kapitel unter Hören → Hörbücher, nicht in „Neu“',
active: book, action: () => save({kind: book ? 'podcast' : 'audiobook'})});
}
// Skip silence for this podcast (server), where this player can skip silence at all.

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 50c6f9c 2026-10-16: the podcast parts of web-native/static/native.css
/* Copied from diora-android 3eed256 2026-10-16: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */