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 '' +
+ '' + (c.played ? '✓' : '○') + '';
}
// 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',