Hörbücher: Kapitel einzeln als (nicht) gehört, Reihenfolge nach Korrekturen
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 1m54s

- Jedes Kapitel hat ✓/○ zum Umschalten. Ein bis zum Ende gehörtes, das wieder
  als nicht gehört gilt, beginnt von vorn (mark-played setzt die Stelle zurück).
- Feeds, die Titel, Datum oder Nummer schon bekannter Folgen korrigieren, werden
  übernommen: davon hängt die Kapitelreihenfolge ab (Das Kapital, Erster Band,
  hatte Daten aus einer älteren Fassung des Feeds). Ein Aktualisieren von Hand
  und das Markieren als Hörbuch lesen den Feed dafür ganz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-16 14:08:44 +02:00
parent 5be1b3fbaf
commit 12b651b772
5 changed files with 116 additions and 18 deletions

View file

@ -133,3 +133,57 @@ class AudiobookTests(TestCase):
self.assertEqual(self.post('/podcasts/bookmarks/', {'episode_id': ep.pk, 'position_ms': -1}).status_code, 400) self.assertEqual(self.post('/podcasts/bookmarks/', {'episode_id': ep.pk, 'position_ms': -1}).status_code, 400)
self.assertEqual(self.post('/podcasts/bookmarks/', {'episode_id': ep.pk, 'position_ms': 'x'}).status_code, 400) self.assertEqual(self.post('/podcasts/bookmarks/', {'episode_id': ep.pk, 'position_ms': 'x'}).status_code, 400)
self.assertEqual(self.post('/podcasts/bookmarks/', {'episode_id': ep.pk, 'position_ms': 1, 'note': 5}).status_code, 400) self.assertEqual(self.post('/podcasts/bookmarks/', {'episode_id': ep.pk, 'position_ms': 1, 'note': 5}).status_code, 400)
class FeedCorrectionTests(TestCase):
"""A feed that corrects titles and dates of episodes it had already (an audiobook's
order depends on them): a refresh takes them over, a refresh by hand even when the
feed looks unchanged."""
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/book', title='Buch',
last_refreshed_at=T0, http_body_hash='same')
PodcastEpisode.objects.create(feed=self.feed, guid='a', title='010 - Erstes Kapitel', pub_date=T0,
audio_url='https://example.org/a.mp3')
RSS = ('<?xml version="1.0"?><rss version="2.0"><channel><title>Buch</title>'
'<item><guid>a</guid><title>Erstes Kapitel</title><pubDate>Wed, 14 Oct 2026 21:45:54 GMT</pubDate>'
'<enclosure url="https://example.org/a.mp3" type="audio/mpeg" length="1"/></item></channel></rss>')
def test_a_refresh_by_hand_takes_over_corrected_titles_and_dates(self):
seen = {}
def fetch(url, **kw):
seen.update(kw)
if kw.get('body_hash') == 'same': # what an unchanged feed gets: nothing parsed
return FeedFetch(304, None, body_hash='same')
return FeedFetch(200, feedparser.parse(self.RSS), body_hash='same')
with patch('podcasts.views._fetch_feed', fetch):
r = self.client.post('/podcasts/feeds/refresh/', json.dumps({'feed_id': self.feed.pk}),
content_type='application/json')
self.assertEqual(r.status_code, 200, r.content)
self.assertEqual(seen.get('body_hash', ''), '')
ep = PodcastEpisode.objects.get(guid='a')
self.assertEqual(ep.title, 'Erstes Kapitel')
self.assertEqual(ep.pub_date, datetime(2026, 10, 14, 21, 45, 54, tzinfo=timezone.utc))
self.assertEqual(PodcastEpisode.objects.count(), 1)
class UnplayedTests(TestCase):
def test_a_chapter_heard_to_the_end_starts_over_once_not_heard(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')
ep = PodcastEpisode.objects.create(feed=feed, guid='x', title='x', audio_url='https://example.org/x.mp3',
duration_seconds=600)
half = PodcastEpisode.objects.create(feed=feed, guid='y', title='y', audio_url='https://example.org/y.mp3',
duration_seconds=600)
EpisodeProgress.objects.create(user=user, episode=ep, position_seconds=598, played=True)
EpisodeProgress.objects.create(user=user, episode=half, position_seconds=200, played=True)
for e in (ep, half):
self.client.post('/podcasts/progress/mark-played/', json.dumps({'episode_id': e.pk, 'played': False}),
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

View file

@ -1706,7 +1706,8 @@ class FeedRefreshTests(TestCase):
content_type='application/json') content_type='application/json')
self.assertEqual(r.status_code, 502) self.assertEqual(r.status_code, 502)
self.assertEqual(r.json()['error'], 'not a feed') self.assertEqual(r.json()['error'], 'not a feed')
self.assertEqual(self.calls, [('https://example.org/feed', self.V)]) # By hand: the whole feed, without the validators (corrections to known episodes come in).
self.assertEqual(self.calls, [('https://example.org/feed', {})])
self.feed.refresh_from_db() self.feed.refresh_from_db()
self.assertEqual((self.feed.last_error, self.feed.error_count), ('not a feed', 1)) self.assertEqual((self.feed.last_error, self.feed.error_count), ('not a feed', 1))

View file

@ -249,8 +249,10 @@ def _entry_transcripts(entry):
return transcripts return transcripts
def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts): def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts, renamed=None):
"""Copy what the feed says now onto `episode`; True if anything changed. """Copy what the feed says now onto `episode`; True if anything changed.
`renamed`: its title, date and number as the feed has them now (a feed may correct
them; an audiobook's chapter order depends on them).
Inline (Podlove) chapters win. Without them, fetched chapters (from Inline (Podlove) chapters win. Without them, fetched chapters (from
chapters_url, or from the audio file when there's no URL) stay cached for chapters_url, or from the audio file when there's no URL) stay cached for
@ -258,6 +260,10 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts)
fresh fetch. fresh fetch.
""" """
changed = [] changed = []
for name, value in (renamed or {}).items():
if getattr(episode, name) != value:
setattr(episode, name, value)
changed.append(name)
if episode.description != description: if episode.description != description:
episode.description = description episode.description = description
changed.append('description') changed.append('description')
@ -282,7 +288,8 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts)
return bool(changed) return bool(changed)
EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at'] EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
'title', 'pub_date', 'episode_number']
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -608,15 +615,6 @@ def _apply_feed(feed_obj, fetched, full=False):
psc_chapters = chapters_mod.from_psc(entry) psc_chapters = chapters_mod.from_psc(entry)
transcripts = _entry_transcripts(entry) transcripts = _entry_transcripts(entry)
known = existing.get(guid)
if known is not None:
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts):
changed_episodes[known.id] = known
continue
duration_raw = entry.get('itunes_duration', 0)
duration_seconds = _parse_duration(duration_raw)
pub_date = None pub_date = None
if entry.get('published_parsed'): if entry.get('published_parsed'):
import datetime as dt import datetime as dt
@ -629,6 +627,18 @@ def _apply_feed(feed_obj, fetched, full=False):
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
known = existing.get(guid)
if known is not None:
renamed = {'title': title, 'episode_number': ep_number}
if pub_date is not None:
renamed['pub_date'] = pub_date
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed):
changed_episodes[known.id] = known
continue
duration_raw = entry.get('itunes_duration', 0)
duration_seconds = _parse_duration(duration_raw)
season_number = None season_number = None
try: try:
season_number = int(entry.get('itunes_season', '')) season_number = int(entry.get('itunes_season', ''))
@ -1113,6 +1123,8 @@ def feed_settings(request, pk):
feed.save(update_fields=list(changes)) feed.save(update_fields=list(changes))
if became_book: # an audiobook's chapters aren't news (docs/audiobooks.md) if became_book: # an audiobook's chapters aren't news (docs/audiobooks.md)
_leave_inbox(request.user, list(feed.episodes.values_list('id', flat=True))) _leave_inbox(request.user, list(feed.episodes.values_list('id', flat=True)))
# Its order depends on titles and dates: the next refresh reads the feed whole.
PodcastFeed.objects.filter(pk=feed.pk).update(http_etag='', http_last_modified='', http_body_hash='')
return JsonResponse(_feed_settings(feed)) return JsonResponse(_feed_settings(feed))
@ -1693,6 +1705,9 @@ def refresh_feed_now(request):
except PodcastFeed.DoesNotExist: except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404) return JsonResponse({'error': 'not found'}, status=404)
# Asked for by hand: read it whole even if it looks unchanged (validators, body hash),
# so corrections to episodes already known come in too.
feed.http_etag = feed.http_last_modified = feed.http_body_hash = ''
try: try:
new_episodes = _refresh_feed(feed) new_episodes = _refresh_feed(feed)
except Exception as e: except Exception as e:
@ -2066,7 +2081,11 @@ def mark_played(request):
episode=episode, episode=episode,
) )
progress.played = played progress.played = played
progress.save(update_fields=['played', 'updated_at']) fields = ['played', 'updated_at']
if not played and counts_as_played(progress.position_seconds, episode.duration_seconds):
progress.position_seconds = 0 # heard to the end, now "not heard": from the start again
fields.append('position_seconds')
progress.save(update_fields=fields)
return JsonResponse({'ok': True, 'played': played}) return JsonResponse({'ok': True, 'played': played})

View file

@ -307,10 +307,13 @@
else if (playing && now.dur) meta = clock(now.pos) + ' / ' + clock(now.dur); else if (playing && now.dur) meta = clock(now.pos) + ' / ' + clock(now.dur);
else if (c.position_seconds > 0 && dur) meta = 'noch ' + hours(dur - c.position_seconds); else if (c.position_seconds > 0 && dur) meta = 'noch ' + hours(dur - c.position_seconds);
else meta = dur ? hours(dur) : ''; else meta = dur ? hours(dur) : '';
return '<button type="button" class="ab-row ab-chapter' + (c.played ? ' ab-done' : '') + (playing ? ' ab-now' : '') + return '<div class="ab-chrow"><button type="button" class="ab-row ab-chapter' + (c.played ? ' ab-done' : '') + (playing ? ' ab-now' : '') +
'" data-ab-ch="' + c.index + '"><span class="ab-num">' + (playing ? '♪' : (c.index + 1)) + '</span>' + '" data-ab-ch="' + c.index + '"><span class="ab-num">' + (playing ? '♪' : (c.index + 1)) + '</span>' +
'<span class="ab-card-text"><span class="ab-title">' + esc(c.episode__title) + '</span>' + '<span class="ab-card-text"><span class="ab-title">' + esc(c.episode__title) + '</span>' +
'<span class="ab-meta">' + esc(meta) + '</span></span></button>'; '<span class="ab-meta">' + esc(meta) + '</span></span></button>' +
'<button type="button" class="ab-icon ab-tick' + (c.played ? ' ab-ticked' : '') + '" data-ab-played="' + c.index + '" ' +
'aria-label="' + (c.played ? 'Als nicht gehört markieren' : 'Als gehört markieren') + '" ' +
'title="' + (c.played ? 'Als nicht gehört markieren' : 'Als gehört markieren') + '">' + (c.played ? '✓' : '○') + '</button></div>';
} }
// Play the open book's chapter `index` (from its own place, or from `ms`). // Play the open book's chapter `index` (from its own place, or from `ms`).
@ -320,8 +323,9 @@
if (!h || !d || !d.chapters[index]) return; if (!h || !d || !d.chapters[index]) return;
const ch = d.chapters[index]; const ch = d.chapters[index];
const body = {feed_id: d.book.feed_id, chapters: d.chapters, index}; const body = {feed_id: d.book.feed_id, chapters: d.chapters, index};
const dur = ch.episode__duration_seconds || 0;
if (ms != null) body.position_ms = ms; if (ms != null) body.position_ms = ms;
else if (ch.played) body.position_ms = 0; // heard already: from its start else if (ch.played || (dur && ch.position_seconds >= dur - 30)) body.position_ms = 0; // heard already: from its start
h.podcastPlayBook(JSON.stringify(body)); h.podcastPlayBook(JSON.stringify(body));
setTimeout(poll, 400); setTimeout(poll, 400);
} }
@ -413,6 +417,20 @@
if (ch) play(ch.index, m.position_ms); if (ch) play(ch.index, m.position_ms);
} }
// One chapter heard or not: at once here, the host tells the server.
function markChapter(index) {
const h = host();
const d = st.detail;
const ch = d && d.chapters[index];
if (!h || !ch) return;
ch.played = !ch.played;
const dur = ch.episode__duration_seconds || 0;
if (!ch.played && dur && ch.position_seconds >= dur - 30) ch.position_seconds = 0; // from the start again
h.podcastMarkPlayedAll(JSON.stringify([ch.episode__id]), ch.played);
renderView();
setTimeout(() => loadDetail(st.open), 1500);
}
function markAll(played) { function markAll(played) {
const h = host(); const h = host();
const d = st.detail; const d = st.detail;
@ -442,6 +460,7 @@
setKind(st.open, 'podcast'); setKind(st.open, 'podcast');
closeBook(); closeBook();
} }
else if (t.dataset.abPlayed != null) markChapter(Number(t.dataset.abPlayed));
else if (t.dataset.abCh != null) play(Number(t.dataset.abCh)); else if (t.dataset.abCh != null) play(Number(t.dataset.abCh));
else if (t.dataset.abBm != null) playBookmark(Number(t.dataset.abBm)); else if (t.dataset.abBm != null) playBookmark(Number(t.dataset.abBm));
else if (t.dataset.abBmEdit != null) editBookmark(Number(t.dataset.abBmEdit)); else if (t.dataset.abBmEdit != null) editBookmark(Number(t.dataset.abBmEdit));
@ -590,7 +609,12 @@
'.ab-num { flex: none; width: 28px; text-align: right; color: var(--muted, #9a9a9a); font-size: 13px; }', '.ab-num { flex: none; width: 28px; text-align: right; color: var(--muted, #9a9a9a); font-size: 13px; }',
'.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 { display: flex; align-items: center; }', '.ab-mark, .ab-chrow { display: flex; align-items: center; }',
'.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; }',
'.ab-tick { color: var(--muted, #9a9a9a); font-size: 17px; }',
'.ab-tick.ab-ticked { color: var(--accent, #e63946); }',
'.ab-mark .ab-row { flex: 1; min-width: 0; }', '.ab-mark .ab-row { flex: 1; min-width: 0; }',
'#ab-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 2500; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }', '#ab-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 2500; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }',
'#ab-toast.ab-show { opacity: 1; }', '#ab-toast.ab-show { opacity: 1; }',

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-v113'; const CACHE = 'diora-v114';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/static/js/app/01-core.js',