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. 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 2. "Abos & Quellen": podcasts, YouTube channels, news sources, blogs and the
audiobook mark in one place; the everyday views without managing. audiobook mark in one place; the everyday views without managing.
3. Library: audiobook series (e.g. Das Kapital, Band 1–3) and folders, 3. Library (done): audiobook series are found in the titles ("Das Kapital –
magazines marked per book. 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) nxt = _next(chapters)
return { return {
'feed_id': feed.id, 'title': feed.title, 'author': feed.author, 'artwork_url': feed.artwork_url, '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, '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, '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 in this podcast's episodes: null = as the device is set.
skip_silence = models.BooleanField(null=True, blank=True) skip_silence = models.BooleanField(null=True, blank=True)
kind = models.CharField(max_length=10, choices=KINDS, default=PODCAST) 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 # The vocabulary of episode_sort and episode_filter: the podcast page's
# sort menu and filter chips (native-podcast.js EP_SORTS and FILTERS). # 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') content_type='application/json')
self.assertEqual(EpisodeProgress.objects.get(episode=ep).position_seconds, 0) self.assertEqual(EpisodeProgress.objects.get(episode=ep).position_seconds, 0)
self.assertEqual(EpisodeProgress.objects.get(episode=half).position_seconds, 200) # its place stays 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', '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, 'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False,
'notify': False, 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', '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, '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, 'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10,
'auto_queue': False, 'auto_download': False, 'notify': False, '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.feed.refresh_from_db()
self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration), 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 = ( FEED_SETTING_FIELDS = (
'playback_speed', 'skip_intro', 'skip_outro', 'volume', 'playback_speed', 'skip_intro', 'skip_outro', 'volume',
'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download', '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_sort': _episode_sort,
'episode_filter': _episode_filter, 'episode_filter': _episode_filter,
'kind': _kind, 'kind': _kind,
'folder': lambda v: ' '.join(str(v or '').split())[:100],
'skip_silence': _optional_boolean, 'skip_silence': _optional_boolean,
} }

View file

@ -41,8 +41,8 @@ async function loadBookList() {
try { try {
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
const meta = JSON.parse(new TextDecoder().decode(metaBuf)); 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}; 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, 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}); 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) { } catch (e) {
bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; 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}); 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); const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress);
if (cachedBooks.length) { if (cachedBooks.length) {
cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || '')); 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'); const uploadArea = $('book-upload-area');
if (uploadArea) uploadArea.style.display = 'none'; if (uploadArea) uploadArea.style.display = 'none';
renderBookList(cachedBooks); renderBookList(cachedBooks);
@ -145,6 +145,7 @@ function repairBook(bookId) {
isbn: isbn || prevMeta.isbn || '', isbn: isbn || prevMeta.isbn || '',
folder: prevMeta.folder || '', folder: prevMeta.folder || '',
shelfTag: prevMeta.shelfTag || '', shelfTag: prevMeta.shelfTag || '',
magazine: prevMeta.magazine,
})); }));
const [metaEnc, dataEnc] = await Promise.all([ const [metaEnc, dataEnc] = await Promise.all([
encryptBytes(key, metaJson), encryptBytes(key, metaJson),
@ -205,11 +206,33 @@ async function toggleBookRead(bookId, currentlyRead) {
// free in /api/sync/. One level only: folders don't nest. // free in /api/sync/. One level only: folders don't nest.
let _currentBookFolder = null; // null = root view (folder tiles + unfiled books) 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 // Bücher and Zeitschriften are two sections of Lesen (static/js/nav.js), one list: a
// magazines are the books in a folder called "Zeitschriften" (or "Magazine"). // 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'; let _bookSection = 'books';
const _MAGAZINE_FOLDER = /^(zeitschriften|magazine|magazines)$/i; 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). // The books of the section shown (all of them where there are no sections).
function _bookSectionFilter(books) { 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>` : ''} ${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>` : ''} ${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="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="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" 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> <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) { if (!books.length) {
listEl.innerHTML = window.DioraNav && allBooks.length listEl.innerHTML = window.DioraNav && allBooks.length
? (_bookSection === 'magazines' ? (_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>') : '<p class="muted">Alle Bücher liegen bei den Zeitschriften.</p>')
: ''; : '';
return; return;
@ -324,11 +348,22 @@ function renderBookList(allBooks) {
} }
const grouped = new Map(); const grouped = new Map();
const magazines = _bookSection === 'magazines' && !!window.DioraNav;
for (const b of visible) { 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, []); if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(b); 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. // Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root.
if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) { if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) {
_currentBookFolder = null; _currentBookFolder = null;
@ -339,7 +374,7 @@ function renderBookList(allBooks) {
if (folderNames.length) { if (folderNames.length) {
html += '<div class="book-folder-grid">' html += '<div class="book-folder-grid">'
+ folderNames.map(name => `<button class="book-folder-tile" data-folder="${escapeHtml(name)}"> + 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-name">${escapeHtml(name)}</span>
<span class="book-folder-tile-count">${grouped.get(name).length}</span> <span class="book-folder-tile-count">${grouped.get(name).length}</span>
</button>`).join('') </button>`).join('')
@ -375,6 +410,7 @@ async function _updateBookMeta(bookId, overrides) {
folder: cached.folder || book.folder || '', folder: cached.folder || book.folder || '',
shelfTag: cached.shelfTag || book.shelfTag || '', shelfTag: cached.shelfTag || book.shelfTag || '',
pages: cached.pages || book.pages || 0, pages: cached.pages || book.pages || 0,
magazine: cached.magazine !== undefined ? cached.magazine : book.magazine,
...overrides, ...overrides,
}; };
const key = await getOrCreateEncKey(); const key = await getOrCreateEncKey();

View file

@ -28,6 +28,8 @@
picking: false, // choosing a podcast to make an audiobook picking: false, // choosing a podcast to make an audiobook
pickQuery: '', pickQuery: '',
now: null, // playerState() of the last poll 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'); const shelf = document.createElement('div');
@ -189,13 +191,7 @@
html += '<div class="ab-head"><h3 class="ab-h">Hörbücher</h3>' + 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>'; (pods.length ? '<button type="button" class="ab-link" data-ab="pick">+ Hörbuch</button>' : '') + '</div>';
if (books.length) { if (books.length) {
html += '<div class="ab-grid">' + books.map(b => html += gridHtml(books);
'<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>';
} else { } else {
html += '<p class="ab-empty">Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier ' + 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>'; '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; } 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) { function pickHtml(pods) {
const q = st.pickQuery.trim().toLowerCase(); const q = st.pickQuery.trim().toLowerCase();
const found = pods.filter(f => !q || (f.title + ' ' + (f.author || '')).toLowerCase().indexOf(q) >= 0) const found = pods.filter(f => !q || (f.title + ' ' + (f.author || '')).toLowerCase().indexOf(q) >= 0)
@ -273,6 +394,7 @@
'<div class="ab-menu" hidden>' + '<div class="ab-menu" hidden>' +
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' + '<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="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>' + '<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">' + 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>' + '<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 === 'mark') addBookmark();
else if (act === 'unplayed') markAll(false); else if (act === 'unplayed') markAll(false);
else if (act === 'played') markAll(true); else if (act === 'played') markAll(true);
else if (act === 'folder') assignFolder(st.open);
else if (act === 'podcast') { else if (act === 'podcast') {
const ok = typeof customConfirm === 'function' 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.') ? 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) return;
if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); 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.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) { if (t.dataset.abMark != null) {
const id = Number(t.dataset.abMark); const id = Number(t.dataset.abMark);
st.picking = false; st.picking = false;
@ -549,6 +679,11 @@
return true; return true;
} }
if (st.picking && tab.style.display !== 'none') { st.picking = false; renderShelf(); 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; return back ? back() : false;
}; };
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
@ -616,6 +751,17 @@
'.ab-chapter.ab-done .ab-title { color: var(--muted, #9a9a9a); }', '.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-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-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 .ab-row { flex: 1; min-width: 0; }',
'.ab-chrow { border-bottom: 1px solid var(--border, #2a2a2a); }', '.ab-chrow { border-bottom: 1px solid var(--border, #2a2a2a); }',
'.ab-chrow .ab-row { border-bottom: none; }', '.ab-chrow .ab-row { border-bottom: none; }',

View file

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

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v117'; const CACHE = 'diora-v118';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/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 // 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. // 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 items = podcastFeeds().slice().sort((a, b) => sort.cmp(a, b) || byTitle(a, b));
const books = feeds().length - items.length; const books = feeds().length - items.length;
const booksHint = () => el('div', 'nui-pod-hint', 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) { if (!items.length) {
empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.'); empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
if (books) root.append(booksHint()); 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). // An audiobook: its episodes are chapters, listened to in the library (diora-web docs/audiobooks.md).
if ('kind' in live && N.podcastFeedSettings) { if ('kind' in live && N.podcastFeedSettings) {
const book = isBook(live); const book = isBook(live);
items.push({icon: '▤', label: book ? 'Hörbuch (in Bücher)' : 'Als Hörbuch', 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 in der Bibliothek, nicht in „Neu“', 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'})}); active: book, action: () => save({kind: book ? 'podcast' : 'audiobook'})});
} }
// Skip silence for this podcast (server), where this player can skip silence at all. // 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. (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */ The web's own additions are in podcasts-web.css. */