Podcasts: Einstellungen pro Podcast auf dem Server, Regeln für neue Folgen, Warteschlange sortiert halten

- PodcastFeed (Migration 0007_feed_settings): playback_speed (null = Tempo
  des Geräts), skip_intro, skip_outro, volume (0.2/0.5/1.0/1.5/2.0/2.5),
  new_episodes (inbox/queue/ignore), include_words, exclude_words,
  min_duration. Abos mit auto_queue bekommen new_episodes='queue';
  auto_queue bleibt gleich (new_episodes == 'queue'), auch über
  set-auto-queue/ und beim Speichern im Code.
- POST feeds/<id>/settings/: beliebige Teilmenge, prüft jeden Wert (400 mit
  Feldname, dann wird nichts geändert), nur eigene Abos (sonst 404);
  Antwort sind alle Einstellungen des Abos. feeds/ und state/ liefern sie
  für jedes Abo mit.
- Neue Folgen beim Aktualisieren: was am Filter scheitert (keins der
  Wörter, ein Ausschlusswort im Titel, kürzer als die Mindestlänge) und
  bei 'ignore' alles verlässt sofort „Neu“ (nicht gehört); 'queue' reiht
  an der auto_queue_position ein; 'inbox' wie bisher. Beim ersten Abruf
  zählt weiter nur die neueste Folge.
- PodcastSettings.keep_sorted ('', new, old, short, long, feed): danach
  wird die ganze Warteschlange nach jedem Einreihen, jedem Umsortieren und
  beim Einschalten neu sortiert; settings/ und state/ liefern den Wert.
- Tests für alles davon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-02 09:47:49 +02:00
parent f9b3a0ce04
commit 7fdcbb4a3d
5 changed files with 709 additions and 9 deletions

View file

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

View file

@ -3,6 +3,18 @@ from django.db import models
class PodcastFeed(models.Model): 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') user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_feeds')
rss_url = models.URLField(max_length=1000) rss_url = models.URLField(max_length=1000)
title = models.CharField(max_length=300) title = models.CharField(max_length=300)
@ -12,8 +24,22 @@ class PodcastFeed(models.Model):
link = models.URLField(max_length=1000, blank=True) link = models.URLField(max_length=1000, blank=True)
last_refreshed_at = models.DateTimeField(null=True, blank=True) last_refreshed_at = models.DateTimeField(null=True, blank=True)
added_at = models.DateTimeField(auto_now_add=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) 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: class Meta:
unique_together = ('user', 'rss_url') unique_together = ('user', 'rss_url')
ordering = ['title'] ordering = ['title']
@ -21,6 +47,58 @@ class PodcastFeed(models.Model):
def __str__(self): def __str__(self):
return self.title 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): class PodcastEpisode(models.Model):
feed = models.ForeignKey(PodcastFeed, on_delete=models.CASCADE, related_name='episodes') feed = models.ForeignKey(PodcastFeed, on_delete=models.CASCADE, related_name='episodes')
@ -94,8 +172,8 @@ class PodcastQueue(models.Model):
class PodcastSettings(models.Model): class PodcastSettings(models.Model):
"""A user's podcast preferences. Where episodes go in the queue: when added """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 by hand (also when played from outside the queue), and when a feed that
auto_queue brings new ones.""" queues its new episodes brings some; and whether the queue stays sorted."""
BACK = 'back' BACK = 'back'
FRONT = 'front' FRONT = 'front'
AFTER_CURRENT = 'after_current' AFTER_CURRENT = 'after_current'
@ -105,10 +183,22 @@ class PodcastSettings(models.Model):
(AFTER_CURRENT, 'Nach der laufenden Folge'), (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') user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='podcast_settings')
enqueue_position = models.CharField(max_length=20, choices=POSITIONS, default=BACK) enqueue_position = models.CharField(max_length=20, choices=POSITIONS, default=BACK)
# front = how auto-queue always worked before this setting existed # front = how auto-queue always worked before this setting existed
auto_queue_position = models.CharField(max_length=20, choices=POSITIONS, default=FRONT) 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 @classmethod
def for_user(cls, user): def for_user(cls, user):

View file

@ -13,12 +13,16 @@ _parse = feedparser.parse
def _rss(items): def _rss(items):
"""Items are (guid, date) or (guid, date, extra XML inside the <item>);
the guid is the title too."""
entries = ''.join( entries = ''.join(
f'<item><guid>{guid}</guid><title>{guid}</title><pubDate>{date}</pubDate>' f'<item><guid>{guid}</guid><title>{guid}</title><pubDate>{date}</pubDate>'
f'<enclosure url="https://example.org/{guid}.mp3" type="audio/mpeg" length="1"/></item>' f'<enclosure url="https://example.org/{guid}.mp3" type="audio/mpeg" length="1"/>{extra[0] if extra else ""}'
for guid, date in items f'</item>'
for guid, date, *extra in items
) )
return f'<?xml version="1.0"?><rss version="2.0"><channel><title>Feed</title>{entries}</channel></rss>' return ('<?xml version="1.0"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">'
f'<channel><title>Feed</title>{entries}</channel></rss>')
class QueuePositionTests(TestCase): class QueuePositionTests(TestCase):
@ -260,7 +264,8 @@ class RefreshAllTests(TestCase):
self.assertTrue(data['inbox'][0]['in_queue']) self.assertTrue(data['inbox'][0]['in_queue'])
# Shownotes stay out of the one-request state (they can be large). # Shownotes stay out of the one-request state (they can be large).
self.assertNotIn('description', data['inbox'][0]) 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): def test_login_required(self):
self.client.logout() self.client.logout()
self.assertEqual(self.client.get('/podcasts/feeds/export.opml').status_code, 401) 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 = '<itunes:duration>{}</itunes:duration>'
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'])

View file

@ -12,6 +12,7 @@ urlpatterns = [
path('state/', views.podcast_state, name='podcast_state'), path('state/', views.podcast_state, name='podcast_state'),
path('feeds/<int:pk>/remove/', views.remove_feed, name='podcast_remove_feed'), path('feeds/<int:pk>/remove/', views.remove_feed, name='podcast_remove_feed'),
path('feeds/<int:pk>/set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'), path('feeds/<int:pk>/set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'),
path('feeds/<int:pk>/settings/', views.feed_settings, name='podcast_feed_settings'),
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'), path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
path('episodes/', views.episode_list, name='podcast_episode_list'), path('episodes/', views.episode_list, name='podcast_episode_list'),
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'), path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),

View file

@ -38,6 +38,7 @@ MAX_TRANSCRIPTS = 10
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
QUEUE_POSITIONS = {key for key, _ in PodcastSettings.POSITIONS} QUEUE_POSITIONS = {key for key, _ in PodcastSettings.POSITIONS}
KEEP_SORTED = {key for key, _ in PodcastSettings.SORT_ORDERS}
def _queue_order(user): 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:]) _write_queue_order(user, queued[:at] + new + queued[at:])
_leave_inbox(user, new) _leave_inbox(user, new)
_keep_sorted(user)
return new 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): def _leave_inbox(user, episode_ids):
"""Queued or started episodes are no longer new (AntennaPod's rule): out """Queued or started episodes are no longer new (AntennaPod's rule): out
of the inbox, without counting as played.""" 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] 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 where = PodcastSettings.for_user(feed_obj.user).auto_queue_position
# Newest first in front of what's there; oldest first when appended, # 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. # 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)}) 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): def _feeds_data(user):
feeds = list( feeds = list(
user.podcast_feeds user.podcast_feeds
.annotate(latest_episode_at=Max('episodes__pub_date')) .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: for f in feeds:
f['feed_id'] = f['id'] # as in feeds/<id>/settings/
if f['last_refreshed_at']: if f['last_refreshed_at']:
f['last_refreshed_at'] = f['last_refreshed_at'].isoformat() f['last_refreshed_at'] = f['last_refreshed_at'].isoformat()
if f['added_at']: if f['added_at']:
@ -534,11 +609,99 @@ def set_auto_queue(request, pk):
feed = PodcastFeed.objects.get(pk=pk, user=request.user) feed = PodcastFeed.objects.get(pk=pk, user=request.user)
except PodcastFeed.DoesNotExist: except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404) 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.auto_queue = bool(body.get('auto_queue', not feed.auto_queue))
feed.save(update_fields=['auto_queue']) feed.save(update_fields=['auto_queue'])
return JsonResponse({'ok': True, 'auto_queue': feed.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 # Feed episodes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -989,7 +1152,8 @@ def podcast_state(request):
'feeds': _feeds_data(request.user), 'feeds': _feeds_data(request.user),
'queue': _queue_data(request.user), 'queue': _queue_data(request.user),
'inbox': _inbox_data(request.user, 0, limit, with_description=False), '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(), 'server_time': timezone.now().isoformat(),
}) })
@ -1101,6 +1265,8 @@ def queue_reorder(request):
order = body.get('order', []) # list of episode ids order = body.get('order', []) # list of episode ids
for pos, ep_id in enumerate(order): for pos, ep_id in enumerate(order):
PodcastQueue.objects.filter(user=request.user, episode_id=ep_id).update(position=pos) 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}) return JsonResponse({'ok': True})
@ -1123,13 +1289,21 @@ def podcast_settings(request):
return JsonResponse({'error': f'unknown {name}'}, status=400) return JsonResponse({'error': f'unknown {name}'}, status=400)
setattr(s, name, body[name]) setattr(s, name, body[name])
fields.append(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: if fields:
s.save(update_fields=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': elif request.method != 'GET':
return JsonResponse({'error': 'method not allowed'}, status=405) return JsonResponse({'error': 'method not allowed'}, status=405)
return JsonResponse({ return JsonResponse({
'enqueue_position': s.enqueue_position, 'enqueue_position': s.enqueue_position,
'auto_queue_position': s.auto_queue_position, 'auto_queue_position': s.auto_queue_position,
'keep_sorted': s.keep_sorted,
'positions': [{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS], 'positions': [{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS],
}) })