Hörbücher: Kapitel einzeln als (nicht) gehört, Reihenfolge nach Korrekturen
- 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:
parent
5be1b3fbaf
commit
12b651b772
5 changed files with 116 additions and 18 deletions
|
|
@ -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 = ('<?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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 '<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>' +
|
||||
'<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`).
|
||||
|
|
@ -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; }',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue