From 3ef2bdcb6134fc45431957e4eb9abcff1f2a0ec2 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 19 Oct 2026 18:05:28 +0200 Subject: [PATCH] Podcasts: Merkliste und Wiedergabeliste (hinter lists_v2), Abo-Stufen inkl. Stamm direkt in die Wiedergabeliste, Abrufintervall pro Abo, Strom-Daten (heard_365, stream/seen, filter=stream) Co-Authored-By: Claude Opus 5.5 --- docs/podcasts.md | 29 ++- podcasts/management/commands/refresh_feeds.py | 6 +- podcasts/migrations/0022_lists_and_levels.py | 49 ++++ podcasts/models.py | 47 +++- podcasts/test_lists.py | 176 ++++++++++++++ podcasts/tests.py | 9 +- podcasts/urls.py | 4 + podcasts/views.py | 219 +++++++++++++++++- static/js/podcasts-web.js | 59 ++++- static/js/sw.js | 2 +- 10 files changed, 577 insertions(+), 23 deletions(-) create mode 100644 podcasts/migrations/0022_lists_and_levels.py create mode 100644 podcasts/test_lists.py diff --git a/docs/podcasts.md b/docs/podcasts.md index b915635..a41b54e 100644 --- a/docs/podcasts.md +++ b/docs/podcasts.md @@ -52,7 +52,7 @@ silent subscriptions become Strom with *täglich*. All episodes of all Strom and Stamm subscriptions, by period (Heute, Gestern, Diese Woche, …). Within a period the podcasts are ordered by **how much the user has listened to them** (episodes heard): every podcast with at least one heard episode before every one without — -those count for infinitely more; among the never-heard ones, rarely publishing first. +those count for infinitely more (heard in the last 12 months, `heard_365`); among the never-heard ones, rarely publishing first. Each podcast appears **once per period, always bundled**: its newest episode, and "+n ›" for the others. Episodes new since they were last seen carry a **small mark** (no counts). Per episode: ▶ (Wiedergabeliste, first), + (Merkliste), ★. Filters stay: Favoriten, Verlauf, Angefangen, Gehört. @@ -85,3 +85,30 @@ thought through. 2. The three levels and the fetch interval; downloads following the lists. 3. The Strom replacing "Neu" and "Folgen". 4. Browsing — after more thought. + +## API (server, since 2026-10-19) + +- `PodcastSettings.lists_v2` turns the two lists on. `POST /podcasts/lists/ {"on": true}` + moves the queue into the Merkliste (the Wiedergabeliste starts empty); `{"on": false}` puts + the Merkliste back at the end of the queue. Off, everything works as before (apps not + updated yet). +- **Wiedergabeliste** = the queue as it was: `state.queue`, `/podcasts/queue/add|remove|reorder/`. + The player goes through it and stops at its end. +- **Merkliste** = `PodcastLater`: `state.later` (rows like the queue's, newest first, plus + `added_at`, `auto` — added by a Stamm feed — and `in_queue`), `POST /podcasts/later/add/` and + `/podcasts/later/remove/` `{"episode_ids": [...]}`. Heard (progress at the end, mark-played) + and "Folge löschen" take an episode off it. Episode lists carry `in_later`; filter `later`. +- **Levels**: `PodcastFeed.new_episodes` — `queue` = Stamm into the Merkliste (with lists_v2; + without it: the queue), `play` = Stamm into the Wiedergabeliste, `inbox` = Strom, + `ignore` = Auf Abruf. `auto_queue` stays true for queue and play. +- **Fetch interval**: `PodcastFeed.refresh_interval` — `''` (by rhythm: every `interval_days` + hours, within 15 minutes and 24 hours), `hourly`, `daily`, `weekly`; the cron's + `refresh_feeds` only fetches feeds that are due (`views.refresh_due`). +- **Strom**: `GET /podcasts/episodes/?filter=stream` (all but Auf Abruf; with `folder=`, `sort=`); + episodes carry `discovered_at`. Per feed in `state.feeds`: `heard_365` (episodes heard in the + last 12 months: played, or listened into for more than a minute) and `interval_days`. + `POST /podcasts/stream/seen/` → `{before, now}`; `state.stream_seen_at`. An episode is marked + when its `discovered_at` is after the time before the current look. +- Web host (`static/js/podcasts-web.js`, `DioraPodcastHost`): `podcastLaterAdd(json episodes)`, + `podcastLaterRemove(json ids)`, `podcastListsSwitch(on)`, `podcastStreamSeen()` (keeps the + previous time as `state.stream_seen_before` for the marks). diff --git a/podcasts/management/commands/refresh_feeds.py b/podcasts/management/commands/refresh_feeds.py index b3b6502..8049cad 100644 --- a/podcasts/management/commands/refresh_feeds.py +++ b/podcasts/management/commands/refresh_feeds.py @@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand from django.utils import timezone from podcasts.models import PodcastFeed -from podcasts.views import _in_backoff_q, _refresh_feeds +from podcasts.views import _in_backoff_q, _refresh_feeds, refresh_due class Command(BaseCommand): @@ -26,6 +26,10 @@ class Command(BaseCommand): # A feed failing 3+ times in a row is tried every 2, 4, 8, 16, # at most 24 hours instead of every hour. qs = qs.exclude(_in_backoff_q(timezone.now())) + # Each feed as often as its interval says (docs/podcasts.md): by its rhythm, + # or hourly, daily, weekly. --feed-id and --full take every one. + if not options['full']: + qs = qs.filter(pk__in=refresh_due(list(qs.select_related('user')))) if options['limit']: qs = qs[: options['limit']] diff --git a/podcasts/migrations/0022_lists_and_levels.py b/podcasts/migrations/0022_lists_and_levels.py new file mode 100644 index 0000000..6d6f04e --- /dev/null +++ b/podcasts/migrations/0022_lists_and_levels.py @@ -0,0 +1,49 @@ +# Generated by Django 6.1 on 2026-10-19 15:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('podcasts', '0021_folders_list'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='podcastfeed', + name='refresh_interval', + field=models.CharField(blank=True, choices=[('', 'Automatisch'), ('hourly', 'Stündlich'), ('daily', 'Täglich'), ('weekly', 'Wöchentlich')], default='', max_length=10), + ), + migrations.AddField( + model_name='podcastsettings', + name='lists_v2', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='podcastsettings', + name='stream_seen_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='podcastfeed', + name='new_episodes', + field=models.CharField(choices=[('inbox', 'Im Strom'), ('queue', 'Stamm: in die Merkliste'), ('play', 'Stamm: in die Wiedergabeliste'), ('ignore', 'Auf Abruf')], default='inbox', max_length=10), + ), + migrations.CreateModel( + name='PodcastLater', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('added_at', models.DateTimeField(auto_now_add=True)), + ('auto', models.BooleanField(default=False)), + ('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='later_by', to='podcasts.podcastepisode')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_later', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'episode')}, + }, + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 798b0ab..d71ed96 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -8,15 +8,23 @@ MAX_SPEED = 3.5 class PodcastFeed(models.Model): - # What happens to a new episode of this feed (one that passed the filter). + # What happens to a new episode of this feed (one that passed the filter); with the + # lists of docs/podcasts.md (PodcastSettings.lists_v2) these are the subscription levels: + # inbox = Strom, queue = Stamm (into the Merkliste), play = Stamm straight into the + # Wiedergabeliste, ignore = Auf Abruf. Without lists_v2, queue and play both queue. NEW_INBOX = 'inbox' NEW_QUEUE = 'queue' + NEW_PLAY = 'play' NEW_IGNORE = 'ignore' NEW_EPISODE_ACTIONS = [ - (NEW_INBOX, 'In „Neu“'), - (NEW_QUEUE, 'In die Warteschlange'), - (NEW_IGNORE, 'Nicht anzeigen'), + (NEW_INBOX, 'Im Strom'), + (NEW_QUEUE, 'Stamm: in die Merkliste'), + (NEW_PLAY, 'Stamm: in die Wiedergabeliste'), + (NEW_IGNORE, 'Auf Abruf'), ] + # How often the feed is fetched: '' = by its rhythm (refresh_due()), or fixed. + REFRESH_INTERVALS = [('', 'Automatisch'), ('hourly', 'Stündlich'), ('daily', 'Täglich'), + ('weekly', 'Wöchentlich')] # Volume factors: below 1 quieter, above 1 louder (the apps boost those). VOLUMES = (0.2, 0.5, 1.0, 1.5, 2.0, 2.5) # What it is: a podcast, or an audiobook (Hörbuch, Hörspiel), which lives in the @@ -58,6 +66,7 @@ class PodcastFeed(models.Model): volume = models.FloatField(default=1.0) # one of VOLUMES # New episodes: what happens to them, and which ones count at all. new_episodes = models.CharField(max_length=10, choices=NEW_EPISODE_ACTIONS, default=NEW_INBOX) + refresh_interval = models.CharField(max_length=10, choices=REFRESH_INTERVALS, default='', blank=True) # Comma-separated words, case-insensitive, matched against the title. include_words = models.CharField(max_length=500, blank=True) exclude_words = models.CharField(max_length=500, blank=True) @@ -106,15 +115,16 @@ class PodcastFeed(models.Model): self._synced = (self.__dict__.get('auto_queue'), self.__dict__.get('new_episodes')) def _sync_auto_queue(self): - """Keep auto_queue == (new_episodes == 'queue'). Whichever of the two + """Keep auto_queue == (new_episodes is 'queue' or 'play'). Whichever of the two was changed since loading (or, for a new feed, set away from its default) wins; new_episodes if both were.""" auto_before, new_before = getattr(self, '_synced', (False, self.NEW_INBOX)) + queues = (self.NEW_QUEUE, self.NEW_PLAY) if self.new_episodes != new_before or self.auto_queue == auto_before: - self.auto_queue = self.new_episodes == self.NEW_QUEUE + self.auto_queue = self.new_episodes in queues elif self.auto_queue: self.new_episodes = self.NEW_QUEUE - elif self.new_episodes == self.NEW_QUEUE: + elif self.new_episodes in queues: self.new_episodes = self.NEW_INBOX def save(self, *args, **kwargs): @@ -260,6 +270,23 @@ class PodcastQueue(models.Model): def __str__(self): return f'{self.user} queue pos {self.position}: {self.episode}' +class PodcastLater(models.Model): + """The Merkliste (docs/podcasts.md): episodes the user wants to hear some day. It may + grow and never expires; shown grouped by podcast, so it has no order of its own. An + episode can be in it and in the Wiedergabeliste (PodcastQueue) at once; heard, it leaves + both. auto: put there by a Stamm subscription, not by hand.""" + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_later') + episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='later_by') + added_at = models.DateTimeField(auto_now_add=True) + auto = models.BooleanField(default=False) + + class Meta: + unique_together = ('user', 'episode') + + def __str__(self): + return f'{self.user} Merkliste: {self.episode}' + + class PodcastSettings(models.Model): """A user's podcast preferences. Where episodes go in the queue: when added by hand (also when played from outside the queue), and when a feed that @@ -297,6 +324,12 @@ class PodcastSettings(models.Model): import_downloads = models.JSONField(default=list, blank=True) # The order of the podcast/audiobook folders (their chips), names. folder_order = models.JSONField(default=list, blank=True) + # The two lists of docs/podcasts.md: PodcastQueue is the Wiedergabeliste (the player goes + # through it and stops at its end), PodcastLater the Merkliste, and Stamm subscriptions + # fill the Merkliste. Off: the one queue as before (apps not updated yet). + lists_v2 = models.BooleanField(default=False) + # When the user last looked at the Strom: episodes found after it carry a mark. + stream_seen_at = models.DateTimeField(null=True, blank=True) # Playback, the same on every device (AntennaPod's playback settings). # Null = not set yet: each device keeps its own value; the first app that diff --git a/podcasts/test_lists.py b/podcasts/test_lists.py new file mode 100644 index 0000000..947f744 --- /dev/null +++ b/podcasts/test_lists.py @@ -0,0 +1,176 @@ +"""The two lists, the subscription levels, the fetch interval and the Strom (docs/podcasts.md).""" +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils import timezone as dj_tz + +from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastLater, PodcastQueue, PodcastSettings +from .tests import _ok, _rss +from .views import _refresh_feed, refresh_due, refresh_hours + + +class Base(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='lists', password='pw12345678') + self.client.force_login(self.user) + self.feed = PodcastFeed.objects.create( + user=self.user, rss_url='https://example.org/feed', title='Feed', + last_refreshed_at=datetime(2026, 1, 1, tzinfo=timezone.utc)) + self.eps = [PodcastEpisode.objects.create(feed=self.feed, guid=f'e{i}', title=f'E{i}', duration_seconds=600, + audio_url=f'https://example.org/e{i}.mp3', + pub_date=datetime(2026, 1, 1 + i, tzinfo=timezone.utc)) + for i in range(4)] + + def post(self, url, body=None): + return self.client.post(url, json.dumps(body or {}), content_type='application/json') + + def state(self): + return self.client.get('/podcasts/state/').json() + + def queue(self): + return list(PodcastQueue.objects.filter(user=self.user).order_by('position').values_list('episode_id', flat=True)) + + def later(self): + return set(PodcastLater.objects.filter(user=self.user).values_list('episode_id', flat=True)) + + +class SwitchTests(Base): + def test_switching_on_makes_the_queue_the_merkliste(self): + for i, ep in enumerate(self.eps[:3]): + PodcastQueue.objects.create(user=self.user, episode=ep, position=i) + r = self.post('/podcasts/lists/', {'on': True}).json() + self.assertEqual((r['lists_v2'], r['queue'], r['later']), (True, 0, 3)) + self.assertEqual(self.later(), {e.id for e in self.eps[:3]}) + self.assertEqual(self.queue(), []) + st = self.state() + self.assertTrue(st['lists_v2']) + self.assertEqual([i['episode__id'] for i in st['later']], [self.eps[2].id, self.eps[1].id, self.eps[0].id]) + # And back: the Merkliste joins the queue, oldest first. + self.post('/podcasts/lists/', {'on': False}) + self.assertEqual(self.queue(), [e.id for e in self.eps[:3]]) + self.assertEqual(self.later(), set()) + + def test_switching_twice_changes_nothing(self): + PodcastQueue.objects.create(user=self.user, episode=self.eps[0], position=0) + self.post('/podcasts/lists/', {'on': True}) + PodcastQueue.objects.create(user=self.user, episode=self.eps[1], position=0) + self.post('/podcasts/lists/', {'on': True}) + self.assertEqual(self.queue(), [self.eps[1].id]) + self.assertEqual(self.later(), {self.eps[0].id}) + + +class MerklisteTests(Base): + def test_add_remove_and_both_lists(self): + r = self.post('/podcasts/later/add/', {'episode_ids': [self.eps[0].id, self.eps[1].id, 999999]}).json() + self.assertEqual(r['added'], [self.eps[0].id, self.eps[1].id]) + self.post('/podcasts/queue/add/', {'episode_ids': [self.eps[0].id], 'where': 'front'}) + rows = {i['episode__id']: i for i in self.state()['later']} + self.assertTrue(rows[self.eps[0].id]['in_queue']) + self.assertFalse(rows[self.eps[1].id]['in_queue']) + self.post('/podcasts/later/remove/', {'episode_ids': [self.eps[1].id]}) + self.assertEqual(self.later(), {self.eps[0].id}) + self.assertEqual(self.queue(), [self.eps[0].id], 'the Wiedergabeliste is left alone') + + def test_heard_leaves_the_merkliste(self): + self.post('/podcasts/later/add/', {'episode_ids': [e.id for e in self.eps[:3]]}) + self.post('/podcasts/progress/save/', {'episode_id': self.eps[0].id, 'position_seconds': 590}) + self.post('/podcasts/progress/mark-played/', {'episode_id': self.eps[1].id}) + self.post('/podcasts/progress/save/', {'episode_id': self.eps[2].id, 'position_seconds': 100}) + self.assertEqual(self.later(), {self.eps[2].id}, 'started stays, heard goes') + + def test_discard_leaves_both_lists(self): + self.post('/podcasts/later/add/', {'episode_ids': [self.eps[0].id]}) + self.post('/podcasts/queue/add/', {'episode_ids': [self.eps[0].id]}) + self.post('/podcasts/episodes/discard/', {'episode_ids': [self.eps[0].id]}) + self.assertEqual((self.later(), self.queue()), (set(), [])) + + def test_episode_lists_tell_and_filter(self): + self.post('/podcasts/later/add/', {'episode_ids': [self.eps[3].id]}) + r = self.client.get('/podcasts/episodes/?filter=later').json() + self.assertEqual([e['id'] for e in r['episodes']], [self.eps[3].id]) + self.assertTrue(r['episodes'][0]['in_later']) + self.assertIn('discovered_at', r['episodes'][0]) + + +class LevelTests(Base): + """New episodes by level: Stamm into the Merkliste (or the Wiedergabeliste), Strom + nowhere, and the old one queue without lists_v2.""" + + RSS = _rss([('n1', 'Mon, 05 Jan 2026 10:00:00 GMT'), ('n2', 'Tue, 06 Jan 2026 10:00:00 GMT')]) + + def refresh(self, level, v2=True): + PodcastSettings.objects.update_or_create(user=self.user, defaults={'lists_v2': v2}) + self.feed.new_episodes = level + self.feed.save() + with patch('podcasts.views._fetch_feed', lambda url, **kw: _ok(self.RSS)): + _refresh_feed(self.feed) + guid = dict(PodcastEpisode.objects.filter(feed=self.feed).values_list('id', 'guid')) + return ([guid[i] for i in self.queue()], sorted(guid[i] for i in self.later())) + + def test_stamm_goes_to_the_merkliste_marked_auto(self): + self.assertEqual(self.refresh('queue'), ([], ['n1', 'n2'])) + self.assertTrue(all(PodcastLater.objects.filter(user=self.user).values_list('auto', flat=True))) + + def test_stamm_play_goes_to_the_wiedergabeliste(self): + self.assertEqual(self.refresh('play'), (['n2', 'n1'], [])) + self.assertTrue(PodcastFeed.objects.get(pk=self.feed.pk).auto_queue) + + def test_strom_and_auf_abruf_go_nowhere(self): + self.assertEqual(self.refresh('inbox'), ([], [])) + + def test_without_lists_v2_stamm_queues_as_before(self): + self.assertEqual(self.refresh('queue', v2=False), (['n2', 'n1'], [])) + + def test_the_strom_leaves_out_auf_abruf(self): + other = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/o', title='O', new_episodes='ignore') + PodcastEpisode.objects.create(feed=other, guid='x', title='X', audio_url='https://example.org/x.mp3') + r = self.client.get('/podcasts/episodes/?filter=stream&limit=50').json() + self.assertNotIn('X', [e['title'] for e in r['episodes']]) + self.assertEqual(r['total'], 4) + + +class IntervalTests(Base): + def test_fixed_and_by_rhythm(self): + self.assertEqual(refresh_hours({'refresh_interval': 'daily'}, 0.5), 24) + self.assertEqual(refresh_hours({'refresh_interval': 'weekly'}, 0.5), 168) + self.assertEqual(refresh_hours({'refresh_interval': ''}, 0.1), 0.25) + self.assertEqual(refresh_hours({'refresh_interval': ''}, 7), 7) + self.assertEqual(refresh_hours({'refresh_interval': ''}, 90), 24) + self.assertEqual(refresh_hours({'refresh_interval': ''}, None), 1) + + def test_due(self): + now = dj_tz.now() + daily = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/d', title='D', + refresh_interval='daily', last_refreshed_at=now - timedelta(hours=5)) + stale = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/s', title='S', + refresh_interval='daily', last_refreshed_at=now - timedelta(hours=25)) + new = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/n', title='N') + due = refresh_due(list(PodcastFeed.objects.filter(pk__in=[daily.pk, stale.pk, new.pk])), now) + self.assertEqual(set(due), {stale.pk, new.pk}) + + def test_setting_it(self): + r = self.post(f'/podcasts/feeds/{self.feed.pk}/settings/', {'refresh_interval': 'weekly'}).json() + self.assertEqual(r['refresh_interval'], 'weekly') + self.assertEqual(self.post(f'/podcasts/feeds/{self.feed.pk}/settings/', {'refresh_interval': 'often'}).status_code, 400) + + +class StromTests(Base): + def test_heard_in_twelve_months_per_feed(self): + EpisodeProgress.objects.create(user=self.user, episode=self.eps[0], played=True) + EpisodeProgress.objects.create(user=self.user, episode=self.eps[1], position_seconds=120) + EpisodeProgress.objects.create(user=self.user, episode=self.eps[2], position_seconds=10) # just a taste + old = EpisodeProgress.objects.create(user=self.user, episode=self.eps[3], played=True) + EpisodeProgress.objects.filter(pk=old.pk).update(updated_at=dj_tz.now() - timedelta(days=400)) + feed = [f for f in self.state()['feeds'] if f['id'] == self.feed.id][0] + self.assertEqual(feed['heard_365'], 2) + + def test_seen(self): + self.assertIsNone(self.state()['stream_seen_at']) + r = self.post('/podcasts/stream/seen/').json() + self.assertIsNone(r['before']) + r2 = self.post('/podcasts/stream/seen/').json() + self.assertEqual(r2['before'], r['now']) + self.assertEqual(self.state()['stream_seen_at'], r2['now']) diff --git a/podcasts/tests.py b/podcasts/tests.py index d1fb5a1..702b16d 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -834,11 +834,13 @@ class EpisodeListTests(TestCase): def test_episode_fields(self): eps = {e['id']: e for e in self.get()['episodes']} - self.assertEqual(eps[self.a2.id], { + a2 = dict(eps[self.a2.id]) + self.assertTrue(a2.pop('discovered_at')) # when diora found it (the Strom's mark) + self.assertEqual(a2, { 'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'duration_seconds': 0, 'pub_date': '2026-01-02T09:00:00+00:00', 'artwork_url': '', 'feed__id': self.fa.id, 'feed__title': 'Alpha', 'feed__artwork_url': 'https://example.org/a.jpg', - 'position_seconds': 100, 'played': False, 'favorite': False, 'in_queue': True, + 'position_seconds': 100, 'played': False, 'favorite': False, 'in_queue': True, 'in_later': False, 'last_played_at': '2026-02-03T09:00:00+00:00', }) a4 = eps[self.a4.id] @@ -1104,7 +1106,7 @@ FEED_DEFAULTS = { 'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox', 'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False, 'notify': False, 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', - 'folder': '', 'folders': [], + 'folder': '', 'folders': [], 'refresh_interval': '', } @@ -1132,6 +1134,7 @@ class FeedSettingsTests(TestCase): 'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10, 'auto_queue': False, 'auto_download': False, 'notify': False, 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', 'folder': '', 'folders': [], + 'refresh_interval': '', }) self.feed.refresh_from_db() self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration), diff --git a/podcasts/urls.py b/podcasts/urls.py index 0fad4c6..f548b79 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -35,6 +35,10 @@ urlpatterns = [ path('queue/add/', views.queue_add, name='podcast_queue_add'), path('queue/remove/', views.queue_remove, name='podcast_queue_remove'), path('queue/reorder/', views.queue_reorder, name='podcast_queue_reorder'), + path('later/add/', views.later_add, name='podcast_later_add'), + path('later/remove/', views.later_remove, name='podcast_later_remove'), + path('lists/', views.lists_switch, name='podcast_lists_switch'), + path('stream/seen/', views.stream_seen, name='podcast_stream_seen'), path('settings/', views.podcast_settings, name='podcast_settings'), path('progress/save/', views.save_progress, name='podcast_save_progress'), path('progress/mark-played/', views.mark_played, name='podcast_mark_played'), diff --git a/podcasts/views.py b/podcasts/views.py index 10c00e2..6c712fb 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -32,8 +32,8 @@ from . import transcripts as transcripts_mod from . import youtube as youtube_mod from . import folders as folders_mod from .models import ( - EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue, - MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played, + EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater, + PodcastQueue, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played, ) from .shownotes import extract_timestamps, sanitize_html @@ -191,6 +191,89 @@ def _leave_inbox(user, episode_ids): ) +# --------------------------------------------------------------------------- +# Merkliste (docs/podcasts.md) +# --------------------------------------------------------------------------- + +def _later_add(user, episode_ids, auto=False): + """Put episodes on the user's Merkliste (those not on it yet). Like queueing they are no + longer new; wanted again after "Folge löschen". Returns the ids added.""" + have = set(PodcastLater.objects.filter(user=user, episode_id__in=episode_ids).values_list('episode_id', flat=True)) + new = [i for i in dict.fromkeys(episode_ids) if i not in have] + if not new: + return [] + PodcastLater.objects.bulk_create([PodcastLater(user=user, episode_id=i, auto=auto) for i in new], ignore_conflicts=True) + _leave_inbox(user, new) + EpisodeProgress.objects.filter(user=user, episode_id__in=new, discarded_at__isnull=False).update(discarded_at=None) + return new + + +def _later_remove(user, episode_ids): + PodcastLater.objects.filter(user=user, episode_id__in=episode_ids).delete() + + +def _later_data(user): + """The Merkliste: like the queue's rows (episode__… fields, position_seconds), plus + added_at, auto and in_queue (also in the Wiedergabeliste). Newest episode first; the + apps group it by podcast.""" + items = list( + PodcastLater.objects.filter(user=user) + .order_by(F('episode__pub_date').desc(nulls_last=True), '-id') + .values( + 'id', 'added_at', 'auto', + 'episode__id', 'episode__title', 'episode__audio_url', + 'episode__duration_seconds', 'episode__artwork_url', + 'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url', + 'episode__pub_date', + ) + ) + ids = [i['episode__id'] for i in items] + positions = dict(EpisodeProgress.objects.filter(user=user, episode_id__in=ids).values_list('episode_id', 'position_seconds')) + queued = set(PodcastQueue.objects.filter(user=user, episode_id__in=ids).values_list('episode_id', flat=True)) + for i in items: + i['position_seconds'] = positions.get(i['episode__id'], 0) + i['in_queue'] = i['episode__id'] in queued + i['added_at'] = i['added_at'].isoformat() + if i['episode__pub_date']: + i['episode__pub_date'] = i['episode__pub_date'].isoformat() + return items + + +# How often a feed is fetched (docs/podcasts.md): fixed, or by its rhythm — a feed that +# brings an episode every n days is fetched every n hours, within 15 minutes and a day. +REFRESH_HOURS = {'hourly': 1, 'daily': 24, 'weekly': 24 * 7} +REFRESH_AUTO_MIN_HOURS = 0.25 +REFRESH_AUTO_MAX_HOURS = 24 + + +def refresh_hours(feed, interval_days): + """Hours between two fetches of `feed` (a PodcastFeed or a dict with refresh_interval).""" + fixed = REFRESH_HOURS.get(getattr(feed, 'refresh_interval', None) if not isinstance(feed, dict) else feed.get('refresh_interval')) + if fixed: + return fixed + if interval_days is None: + return 1 + return min(max(interval_days, REFRESH_AUTO_MIN_HOURS), REFRESH_AUTO_MAX_HOURS) + + +def refresh_due(feeds, now=None): + """The ids of the feeds due for a fetch: never fetched, or longer ago than their + refresh_hours (with a few minutes' slack for the 15-minute cron).""" + now = now or timezone.now() + intervals = {} + due = [] + for f in feeds: + if f.user_id not in intervals: + intervals[f.user_id] = _intervals(f.user) + if f.last_refreshed_at is None: + due.append(f.id) + continue + hours = refresh_hours(f, intervals[f.user_id].get(f.id)) + if now - f.last_refreshed_at >= timedelta(hours=hours) - timedelta(minutes=5): + due.append(f.id) + return due + + # --------------------------------------------------------------------------- # Duration helper # --------------------------------------------------------------------------- @@ -724,17 +807,23 @@ def _apply_feed(feed_obj, fetched, full=False): _leave_inbox(feed_obj.user, list(gone)) new_episodes_list = [e for e in new_episodes_list if e.id not in gone] - if feed_obj.new_episodes == PodcastFeed.NEW_QUEUE and new_episodes_list and feed_obj.kind != PodcastFeed.AUDIOBOOK: - where = PodcastSettings.for_user(feed_obj.user).auto_queue_position - # Newest first in front of what's there; oldest first when appended, - # so the end of the queue plays in the order the episodes came out. + stamm = (PodcastFeed.NEW_QUEUE, PodcastFeed.NEW_PLAY) + if feed_obj.new_episodes in stamm and new_episodes_list and feed_obj.kind != PodcastFeed.AUDIOBOOK: + s = PodcastSettings.for_user(feed_obj.user) newest_first = sorted( new_episodes_list, key=lambda e: e.pub_date or e.discovered_at, reverse=True, ) - ordered = newest_first if where != PodcastSettings.BACK else newest_first[::-1] - _enqueue(feed_obj.user, [e.id for e in ordered], where) + if s.lists_v2 and feed_obj.new_episodes == PodcastFeed.NEW_QUEUE: + # Stamm: into the Merkliste (docs/podcasts.md). + _later_add(feed_obj.user, [e.id for e in newest_first], auto=True) + else: + where = s.auto_queue_position + # Newest first in front of what's there; oldest first when appended, + # so the end of the queue plays in the order the episodes came out. + ordered = newest_first if where != PodcastSettings.BACK else newest_first[::-1] + _enqueue(feed_obj.user, [e.id for e in ordered], where) # Only now, with every episode written: the validators (a 304 next time # skips all of the above) and a clean error state. @@ -803,6 +892,7 @@ FEED_SETTING_FIELDS = ( 'playback_speed', 'skip_intro', 'skip_outro', 'volume', 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download', 'notify', 'episode_sort', 'episode_filter', 'skip_silence', 'kind', 'folder', 'folders', + 'refresh_interval', ) @@ -811,6 +901,7 @@ def _feed_settings(feed): INTERVAL_EPISODES = 10 # a feed's newest episodes that tell how often it comes +HEARD_MIN_SECONDS = 60 # listened into this far, an episode counts as heard (with played) INTERVAL_SINGLE_DAYS = 30 @@ -870,6 +961,8 @@ def _feeds_data(user): played_count=Count('id', filter=Q(played=True)), hidden_count=Count('id', filter=Q(played=True) | Q(dismissed=True)), last_played_at=Max('updated_at', filter=Q(played=True) | Q(position_seconds__gt=0)), + heard_365=Count('id', filter=(Q(played=True) | Q(position_seconds__gt=HEARD_MIN_SECONDS)) + & Q(updated_at__gte=timezone.now() - timedelta(days=365))), ) } @@ -880,6 +973,9 @@ def _feeds_data(user): mine = listened.get(f['id'], {}) f['unplayed_count'] = max(f['episode_count'] - mine.get('played_count', 0), 0) f['new_count'] = max(f['episode_count'] - mine.get('hidden_count', 0), 0) + # Episodes heard in the last 12 months: the Strom puts the most listened first, + # any heard one before every never-heard one (docs/podcasts.md). + f['heard_365'] = mine.get('heard_365', 0) last = mine.get('last_played_at') f['last_played_at'] = last.isoformat() if last else None if f['last_refreshed_at']: @@ -1052,6 +1148,12 @@ def _new_episodes(value): return value +def _refresh_interval(value): + if value not in {key for key, _ in PodcastFeed.REFRESH_INTERVALS}: + raise ValueError + return value + + def _boolean(value): if not isinstance(value, bool): raise ValueError @@ -1083,6 +1185,7 @@ FEED_SETTING_PARSERS = { 'folder': lambda v: folders_mod.clean_name(v), 'folders': folders_mod.clean_list, 'skip_silence': _optional_boolean, + 'refresh_interval': lambda v: _refresh_interval(v), } @@ -1493,6 +1596,9 @@ EPISODE_FILTERS = { 'favorites': Q(favorite=True), 'history': Q(played=True) | Q(position_seconds__gt=0), 'queued': Q(in_queue=True), + 'later': Q(in_later=True), + # The Strom (docs/podcasts.md): every subscription but the "Auf Abruf" ones. + 'stream': ~Q(feed__new_episodes=PodcastFeed.NEW_IGNORE), } # Episodes of unknown length (0) come last when sorting by length. _UNKNOWN_LENGTH_LAST = Case(When(duration_seconds__gt=0, then=Value(0)), default=Value(1), output_field=IntegerField()) @@ -1510,7 +1616,7 @@ EPISODE_LIST_MAX = 500 EPISODE_LIST_FIELDS = ( 'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url', 'feed__id', 'feed__title', 'feed__artwork_url', - 'position_seconds', 'played', 'favorite', 'in_queue', 'last_played_at', + 'position_seconds', 'played', 'favorite', 'in_queue', 'in_later', 'last_played_at', 'discovered_at', ) @@ -1542,6 +1648,7 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li favorite=Coalesce('mine__favorite', Value(False)), last_played_at=F('mine__updated_at'), in_queue=Exists(PodcastQueue.objects.filter(user=user, episode=OuterRef('pk'))), + in_later=Exists(PodcastLater.objects.filter(user=user, episode=OuterRef('pk'))), ) ) for name in names: @@ -1574,6 +1681,8 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li ep['played'] = bool(ep['played']) ep['favorite'] = bool(ep['favorite']) ep['in_queue'] = bool(ep['in_queue']) + ep['in_later'] = bool(ep['in_later']) + ep['discovered_at'] = ep['discovered_at'].isoformat() if ep['discovered_at'] else None return episodes, total @@ -1838,6 +1947,10 @@ def podcast_state(request): 'inbox_total': _inbox_episodes(request.user).count(), 'inbox_limit': limit, 'settings': _settings_data(s), + # docs/podcasts.md: the Merkliste, whether the two lists are on, the Strom's mark. + 'later': _later_data(request.user), + 'lists_v2': s.lists_v2, + 'stream_seen_at': s.stream_seen_at.isoformat() if s.stream_seen_at else None, 'import_downloads': {'count': len(downloads), 'bytes': sum(e['size'] for e in downloads)}, 'discarded': _discarded_data(request.user), 'server_time': timezone.now().isoformat(), @@ -1956,6 +2069,90 @@ def queue_reorder(request): return JsonResponse({'ok': True}) +def _episode_ids(request): + """The user's episodes among body['episode_ids'] (in order), or a JsonResponse error.""" + try: + body = json.loads(request.body or b'{}') + except (json.JSONDecodeError, ValueError): + return None, JsonResponse({'error': 'invalid JSON'}, status=400) + ids = body.get('episode_ids') if isinstance(body, dict) else None + if not isinstance(ids, list) or not all(isinstance(i, int) and not isinstance(i, bool) for i in ids): + return None, JsonResponse({'error': 'episode_ids (a list of numbers) required'}, status=400) + found = set(PodcastEpisode.objects.filter(pk__in=ids[:EPISODE_IDS_MAX], feed__user=request.user) + .values_list('id', flat=True)) + return [i for i in ids if i in found], None + + +@csrf_exempt +@require_http_methods(['POST']) +def later_add(request): + """{"episode_ids": [...]}: onto the Merkliste (docs/podcasts.md).""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + ids, err = _episode_ids(request) + if err: + return err + return JsonResponse({'ok': True, 'added': _later_add(request.user, ids)}) + + +@csrf_exempt +@require_http_methods(['POST']) +def later_remove(request): + """{"episode_ids": [...]}: off the Merkliste (the Wiedergabeliste is left alone).""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + ids, err = _episode_ids(request) + if err: + return err + _later_remove(request.user, ids) + return JsonResponse({'ok': True}) + + +@csrf_exempt +@require_http_methods(['POST']) +def lists_switch(request): + """{"on": true}: the two lists of docs/podcasts.md — what is in the queue now becomes + the Merkliste, the Wiedergabeliste starts empty. {"on": false}: back, the Merkliste + joins the end of the queue.""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + try: + body = json.loads(request.body or b'{}') + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + on = bool(body.get('on')) if isinstance(body, dict) else False + user = request.user + with transaction.atomic(): + s = PodcastSettings.for_user(user) + if on and not s.lists_v2: + _later_add(user, _queue_order(user)) + PodcastQueue.objects.filter(user=user).delete() + elif not on and s.lists_v2: + later = list(PodcastLater.objects.filter(user=user) + .order_by(F('episode__pub_date').asc(nulls_last=True)).values_list('episode_id', flat=True)) + _enqueue(user, later, PodcastSettings.BACK) + PodcastLater.objects.filter(user=user).delete() + s.lists_v2 = on + s.save(update_fields=['lists_v2']) + return JsonResponse({'ok': True, 'lists_v2': on, 'queue': len(_queue_order(user)), + 'later': PodcastLater.objects.filter(user=user).count()}) + + +@csrf_exempt +@require_http_methods(['POST']) +def stream_seen(request): + """The user looked at the Strom now: returns the time before (episodes found after it + carry the mark) and keeps now.""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + s = PodcastSettings.for_user(request.user) + before = s.stream_seen_at + s.stream_seen_at = timezone.now() + s.save(update_fields=['stream_seen_at']) + return JsonResponse({'ok': True, 'before': before.isoformat() if before else None, + 'now': s.stream_seen_at.isoformat()}) + + def _choice(choices, what): def parse(value): if value not in choices: @@ -2125,6 +2322,7 @@ def save_progress(request): # Within the last seconds: played (same rule as the apps). if counts_as_played(position, episode.duration_seconds): progress.played = True + _later_remove(request.user, [episode.id]) # heard: off the Merkliste progress.save() return JsonResponse({'ok': True, 'played': progress.played}) @@ -2159,6 +2357,8 @@ def mark_played(request): progress.position_seconds = 0 # heard to the end, now "not heard": from the start again fields.append('position_seconds') progress.save(update_fields=fields) + if played: + _later_remove(request.user, [episode.id]) return JsonResponse({'ok': True, 'played': played}) @@ -2245,6 +2445,7 @@ def discard_episodes(request): now = timezone.now() with transaction.atomic(): _dequeue(request.user, valid) + _later_remove(request.user, valid) # update() leaves updated_at alone: throwing an episode away isn't listening to it. rows = EpisodeProgress.objects.filter(user=request.user, episode_id__in=valid) rows.update(dismissed=True, discarded_at=now) diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index d4a52ff..27ac1b8 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -96,7 +96,8 @@ }; function empty() { - return {feeds: [], queue: [], inbox: [], settings: {enqueue_position: 'back', auto_queue_position: 'front'}}; + return {feeds: [], queue: [], inbox: [], later: [], lists_v2: false, + settings: {enqueue_position: 'back', auto_queue_position: 'front'}}; } let state = load('state', null) || empty(); @@ -182,6 +183,8 @@ if (r.status !== 200) return false; const fresh = await r.json(); if (outbox.length) continue; + // The Strom's marks of this visit stay until the next look (podcastStreamSeen). + if (state && state.stream_seen_before !== undefined) fresh.stream_seen_before = state.stream_seen_before; state = fresh; save(); changed(); @@ -260,6 +263,7 @@ function markInQueue(s, ids, inQueue) { for (const o of s.inbox || []) if (ids.has(o.id)) o.in_queue = inQueue; + for (const o of s.later || []) if (ids.has(o.episode__id)) o.in_queue = inQueue; } function forEpisode(s, id, block) { @@ -288,12 +292,45 @@ const ids = fresh.map(e => e.episode__id); setQueueOrder(s, order.slice(0, at).concat(ids, order.slice(at))); removeFromInbox(s, new Set(ids)); // queued means no longer new + markInQueue(s, new Set(ids), true); const body = {episode_ids: ids, where: pos}; if (currentId) body.current_episode_id = currentId; queueOp('/podcasts/queue/add/', body); }); } + // ---- the Merkliste (docs/podcasts.md): state.later, entries like the queue's ------------------ + + function laterOf(s) { + if (!Array.isArray(s.later)) s.later = []; + return s.later; + } + + function laterAdd(episodes) { + edit(s => { + const have = new Set(laterOf(s).map(e => e.episode__id)); + const fresh = episodes.map(asQueueEntry).filter(e => !have.has(e.episode__id) && have.add(e.episode__id)); + if (!fresh.length) return; + const queued = new Set(queueIds(s)); + for (const e of fresh) { + laterOf(s).unshift(Object.assign({}, e, {added_at: new Date().toISOString(), auto: false, in_queue: queued.has(e.episode__id)})); + } + removeFromInbox(s, new Set(fresh.map(e => e.episode__id))); + queueOp('/podcasts/later/add/', {episode_ids: fresh.map(e => e.episode__id)}); + }); + } + + function dropLater(s, set) { + if (Array.isArray(s.later)) s.later = s.later.filter(e => !set.has(e.episode__id)); + } + + function laterRemove(ids) { + edit(s => { + dropLater(s, new Set(ids)); + queueOp('/podcasts/later/remove/', {episode_ids: ids}); + }); + } + function dequeueAll(ids) { edit(s => { const gone = new Set(ids); @@ -348,6 +385,7 @@ if (!played) return; const set = new Set(ids); removeFromInbox(s, set); + dropLater(s, set); // heard: off the Merkliste (the server does the same) for (const id of ids) forEpisode(s, id, o => { o.position_seconds = 0; }); if (leaveQueue) { const inQueue = queueIds(s).filter(id => set.has(id)); @@ -378,6 +416,7 @@ if (order.some(id => set.has(id))) setQueueOrder(s, order.filter(id => !set.has(id))); markInQueue(s, set, false); removeFromInbox(s, set); + dropLater(s, set); queueOp('/podcasts/episodes/discard/', {episode_ids: ids}); }); dropDiscarded(ids); @@ -1832,6 +1871,24 @@ }, podcastMarkPlayedAll: sent((json, played) => markPlayedAll(ids(json), !!played, prefs.remove_played)), podcastDismiss: sent(json => dismiss(ids(json))), + // The Merkliste and the two lists (docs/podcasts.md). + podcastLaterAdd: sent(json => laterAdd(parse(json, []) || [])), + podcastLaterRemove: sent(json => laterRemove(ids(json))), + podcastListsSwitch(on) { + queueOp('/podcasts/lists/', {on: !!on}); + save(); + flush().then(() => refresh(false)).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); }); + }, + // The Strom was looked at: the time before (its marks) stays in the state as + // stream_seen_before; the server keeps now. + podcastStreamSeen() { + edit(s => { + s.stream_seen_before = s.stream_seen_at || null; + s.stream_seen_at = new Date().toISOString(); + queueOp('/podcasts/stream/seen/', {}, 'stream-seen'); + }); + flush(); + }, podcastDiscard: sent(json => discard(ids(json))), podcastSettings: sent(json => setSettings(parse(json, {}) || {})), podcastAutoQueue: sent((feedId, on) => setAutoQueue(feedId, !!on)), diff --git a/static/js/sw.js b/static/js/sw.js index cc14f05..1aafb82 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-v135'; +const CACHE = 'diora-v136'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',