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:
parent
f9b3a0ce04
commit
7fdcbb4a3d
5 changed files with 709 additions and 9 deletions
74
podcasts/migrations/0007_feed_settings.py
Normal file
74
podcasts/migrations/0007_feed_settings.py
Normal 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),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -13,12 +13,16 @@ _parse = feedparser.parse
|
|||
|
||||
|
||||
def _rss(items):
|
||||
"""Items are (guid, date) or (guid, date, extra XML inside the <item>);
|
||||
the guid is the title too."""
|
||||
entries = ''.join(
|
||||
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>'
|
||||
for guid, date in items
|
||||
f'<enclosure url="https://example.org/{guid}.mp3" type="audio/mpeg" length="1"/>{extra[0] if extra else ""}'
|
||||
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):
|
||||
|
|
@ -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 = '<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'])
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ urlpatterns = [
|
|||
path('state/', views.podcast_state, name='podcast_state'),
|
||||
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>/settings/', views.feed_settings, name='podcast_feed_settings'),
|
||||
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
|
||||
path('episodes/', views.episode_list, name='podcast_episode_list'),
|
||||
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
|
||||
|
|
|
|||
|
|
@ -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/<id>/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],
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue