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',