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],
})