diff --git a/podcasts/migrations/0007_feed_settings.py b/podcasts/migrations/0007_feed_settings.py new file mode 100644 index 0000000..5f61cd8 --- /dev/null +++ b/podcasts/migrations/0007_feed_settings.py @@ -0,0 +1,74 @@ +from django.db import migrations, models + + +def auto_queue_to_new_episodes(apps, schema_editor): + """Feeds that queued their new episodes keep doing so.""" + PodcastFeed = apps.get_model('podcasts', 'PodcastFeed') + PodcastFeed.objects.filter(auto_queue=True).update(new_episodes='queue') + + +def new_episodes_to_auto_queue(apps, schema_editor): + PodcastFeed = apps.get_model('podcasts', 'PodcastFeed') + PodcastFeed.objects.filter(new_episodes='queue').update(auto_queue=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('podcasts', '0006_episodeprogress_favorite'), + ] + + operations = [ + migrations.AddField( + model_name='podcastfeed', + name='exclude_words', + field=models.CharField(blank=True, max_length=500), + ), + migrations.AddField( + model_name='podcastfeed', + name='include_words', + field=models.CharField(blank=True, max_length=500), + ), + migrations.AddField( + model_name='podcastfeed', + name='min_duration', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='podcastfeed', + name='new_episodes', + field=models.CharField( + choices=[('inbox', 'In „Neu“'), ('queue', 'In die Warteschlange'), ('ignore', 'Nicht anzeigen')], + default='inbox', max_length=10), + ), + migrations.AddField( + model_name='podcastfeed', + name='playback_speed', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='podcastfeed', + name='skip_intro', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='podcastfeed', + name='skip_outro', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='podcastfeed', + name='volume', + field=models.FloatField(default=1.0), + ), + migrations.AddField( + model_name='podcastsettings', + name='keep_sorted', + field=models.CharField( + blank=True, + choices=[('', 'Aus'), ('new', 'Neueste zuerst'), ('old', 'Älteste zuerst'), + ('short', 'Kürzeste zuerst'), ('long', 'Längste zuerst'), ('feed', 'Nach Podcast')], + default='', max_length=10), + ), + migrations.RunPython(auto_queue_to_new_episodes, new_episodes_to_auto_queue), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 24499b9..ff19425 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -3,6 +3,18 @@ from django.db import models class PodcastFeed(models.Model): + # What happens to a new episode of this feed (one that passed the filter). + NEW_INBOX = 'inbox' + NEW_QUEUE = 'queue' + NEW_IGNORE = 'ignore' + NEW_EPISODE_ACTIONS = [ + (NEW_INBOX, 'In „Neu“'), + (NEW_QUEUE, 'In die Warteschlange'), + (NEW_IGNORE, 'Nicht anzeigen'), + ] + # 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) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_feeds') rss_url = models.URLField(max_length=1000) title = models.CharField(max_length=300) @@ -12,8 +24,22 @@ class PodcastFeed(models.Model): link = models.URLField(max_length=1000, blank=True) last_refreshed_at = models.DateTimeField(null=True, blank=True) added_at = models.DateTimeField(auto_now_add=True) + # Always the same as new_episodes == 'queue' (see save()); kept for the + # web UI's toggle and older clients. auto_queue = models.BooleanField(default=False) + # Playback, for every device: null = the device's own speed. + playback_speed = models.FloatField(null=True, blank=True) + skip_intro = models.PositiveIntegerField(default=0) # seconds + skip_outro = models.PositiveIntegerField(default=0) # seconds + 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) + # 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) + min_duration = models.PositiveIntegerField(default=0) # minutes, 0 = any + class Meta: unique_together = ('user', 'rss_url') ordering = ['title'] @@ -21,6 +47,58 @@ class PodcastFeed(models.Model): def __str__(self): return self.title + @classmethod + def from_db(cls, *args, **kwargs): + obj = super().from_db(*args, **kwargs) + obj._remember_sync() + return obj + + def refresh_from_db(self, *args, **kwargs): + super().refresh_from_db(*args, **kwargs) + self._remember_sync() + + def _remember_sync(self): + 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 + 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)) + if self.new_episodes != new_before or self.auto_queue == auto_before: + self.auto_queue = self.new_episodes == self.NEW_QUEUE + elif self.auto_queue: + self.new_episodes = self.NEW_QUEUE + elif self.new_episodes == self.NEW_QUEUE: + self.new_episodes = self.NEW_INBOX + + def save(self, *args, **kwargs): + update_fields = kwargs.get('update_fields') + if update_fields is not None and not {'auto_queue', 'new_episodes'} & set(update_fields): + return super().save(*args, **kwargs) + self._sync_auto_queue() + if update_fields is not None: + kwargs['update_fields'] = set(update_fields) | {'auto_queue', 'new_episodes'} + super().save(*args, **kwargs) + self._remember_sync() + + def matches_filter(self, title, duration_seconds): + """Whether a new episode passes this feed's filter: an include word + (if any are given), no exclude word, and long enough (if known).""" + title = (title or '').casefold() + include = _words(self.include_words) + if include and not any(w in title for w in include): + return False + if any(w in title for w in _words(self.exclude_words)): + return False + if self.min_duration and duration_seconds and duration_seconds < self.min_duration * 60: + return False + return True + + +def _words(raw): + return [w.strip().casefold() for w in (raw or '').split(',') if w.strip()] + class PodcastEpisode(models.Model): feed = models.ForeignKey(PodcastFeed, on_delete=models.CASCADE, related_name='episodes') @@ -94,8 +172,8 @@ class PodcastQueue(models.Model): 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 with - auto_queue brings new ones.""" + by hand (also when played from outside the queue), and when a feed that + queues its new episodes brings some; and whether the queue stays sorted.""" BACK = 'back' FRONT = 'front' AFTER_CURRENT = 'after_current' @@ -105,10 +183,22 @@ class PodcastSettings(models.Model): (AFTER_CURRENT, 'Nach der laufenden Folge'), ] + # Keep the queue sorted: after every change the whole queue is sorted + # again by this rule ('' = off, the user's own order). + SORT_ORDERS = [ + ('', 'Aus'), + ('new', 'Neueste zuerst'), + ('old', 'Älteste zuerst'), + ('short', 'Kürzeste zuerst'), + ('long', 'Längste zuerst'), + ('feed', 'Nach Podcast'), + ] + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='podcast_settings') enqueue_position = models.CharField(max_length=20, choices=POSITIONS, default=BACK) # front = how auto-queue always worked before this setting existed auto_queue_position = models.CharField(max_length=20, choices=POSITIONS, default=FRONT) + keep_sorted = models.CharField(max_length=10, choices=SORT_ORDERS, default='', blank=True) @classmethod def for_user(cls, user): diff --git a/podcasts/tests.py b/podcasts/tests.py index 142630d..8ed25df 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -13,12 +13,16 @@ _parse = feedparser.parse def _rss(items): + """Items are (guid, date) or (guid, date, extra XML inside the ); + the guid is the title too.""" entries = ''.join( f'{guid}{guid}{date}' - f'' - for guid, date in items + f'{extra[0] if extra else ""}' + f'' + for guid, date, *extra in items ) - return f'Feed{entries}' + return ('' + f'Feed{entries}') class QueuePositionTests(TestCase): @@ -260,7 +264,8 @@ class RefreshAllTests(TestCase): self.assertTrue(data['inbox'][0]['in_queue']) # Shownotes stay out of the one-request state (they can be large). self.assertNotIn('description', data['inbox'][0]) - self.assertEqual(data['settings'], {'enqueue_position': 'back', 'auto_queue_position': 'front'}) + self.assertEqual(data['settings'], {'enqueue_position': 'back', 'auto_queue_position': 'front', + 'keep_sorted': ''}) # --------------------------------------------------------------------------- @@ -894,3 +899,359 @@ class OpmlExportTests(TestCase): def test_login_required(self): self.client.logout() self.assertEqual(self.client.get('/podcasts/feeds/export.opml').status_code, 401) + + +# --------------------------------------------------------------------------- +# Per-feed settings, new-episode rules, keep the queue sorted +# --------------------------------------------------------------------------- + +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, +} + + +class FeedSettingsTests(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/feed', title='Feed') + + def post(self, body, feed=None): + return self.client.post(f'/podcasts/feeds/{(feed or self.feed).id}/settings/', json.dumps(body), + content_type='application/json') + + def test_defaults(self): + r = self.post({}) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json(), {'feed_id': self.feed.id, **FEED_DEFAULTS}) + + def test_change_some(self): + r = self.post({'playback_speed': 1.5, 'skip_intro': 30, 'skip_outro': 600, 'volume': 2, + 'include_words': ' News, Talk ', 'exclude_words': 'Teaser', 'min_duration': 10, + 'unknown': 'ignored'}) + self.assertEqual(r.json(), { + 'feed_id': self.feed.id, 'playback_speed': 1.5, 'skip_intro': 30, 'skip_outro': 600, 'volume': 2.0, + 'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10, + 'auto_queue': False, + }) + self.feed.refresh_from_db() + self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration), + (1.5, 30, 2.0, 10)) + # Only what is sent changes; null clears the speed. + r = self.post({'playback_speed': None, 'include_words': ''}) + self.assertEqual((r.json()['playback_speed'], r.json()['include_words'], r.json()['skip_intro']), (None, '', 30)) + self.feed.refresh_from_db() + self.assertIsNone(self.feed.playback_speed) + + def test_every_allowed_volume_and_the_speed_limits(self): + for v in (0.2, 0.5, 1.0, 1.5, 2.0, 2.5): + self.assertEqual(self.post({'volume': v}).json()['volume'], v) + for s in (0.5, 3, 3.0): + self.assertEqual(self.post({'playback_speed': s}).json()['playback_speed'], s) + + def test_invalid_values_name_the_field_and_change_nothing(self): + bad = [ + ('playback_speed', 0.4), ('playback_speed', 3.5), ('playback_speed', 'fast'), ('playback_speed', True), + ('skip_intro', -1), ('skip_intro', 601), ('skip_intro', 1.5), ('skip_intro', '10'), ('skip_intro', None), + ('skip_outro', 601), ('volume', 0.7), ('volume', '1.0'), ('volume', None), + ('new_episodes', 'later'), ('new_episodes', None), + ('include_words', 'x' * 501), ('include_words', 5), ('exclude_words', ['a']), + ('min_duration', -1), ('min_duration', 601), + ] + for name, value in bad: + with self.subTest(name=name, value=value): + r = self.post({'skip_intro': 10, name: value} if name != 'skip_intro' else {name: value}) + self.assertEqual(r.status_code, 400) + self.assertIn(name, r.json()['error']) + self.assertEqual(r.json()['field'], name) + self.feed.refresh_from_db() + self.assertEqual({k: getattr(self.feed, k) for k in FEED_DEFAULTS}, FEED_DEFAULTS) + self.assertEqual(self.client.post(f'/podcasts/feeds/{self.feed.id}/settings/', '[1]', + content_type='application/json').status_code, 400) + + def test_owner_only(self): + bob = User.objects.create_user(username='bob', password='pw12345678') + bobs = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/feed', title='Feed') + self.assertEqual(self.post({'skip_intro': 10}, bobs).status_code, 404) + bobs.refresh_from_db() + self.assertEqual(bobs.skip_intro, 0) + self.assertEqual(self.client.post('/podcasts/feeds/999999/settings/', '{}', + content_type='application/json').status_code, 404) + self.assertEqual(self.client.get(f'/podcasts/feeds/{self.feed.id}/settings/').status_code, 405) + self.client.logout() + self.assertEqual(self.post({'skip_intro': 10}).status_code, 401) + + def test_new_episodes_and_auto_queue_stay_in_step(self): + self.assertEqual(self.post({'new_episodes': 'queue'}).json()['auto_queue'], True) + self.feed.refresh_from_db() + self.assertTrue(self.feed.auto_queue) + self.assertEqual(self.post({'new_episodes': 'ignore'}).json()['auto_queue'], False) + self.assertEqual(self.post({'new_episodes': 'inbox'}).json()['auto_queue'], False) + + def toggle(value=None): + body = {} if value is None else {'auto_queue': value} + r = self.client.post(f'/podcasts/feeds/{self.feed.id}/set-auto-queue/', json.dumps(body), + content_type='application/json') + self.feed.refresh_from_db() + return r.json()['auto_queue'], self.feed.auto_queue, self.feed.new_episodes + + self.assertEqual(toggle(), (True, True, 'queue')) + self.assertEqual(toggle(), (False, False, 'inbox')) + self.post({'new_episodes': 'ignore'}) + self.assertEqual(toggle(False), (False, False, 'ignore')) + self.assertEqual(toggle(True), (True, True, 'queue')) + # Also when a feed is created or saved in code. + f = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/2', title='2', auto_queue=True) + self.assertEqual(PodcastFeed.objects.get(pk=f.pk).new_episodes, 'queue') + f.new_episodes = 'inbox' + f.save() + self.assertFalse(PodcastFeed.objects.get(pk=f.pk).auto_queue) + + def test_feeds_and_state_carry_the_settings(self): + self.post({'playback_speed': 1.25, 'skip_outro': 20, 'volume': 0.5, 'new_episodes': 'queue', + 'exclude_words': 'Trailer', 'min_duration': 5}) + PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/2', title='Zweiter') + expected = { + self.feed.id: {'feed_id': self.feed.id, **FEED_DEFAULTS, 'playback_speed': 1.25, 'skip_outro': 20, + 'volume': 0.5, 'new_episodes': 'queue', 'exclude_words': 'Trailer', 'min_duration': 5, + 'auto_queue': True}, + } + for url in ('/podcasts/feeds/', '/podcasts/state/'): + with self.subTest(url=url): + feeds = {f['id']: f for f in self.client.get(url).json()['feeds']} + self.assertEqual(len(feeds), 2) + mine = feeds[self.feed.id] + self.assertEqual({k: mine[k] for k in expected[self.feed.id]}, expected[self.feed.id]) + other = next(f for f in feeds.values() if f['id'] != self.feed.id) + self.assertEqual({k: other[k] for k in FEED_DEFAULTS}, FEED_DEFAULTS) + self.assertEqual(other['feed_id'], other['id']) + + def test_migration_maps_auto_queue_to_new_episodes(self): + import importlib + from django.apps import apps + migration = importlib.import_module('podcasts.migrations.0007_feed_settings') + on = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/on', title='On') + off = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/off', title='Off') + # As the rows look right after the new columns are added. + PodcastFeed.objects.filter(pk=on.pk).update(auto_queue=True, new_episodes='inbox') + PodcastFeed.objects.filter(pk=off.pk).update(auto_queue=False, new_episodes='inbox') + migration.auto_queue_to_new_episodes(apps, None) + self.assertEqual(PodcastFeed.objects.get(pk=on.pk).new_episodes, 'queue') + self.assertEqual(PodcastFeed.objects.get(pk=off.pk).new_episodes, 'inbox') + self.assertEqual([op.__class__.__name__ for op in migration.Migration.operations][-1], 'RunPython') + self.assertEqual(migration.Migration.dependencies, [('podcasts', '0006_episodeprogress_favorite')]) + + +class NewEpisodeRuleTests(TestCase): + """What happens to a feed's new episodes on a refresh: its filter, then + inbox / queue / ignore.""" + + 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/feed', title='Feed', + last_refreshed_at=datetime(2026, 1, 1, tzinfo=timezone.utc), # not the first fetch + ) + other = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/o', title='O') + self.old = PodcastEpisode.objects.create(feed=other, guid='o0', title='O0', audio_url='https://example.org/o0.mp3') + PodcastQueue.objects.create(user=self.user, episode=self.old, position=0) + dur = '{}' + self.rss = _rss([ + ('Talk-Folge', 'Mon, 05 Jan 2026 10:00:00 GMT', dur.format('15:00')), + ('News-Teaser', 'Tue, 06 Jan 2026 10:00:00 GMT', dur.format('2:00')), + ('Kurzer-Talk', 'Wed, 07 Jan 2026 10:00:00 GMT', dur.format('300')), + ('Ohne-Laenge', 'Thu, 08 Jan 2026 10:00:00 GMT'), + ]) + + def configure(self, **settings): + PodcastFeed.objects.filter(pk=self.feed.pk).update(**settings) + self.feed.refresh_from_db() + + def refresh(self): + with patch('podcasts.views._fetch_feed', lambda url: _parse(self.rss)): + return _refresh_feed(self.feed) + + def inbox(self): + return [e['title'] for e in self.client.get('/podcasts/inbox/').json()['episodes'] if e['feed__id'] == self.feed.id] + + def queue(self): + return [q.episode.guid for q in PodcastQueue.objects.filter(user=self.user).order_by('position')] + + def dismissed(self): + return set(EpisodeProgress.objects.filter(user=self.user, dismissed=True, played=False) + .values_list('episode__guid', flat=True)) + + def test_inbox_is_the_default(self): + self.assertEqual(self.refresh(), 4) + self.assertEqual(self.inbox(), ['Ohne-Laenge', 'Kurzer-Talk', 'News-Teaser', 'Talk-Folge']) + self.assertEqual(self.queue(), ['o0']) + self.assertEqual(self.dismissed(), set()) + + def test_queue(self): + self.configure(new_episodes='queue', auto_queue=True) + self.refresh() + self.assertEqual(self.queue(), ['Ohne-Laenge', 'Kurzer-Talk', 'News-Teaser', 'Talk-Folge', 'o0']) + self.assertEqual(self.inbox(), []) + + def test_queue_at_the_auto_queue_position(self): + self.configure(new_episodes='queue', auto_queue=True) + PodcastSettings.objects.create(user=self.user, auto_queue_position='back') + self.refresh() + self.assertEqual(self.queue(), ['o0', 'Talk-Folge', 'News-Teaser', 'Kurzer-Talk', 'Ohne-Laenge']) + + def test_ignore(self): + self.configure(new_episodes='ignore') + self.assertEqual(self.refresh(), 4) + self.assertEqual(self.inbox(), []) + self.assertEqual(self.queue(), ['o0']) + self.assertEqual(self.dismissed(), {'Talk-Folge', 'News-Teaser', 'Kurzer-Talk', 'Ohne-Laenge'}) + # Still listed for the podcast, not played. + eps = self.client.get(f'/podcasts/feeds/{self.feed.id}/episodes/').json()['episodes'] + self.assertEqual(len(eps), 4) + self.assertFalse(any(e['played'] for e in eps)) + + def test_include_words(self): + self.configure(include_words='talk, nothing ,') + self.refresh() + self.assertEqual(self.inbox(), ['Kurzer-Talk', 'Talk-Folge']) + self.assertEqual(self.dismissed(), {'News-Teaser', 'Ohne-Laenge'}) + + def test_exclude_words(self): + self.configure(exclude_words='TEASER,laenge') + self.refresh() + self.assertEqual(self.inbox(), ['Kurzer-Talk', 'Talk-Folge']) + self.assertEqual(self.dismissed(), {'News-Teaser', 'Ohne-Laenge'}) + + def test_exclude_beats_include(self): + self.configure(include_words='talk', exclude_words='kurz') + self.refresh() + self.assertEqual(self.inbox(), ['Talk-Folge']) + + def test_min_duration_keeps_unknown_lengths(self): + self.configure(min_duration=5) # 300 s is just long enough, 120 s is not + self.refresh() + self.assertEqual(self.inbox(), ['Ohne-Laenge', 'Kurzer-Talk', 'Talk-Folge']) + self.assertEqual(self.dismissed(), {'News-Teaser'}) + PodcastEpisode.objects.filter(feed=self.feed).delete() + self.configure(min_duration=6) + self.refresh() + self.assertEqual(self.inbox(), ['Ohne-Laenge', 'Talk-Folge']) + + def test_filtered_episodes_are_not_queued(self): + self.configure(new_episodes='queue', auto_queue=True, exclude_words='teaser', min_duration=10) + self.refresh() + self.assertEqual(self.queue(), ['Ohne-Laenge', 'Talk-Folge', 'o0']) + self.assertEqual(self.inbox(), []) + self.assertFalse(EpisodeProgress.objects.filter(played=True).exists()) + + def test_only_new_episodes_are_affected(self): + self.refresh() # all four in the inbox + self.configure(new_episodes='ignore', exclude_words='talk') + self.rss = _rss([('Talk-Neu', 'Fri, 09 Jan 2026 10:00:00 GMT')] + [ + ('Talk-Folge', 'Mon, 05 Jan 2026 10:00:00 GMT')]) + self.assertEqual(self.refresh(), 1) + self.assertEqual(self.inbox(), ['Ohne-Laenge', 'Kurzer-Talk', 'News-Teaser', 'Talk-Folge']) + + def test_first_fetch_keeps_only_the_newest(self): + # E.g. imported by OPML and set to queue before its first refresh. + self.configure(last_refreshed_at=None, new_episodes='queue', auto_queue=True) + self.refresh() + self.assertEqual(self.queue(), ['Ohne-Laenge', 'o0']) + self.assertEqual(self.inbox(), []) + self.assertEqual(self.dismissed(), {'Talk-Folge', 'News-Teaser', 'Kurzer-Talk', 'Ohne-Laenge'}) + + +class KeepSortedTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + self.client.force_login(self.user) + beta = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta') + alpha = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='alpha') + + def ep(feed, guid, day, duration): + return PodcastEpisode.objects.create(feed=feed, guid=guid, title=guid, duration_seconds=duration, + audio_url=f'https://example.org/{guid}.mp3', pub_date=_at(1, day)) + + # remaining: e0 3000, e1 1000, e2 500 (2000 - 1500 heard), e3 600 + self.e0 = ep(beta, 'e0', 1, 3000) + self.e1 = ep(alpha, 'e1', 3, 1000) + self.e2 = ep(beta, 'e2', 2, 2000) + self.e3 = ep(alpha, 'e3', 4, 600) + EpisodeProgress.objects.create(user=self.user, episode=self.e2, position_seconds=1500) + for i, e in enumerate((self.e0, self.e1, self.e2)): + PodcastQueue.objects.create(user=self.user, episode=e, position=i) + + def post(self, url, body): + return self.client.post(url, json.dumps(body), content_type='application/json') + + def queue(self): + return [q['episode__title'] for q in self.client.get('/podcasts/queue/').json()['queue']] + + def keep(self, rule): + return self.post('/podcasts/settings/', {'keep_sorted': rule}) + + def test_setting_sorts_right_away(self): + expected = { + 'new': ['e1', 'e2', 'e0'], + 'old': ['e0', 'e2', 'e1'], + 'short': ['e2', 'e1', 'e0'], + 'long': ['e0', 'e1', 'e2'], + 'feed': ['e1', 'e2', 'e0'], + } + for rule, order in expected.items(): + with self.subTest(rule=rule): + r = self.keep(rule) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()['keep_sorted'], rule) + self.assertEqual(self.queue(), order) + self.assertEqual(self.client.get('/podcasts/settings/').json()['keep_sorted'], 'feed') + self.assertEqual(self.client.get('/podcasts/state/').json()['settings']['keep_sorted'], 'feed') + + def test_default_off_and_invalid(self): + self.assertEqual(self.client.get('/podcasts/settings/').json()['keep_sorted'], '') + self.assertEqual(self.keep('random').status_code, 400) + self.assertEqual(self.keep(None).status_code, 400) + self.assertEqual(PodcastSettings.for_user(self.user).keep_sorted, '') + self.assertEqual(self.queue(), ['e0', 'e1', 'e2']) + + def test_off_keeps_the_users_order(self): + self.keep('new') + self.keep('') + self.post('/podcasts/queue/reorder/', {'order': [self.e0.id, self.e2.id, self.e1.id]}) + self.assertEqual(self.queue(), ['e0', 'e2', 'e1']) + self.post('/podcasts/queue/add/', {'episode_ids': [self.e3.id], 'where': 'front'}) + self.assertEqual(self.queue(), ['e3', 'e0', 'e2', 'e1']) + + def test_enqueue_is_sorted(self): + self.keep('old') + self.post('/podcasts/queue/add/', {'episode_ids': [self.e3.id], 'where': 'front'}) + self.assertEqual(self.queue(), ['e0', 'e2', 'e1', 'e3']) + PodcastQueue.objects.filter(episode=self.e3).delete() + self.keep('short') + self.post('/podcasts/queue/add/', {'episode_ids': [self.e3.id]}) + self.assertEqual(self.queue(), ['e2', 'e3', 'e1', 'e0']) + + def test_feed_rule_newest_first_within_a_podcast(self): + self.keep('feed') + self.post('/podcasts/queue/add/', {'episode_ids': [self.e3.id]}) + self.assertEqual(self.queue(), ['e3', 'e1', 'e2', 'e0']) + + def test_reorder_is_undone(self): + self.keep('long') + self.post('/podcasts/queue/reorder/', {'order': [self.e2.id, self.e1.id, self.e0.id]}) + self.assertEqual(self.queue(), ['e0', 'e1', 'e2']) + # Listening changes what is left: e0 now has the least left. + EpisodeProgress.objects.create(user=self.user, episode=self.e0, position_seconds=2900) + self.post('/podcasts/queue/reorder/', {'order': [self.e0.id, self.e1.id, self.e2.id]}) + self.assertEqual(self.queue(), ['e1', 'e2', 'e0']) + + def test_auto_queued_episodes_are_sorted_too(self): + self.keep('old') + feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/n', title='N', + new_episodes='queue', last_refreshed_at=_at(1, 1)) + with patch('podcasts.views._fetch_feed', + lambda url: _parse(_rss([('n1', 'Fri, 02 Jan 2026 12:00:00 GMT')]))): + _refresh_feed(feed) + self.assertEqual(self.queue(), ['e0', 'e2', 'n1', 'e1']) diff --git a/podcasts/urls.py b/podcasts/urls.py index d0948f0..f0941de 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path('state/', views.podcast_state, name='podcast_state'), path('feeds//remove/', views.remove_feed, name='podcast_remove_feed'), path('feeds//set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'), + path('feeds//settings/', views.feed_settings, name='podcast_feed_settings'), path('feeds//episodes/', views.feed_episodes, name='podcast_feed_episodes'), path('episodes/', views.episode_list, name='podcast_episode_list'), path('episodes//', views.episode_detail, name='podcast_episode_detail'), diff --git a/podcasts/views.py b/podcasts/views.py index f38f967..952ca6e 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -38,6 +38,7 @@ MAX_TRANSCRIPTS = 10 # --------------------------------------------------------------------------- QUEUE_POSITIONS = {key for key, _ in PodcastSettings.POSITIONS} +KEEP_SORTED = {key for key, _ in PodcastSettings.SORT_ORDERS} def _queue_order(user): @@ -102,9 +103,58 @@ def _enqueue(user, episode_ids, where, current_hint=None): ) _write_queue_order(user, queued[:at] + new + queued[at:]) _leave_inbox(user, new) + _keep_sorted(user) return new +def _sort_key(rule): + """Sort key for one queue row ({pub_date, duration, position, feed_title}) + by a PodcastSettings.SORT_ORDERS rule. Ties keep their current order + (the sort is stable).""" + def ts(row): + return row['pub_date'].timestamp() if row['pub_date'] else None + + def remaining(row): + return max((row['duration'] or 0) - (row['position'] or 0), 0) + + if rule == 'new': # no date: last + return lambda r: (ts(r) is None, -(ts(r) or 0)) + if rule == 'old': # no date: last as well + return lambda r: (ts(r) is None, ts(r) or 0) + if rule == 'short': + return remaining + if rule == 'long': + return lambda r: -remaining(r) + if rule == 'feed': # by podcast title, then newest first + return lambda r: ((r['feed_title'] or '').casefold(), ts(r) is None, -(ts(r) or 0)) + return None + + +def _keep_sorted(user, rule=None): + """Sort the user's whole queue by their keep_sorted rule (or `rule`), if + there is one. Returns True if it is kept sorted.""" + if rule is None: + rule = PodcastSettings.objects.filter(user=user).values_list('keep_sorted', flat=True).first() or '' + key = _sort_key(rule) + if key is None: + return False + rows = list( + PodcastQueue.objects.filter(user=user) + .order_by('position', 'added_at', 'id') + .values('episode_id', pub_date=F('episode__pub_date'), duration=F('episode__duration_seconds'), + feed_title=F('episode__feed__title')) + ) + positions = dict( + EpisodeProgress.objects.filter(user=user, episode_id__in=[r['episode_id'] for r in rows]) + .values_list('episode_id', 'position_seconds') + ) + for r in rows: + r['position'] = positions.get(r['episode_id'], 0) + rows.sort(key=key) + _write_queue_order(user, [r['episode_id'] for r in rows]) + return True + + def _leave_inbox(user, episode_ids): """Queued or started episodes are no longer new (AntennaPod's rule): out of the inbox, without counting as played.""" @@ -381,7 +431,20 @@ def _refresh_feed(feed_obj, parsed=None): ) new_episodes_list = newest_first[:NEW_ON_SUBSCRIBE] - if feed_obj.auto_queue and new_episodes_list: + # The feed's own rules for its new episodes: those its filter turns down, + # and all of them for 'ignore', go straight out of the inbox (not played); + # 'queue' puts them in the queue (which also takes them out of the inbox); + # 'inbox' leaves them there. + if new_episodes_list: + unwanted = [e for e in new_episodes_list if not feed_obj.matches_filter(e.title, e.duration_seconds)] + if feed_obj.new_episodes == PodcastFeed.NEW_IGNORE: + unwanted = new_episodes_list + if unwanted: + gone = {e.id for e in unwanted} + _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: 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. @@ -439,14 +502,26 @@ def feed_list(request): return JsonResponse({'feeds': _feeds_data(request.user)}) +FEED_SETTING_FIELDS = ( + 'playback_speed', 'skip_intro', 'skip_outro', 'volume', + 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', +) + + +def _feed_settings(feed): + return {'feed_id': feed.id, **{name: getattr(feed, name) for name in FEED_SETTING_FIELDS}} + + def _feeds_data(user): feeds = list( user.podcast_feeds .annotate(latest_episode_at=Max('episodes__pub_date')) - .values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue', 'latest_episode_at') + .values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', + 'latest_episode_at', *FEED_SETTING_FIELDS) ) for f in feeds: + f['feed_id'] = f['id'] # as in feeds//settings/ if f['last_refreshed_at']: f['last_refreshed_at'] = f['last_refreshed_at'].isoformat() if f['added_at']: @@ -534,11 +609,99 @@ def set_auto_queue(request, pk): feed = PodcastFeed.objects.get(pk=pk, user=request.user) except PodcastFeed.DoesNotExist: return JsonResponse({'error': 'not found'}, status=404) + # On: new episodes go to the queue; off: back to the inbox (a feed set + # to 'ignore' stays so). PodcastFeed.save() sets new_episodes to match. feed.auto_queue = bool(body.get('auto_queue', not feed.auto_queue)) feed.save(update_fields=['auto_queue']) return JsonResponse({'ok': True, 'auto_queue': feed.auto_queue}) +# --------------------------------------------------------------------------- +# Per-feed settings +# --------------------------------------------------------------------------- + +def _number(value): + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _whole(value, low, high): + if not _number(value) or value != int(value) or not low <= value <= high: + raise ValueError + return int(value) + + +def _speed(value): + if value is None: + return None + if not _number(value) or not 0.5 <= value <= 3.0: + raise ValueError + return float(value) + + +def _volume(value): + if not _number(value) or float(value) not in PodcastFeed.VOLUMES: + raise ValueError + return float(value) + + +def _new_episodes(value): + if value not in {key for key, _ in PodcastFeed.NEW_EPISODE_ACTIONS}: + raise ValueError + return value + + +def _word_list(value): + if value is None: + return '' + if not isinstance(value, str) or len(value.strip()) > 500: + raise ValueError + return value.strip() + + +FEED_SETTING_PARSERS = { + 'playback_speed': _speed, + 'skip_intro': lambda v: _whole(v, 0, 600), + 'skip_outro': lambda v: _whole(v, 0, 600), + 'volume': _volume, + 'new_episodes': _new_episodes, + 'include_words': _word_list, + 'exclude_words': _word_list, + 'min_duration': lambda v: _whole(v, 0, 600), +} + + +@csrf_exempt +@require_http_methods(['POST']) +def feed_settings(request, pk): + """Change some of one feed's settings (any of FEED_SETTING_PARSERS); + returns all of them. Nothing is changed if one value is invalid.""" + 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) + if not isinstance(body, dict): + return JsonResponse({'error': 'invalid JSON'}, status=400) + try: + feed = PodcastFeed.objects.get(pk=pk, user=request.user) + except PodcastFeed.DoesNotExist: + return JsonResponse({'error': 'not found'}, status=404) + + changes = {} + for name, parse in FEED_SETTING_PARSERS.items(): + if name in body: + try: + changes[name] = parse(body[name]) + except (ValueError, TypeError, OverflowError): + return JsonResponse({'error': f'invalid {name}', 'field': name}, status=400) + if changes: + for name, value in changes.items(): + setattr(feed, name, value) + feed.save(update_fields=list(changes)) + return JsonResponse(_feed_settings(feed)) + + # --------------------------------------------------------------------------- # Feed episodes # --------------------------------------------------------------------------- @@ -989,7 +1152,8 @@ def podcast_state(request): 'feeds': _feeds_data(request.user), 'queue': _queue_data(request.user), 'inbox': _inbox_data(request.user, 0, limit, with_description=False), - 'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position}, + 'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position, + 'keep_sorted': s.keep_sorted}, 'server_time': timezone.now().isoformat(), }) @@ -1101,6 +1265,8 @@ def queue_reorder(request): order = body.get('order', []) # list of episode ids for pos, ep_id in enumerate(order): PodcastQueue.objects.filter(user=request.user, episode_id=ep_id).update(position=pos) + # A queue that is kept sorted goes straight back into its order. + _keep_sorted(request.user) return JsonResponse({'ok': True}) @@ -1123,13 +1289,21 @@ def podcast_settings(request): return JsonResponse({'error': f'unknown {name}'}, status=400) setattr(s, name, body[name]) fields.append(name) + if 'keep_sorted' in body: + if body['keep_sorted'] not in KEEP_SORTED: + return JsonResponse({'error': 'unknown keep_sorted'}, status=400) + s.keep_sorted = body['keep_sorted'] + fields.append('keep_sorted') if fields: s.save(update_fields=fields) + if 'keep_sorted' in fields: + _keep_sorted(request.user, s.keep_sorted) # sorted right away elif request.method != 'GET': return JsonResponse({'error': 'method not allowed'}, status=405) return JsonResponse({ 'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position, + 'keep_sorted': s.keep_sorted, 'positions': [{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS], })