Bibliothek ordnen (Navigation Phase 3): Hörbuch-Reihen und -Ordner, Zeitschriften je Heft
- 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:
parent
143950df94
commit
d7930fcdb9
13 changed files with 246 additions and 27 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
18
podcasts/migrations/0020_audiobook_folder.py
Normal file
18
podcasts/migrations/0020_audiobook_folder.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 & 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();
|
||||
|
|
|
|||
|
|
@ -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; }',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue