- Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“ (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021, übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge (/feeds/folders/order/). - Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +; Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte; Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste. - Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie auf E-Ink. - Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor, Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie, Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster, Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau und Details – einzigartig, überall gleich, nichts gespeichert. - Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
14 KiB
Python
234 lines
14 KiB
Python
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import patch
|
|
|
|
import feedparser
|
|
from django.contrib.auth.models import User
|
|
from django.test import TestCase
|
|
|
|
from .models import EpisodeBookmark, EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue
|
|
from .views import FeedFetch, _refresh_feed
|
|
|
|
T0 = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
|
class AudiobookTests(TestCase):
|
|
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='Das Buch',
|
|
author='A. Autor', last_refreshed_at=T0)
|
|
# Out of order on purpose: the same date, chapter numbers only in the titles.
|
|
self.eps = {}
|
|
for title, dur in [('Kapitel 10', 600), ('Kapitel 2', 300), ('Kapitel 1', 100), ('Vorwort', 0)]:
|
|
self.eps[title] = PodcastEpisode.objects.create(
|
|
feed=self.feed, guid=title, title=title, audio_url=f'https://example.org/{title}.mp3',
|
|
duration_seconds=dur, pub_date=T0 - (timedelta(days=1) if title == 'Vorwort' else timedelta(0)))
|
|
|
|
def post(self, url, body):
|
|
return self.client.post(url, json.dumps(body), content_type='application/json')
|
|
|
|
def mark(self):
|
|
r = self.post(f'/podcasts/feeds/{self.feed.pk}/settings/', {'kind': 'audiobook'})
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
self.assertEqual(r.json()['kind'], 'audiobook')
|
|
|
|
def test_marking_takes_its_episodes_out_of_neu_without_playing_them(self):
|
|
self.mark()
|
|
self.assertIsNone(self.client.get('/podcasts/audiobooks/').json()['books'][0]['last_played_at'])
|
|
self.assertEqual(EpisodeProgress.objects.filter(episode__feed=self.feed, dismissed=True).count(), 4)
|
|
self.assertFalse(EpisodeProgress.objects.filter(played=True).exists())
|
|
|
|
def test_an_unknown_kind_is_refused(self):
|
|
r = self.post(f'/podcasts/feeds/{self.feed.pk}/settings/', {'kind': 'radio'})
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
def test_chapters_in_order_and_one_progress_for_the_book(self):
|
|
self.mark()
|
|
EpisodeProgress.objects.filter(episode=self.eps['Vorwort']).update(played=True)
|
|
EpisodeProgress.objects.filter(episode=self.eps['Kapitel 1']).update(played=True)
|
|
EpisodeProgress.objects.filter(episode=self.eps['Kapitel 2']).update(position_seconds=120)
|
|
d = self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').json()
|
|
self.assertEqual([c['episode__title'] for c in d['chapters']], ['Vorwort', 'Kapitel 1', 'Kapitel 2', 'Kapitel 10'])
|
|
self.assertEqual([c['index'] for c in d['chapters']], [0, 1, 2, 3])
|
|
book = d['book']
|
|
self.assertEqual((book['chapters'], book['done'], book['total_seconds'], book['heard_seconds']), (4, 2, 1000, 220))
|
|
self.assertEqual(book['next'], {'episode_id': self.eps['Kapitel 2'].pk, 'index': 2, 'position_seconds': 120})
|
|
listed = self.client.get('/podcasts/audiobooks/').json()['books']
|
|
self.assertEqual([b['feed_id'] for b in listed], [self.feed.pk])
|
|
|
|
def test_it_goes_on_where_it_was_listened_to_last_not_at_a_skipped_chapter(self):
|
|
self.mark()
|
|
# Started at Kapitel 1 (the foreword skipped), finished it, now in Kapitel 2.
|
|
EpisodeProgress.objects.filter(episode=self.eps['Kapitel 1']).update(played=True, position_seconds=100, updated_at=T0 + timedelta(hours=1))
|
|
EpisodeProgress.objects.filter(episode=self.eps['Kapitel 2']).update(position_seconds=30, updated_at=T0 + timedelta(hours=2))
|
|
nxt = self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').json()['book']['next']
|
|
self.assertEqual(nxt['episode_id'], self.eps['Kapitel 2'].pk)
|
|
# Kapitel 2 finished as well (last): on with Kapitel 10, not back to the foreword.
|
|
EpisodeProgress.objects.filter(episode=self.eps['Kapitel 2']).update(played=True, updated_at=T0 + timedelta(hours=3))
|
|
nxt = self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').json()['book']['next']
|
|
self.assertEqual(nxt['episode_id'], self.eps['Kapitel 10'].pk)
|
|
# The end reached with the foreword still open: that one.
|
|
EpisodeProgress.objects.filter(episode=self.eps['Kapitel 10']).update(played=True, updated_at=T0 + timedelta(hours=4))
|
|
nxt = self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').json()['book']['next']
|
|
self.assertEqual(nxt['episode_id'], self.eps['Vorwort'].pk)
|
|
|
|
def test_a_podcast_is_no_audiobook(self):
|
|
self.assertEqual(self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').status_code, 404)
|
|
self.assertEqual(self.client.get('/podcasts/audiobooks/').json()['books'], [])
|
|
|
|
def test_a_finished_book_has_no_next(self):
|
|
self.mark()
|
|
EpisodeProgress.objects.filter(episode__feed=self.feed).update(played=True)
|
|
self.assertIsNone(self.client.get('/podcasts/audiobooks/').json()['books'][0]['next'])
|
|
|
|
def test_audiobooks_stay_out_of_folgen(self):
|
|
self.mark()
|
|
self.assertEqual(self.client.get('/podcasts/episodes/').json()['episodes'], [])
|
|
d = self.client.get('/podcasts/episodes/', {'feed_id': self.feed.pk}).json()
|
|
self.assertEqual(len(d['episodes']), 4) # in its own list they are
|
|
|
|
def test_new_chapters_never_go_to_neu_or_the_queue(self):
|
|
self.feed.new_episodes = PodcastFeed.NEW_QUEUE
|
|
self.feed.save()
|
|
self.mark()
|
|
rss = ('<?xml version="1.0"?><rss version="2.0"><channel><title>Das Buch</title>'
|
|
'<item><guid>neu</guid><title>Kapitel 11</title><pubDate>Mon, 05 Jan 2026 10:00:00 GMT</pubDate>'
|
|
'<enclosure url="https://example.org/11.mp3" type="audio/mpeg" length="1"/></item></channel></rss>')
|
|
self.feed.refresh_from_db()
|
|
with patch('podcasts.views._fetch_feed', lambda url, **kw: FeedFetch(200, feedparser.parse(rss))):
|
|
_refresh_feed(self.feed)
|
|
new = PodcastEpisode.objects.get(guid='neu')
|
|
self.assertFalse(PodcastQueue.objects.filter(episode=new).exists())
|
|
self.assertTrue(EpisodeProgress.objects.get(episode=new).dismissed)
|
|
|
|
def test_bookmarks(self):
|
|
self.mark()
|
|
ep = self.eps['Kapitel 2']
|
|
r = self.post('/podcasts/bookmarks/', {'episode_id': ep.pk, 'position_ms': 61500, 'note': ' Wichtig '})
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
bm = r.json()['bookmark']
|
|
self.assertEqual((bm['episode_id'], bm['position_ms'], bm['note']), (ep.pk, 61500, 'Wichtig'))
|
|
self.post('/podcasts/bookmarks/', {'episode_id': self.eps['Kapitel 1'].pk, 'position_ms': 5})
|
|
marks = self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').json()['bookmarks']
|
|
self.assertEqual([m['episode_id'] for m in marks], [self.eps['Kapitel 1'].pk, ep.pk]) # chapter order
|
|
r = self.post(f'/podcasts/bookmarks/{bm["id"]}/', {'note': 'Anders'})
|
|
self.assertEqual(r.json()['bookmark']['note'], 'Anders')
|
|
self.post(f'/podcasts/bookmarks/{bm["id"]}/delete/', {})
|
|
self.assertEqual(EpisodeBookmark.objects.count(), 1)
|
|
|
|
def test_bookmarks_are_ones_own(self):
|
|
self.mark()
|
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
|
mine = EpisodeBookmark.objects.create(user=self.user, episode=self.eps['Kapitel 1'], position_ms=1)
|
|
self.client.force_login(bob)
|
|
self.assertEqual(self.post('/podcasts/bookmarks/', {'episode_id': self.eps['Kapitel 1'].pk, 'position_ms': 1}).status_code, 404)
|
|
self.assertEqual(self.post(f'/podcasts/bookmarks/{mine.pk}/', {'note': 'x'}).status_code, 404)
|
|
self.post(f'/podcasts/bookmarks/{mine.pk}/delete/', {})
|
|
self.assertTrue(EpisodeBookmark.objects.filter(pk=mine.pk).exists())
|
|
self.assertEqual(self.client.get(f'/podcasts/audiobooks/{self.feed.pk}/').status_code, 404)
|
|
|
|
def test_bad_bookmarks_are_refused(self):
|
|
ep = self.eps['Kapitel 1']
|
|
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
|
|
|
|
|
|
class AudiobookFolderTests(TestCase):
|
|
def test_a_book_has_a_folder(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', kind='audiobook')
|
|
r = self.client.post(f'/podcasts/feeds/{feed.pk}/settings/', json.dumps({'folder': ' Marx und Engels '}),
|
|
content_type='application/json')
|
|
self.assertEqual(r.json()['folder'], 'Marx und Engels')
|
|
self.assertEqual(self.client.get('/podcasts/audiobooks/').json()['books'][0]['folder'], 'Marx und Engels')
|
|
|
|
|
|
class FolderTagTests(TestCase):
|
|
"""Folders of podcasts and audiobooks work like tags (docs/library.md)."""
|
|
|
|
def setUp(self):
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
self.client.force_login(self.user)
|
|
self.a = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='A')
|
|
self.b = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='B')
|
|
|
|
def post(self, url, body):
|
|
return self.client.post(url, json.dumps(body), content_type='application/json')
|
|
|
|
def test_several_folders_and_the_old_single_one(self):
|
|
r = self.post(f'/podcasts/feeds/{self.a.pk}/settings/', {'folders': [' Politik ', 'Wissen', 'politik', '']})
|
|
self.assertEqual((r.json()['folders'], r.json()['folder']), (['Politik', 'Wissen'], 'Politik'))
|
|
r = self.post(f'/podcasts/feeds/{self.a.pk}/settings/', {'folder': 'Romane'}) # an older client
|
|
self.assertEqual((r.json()['folders'], r.json()['folder']), (['Romane'], 'Romane'))
|
|
r = self.post(f'/podcasts/feeds/{self.a.pk}/settings/', {'folders': []})
|
|
self.assertEqual((r.json()['folders'], r.json()['folder']), ([], ''))
|
|
|
|
def test_order_rename_merge_delete(self):
|
|
self.post(f'/podcasts/feeds/{self.a.pk}/settings/', {'folders': ['Politik', 'Wissen']})
|
|
self.post(f'/podcasts/feeds/{self.b.pk}/settings/', {'folders': ['Wissenschaft']})
|
|
d = self.post('/podcasts/folders/', {'order': ['Wissen', 'Politik']}).json()
|
|
self.assertEqual(d['order'], ['Wissen', 'Politik', 'Wissenschaft'])
|
|
d = self.post('/podcasts/folders/rename/', {'old': 'Wissenschaft', 'new': 'Wissen'}).json() # merge
|
|
self.assertEqual(d['folders'], [{'name': 'Wissen', 'count': 2}, {'name': 'Politik', 'count': 1}])
|
|
d = self.post('/podcasts/folders/delete/', {'name': 'Politik'}).json()
|
|
self.assertEqual(d['folders'], [{'name': 'Wissen', 'count': 2}])
|
|
self.a.refresh_from_db()
|
|
self.assertEqual((self.a.folders, self.a.folder), (['Wissen'], 'Wissen'))
|
|
self.assertEqual(PodcastFeed.objects.count(), 2) # the subscriptions stay
|