From 12b651b7727095d4aa79f8acf13fd65721ce69cf Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Fri, 16 Oct 2026 14:08:44 +0200 Subject: [PATCH] =?UTF-8?q?H=C3=B6rb=C3=BCcher:=20Kapitel=20einzeln=20als?= =?UTF-8?q?=20(nicht)=20geh=C3=B6rt,=20Reihenfolge=20nach=20Korrekturen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- podcasts/test_audiobooks.py | 54 +++++++++++++++++++++++++++++++++++++ podcasts/tests.py | 3 ++- podcasts/views.py | 43 ++++++++++++++++++++--------- static/js/audiobooks.js | 32 +++++++++++++++++++--- static/js/sw.js | 2 +- 5 files changed, 116 insertions(+), 18 deletions(-) diff --git a/podcasts/test_audiobooks.py b/podcasts/test_audiobooks.py index da9014d..aa4631d 100644 --- a/podcasts/test_audiobooks.py +++ b/podcasts/test_audiobooks.py @@ -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': 'x'}).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 = ('Buch' + 'aErstes KapitelWed, 14 Oct 2026 21:45:54 GMT' + '') + + 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 diff --git a/podcasts/tests.py b/podcasts/tests.py index a576f99..a11bfb4 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -1706,7 +1706,8 @@ class FeedRefreshTests(TestCase): content_type='application/json') self.assertEqual(r.status_code, 502) 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.assertEqual((self.feed.last_error, self.feed.error_count), ('not a feed', 1)) diff --git a/podcasts/views.py b/podcasts/views.py index c67913f..5320b31 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -249,8 +249,10 @@ def _entry_transcripts(entry): 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. + `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 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. """ changed = [] + for name, value in (renamed or {}).items(): + if getattr(episode, name) != value: + setattr(episode, name, value) + changed.append(name) if episode.description != description: episode.description = description changed.append('description') @@ -282,7 +288,8 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts) 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) 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 if entry.get('published_parsed'): import datetime as dt @@ -629,6 +627,18 @@ def _apply_feed(feed_obj, fetched, full=False): except (ValueError, TypeError): 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 try: season_number = int(entry.get('itunes_season', '')) @@ -1113,6 +1123,8 @@ def feed_settings(request, pk): feed.save(update_fields=list(changes)) 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))) + # 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)) @@ -1693,6 +1705,9 @@ def refresh_feed_now(request): except PodcastFeed.DoesNotExist: 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: new_episodes = _refresh_feed(feed) except Exception as e: @@ -2066,7 +2081,11 @@ def mark_played(request): episode=episode, ) 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}) diff --git a/static/js/audiobooks.js b/static/js/audiobooks.js index 7e9ddd8..7b258b6 100644 --- a/static/js/audiobooks.js +++ b/static/js/audiobooks.js @@ -307,10 +307,13 @@ 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 meta = dur ? hours(dur) : ''; - return ''; + '' + esc(meta) + '' + + ''; } // 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; const ch = 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; - 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)); setTimeout(poll, 400); } @@ -413,6 +417,20 @@ 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) { const h = host(); const d = st.detail; @@ -442,6 +460,7 @@ setKind(st.open, 'podcast'); 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.abBm != null) playBookmark(Number(t.dataset.abBm)); 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-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 { 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-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; }', diff --git a/static/js/sw.js b/static/js/sw.js index ed72e60..1b3e3d8 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v113'; +const CACHE = 'diora-v114'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',