diff --git a/podcasts/management/commands/refresh_feeds.py b/podcasts/management/commands/refresh_feeds.py
index 1934a26..1c6f601 100644
--- a/podcasts/management/commands/refresh_feeds.py
+++ b/podcasts/management/commands/refresh_feeds.py
@@ -3,7 +3,7 @@ import sys
from django.core.management.base import BaseCommand
from podcasts.models import PodcastFeed
-from podcasts.views import _refresh_feed
+from podcasts.views import _refresh_feeds
class Command(BaseCommand):
@@ -26,9 +26,6 @@ class Command(BaseCommand):
self.stdout.write('No feeds to refresh.')
return
- for feed in qs:
- try:
- new_ep = _refresh_feed(feed)
- self.stdout.write(f'{feed.title}: +{new_ep} episodes')
- except Exception as e:
- self.stderr.write(f'ERROR refreshing "{feed.title}" ({feed.rss_url}): {e}')
+ # Parallel downloads, one per distinct feed URL (see _refresh_feeds).
+ refreshed, failed, new = _refresh_feeds(qs)
+ self.stdout.write(f'{refreshed} feeds refreshed, {failed} failed, +{new} episodes')
diff --git a/podcasts/migrations/0004_podcastsettings.py b/podcasts/migrations/0004_podcastsettings.py
new file mode 100644
index 0000000..e93b2eb
--- /dev/null
+++ b/podcasts/migrations/0004_podcastsettings.py
@@ -0,0 +1,27 @@
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('podcasts', '0003_episodeprogress_dismissed'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+ operations = [
+ migrations.CreateModel(
+ name='PodcastSettings',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('enqueue_position', models.CharField(
+ choices=[('back', 'Ans Ende'), ('front', 'An den Anfang'), ('after_current', 'Nach der laufenden Folge')],
+ default='back', max_length=20)),
+ ('auto_queue_position', models.CharField(
+ choices=[('back', 'Ans Ende'), ('front', 'An den Anfang'), ('after_current', 'Nach der laufenden Folge')],
+ default='front', max_length=20)),
+ ('user', models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE, related_name='podcast_settings',
+ to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/podcasts/models.py b/podcasts/models.py
index e12a3af..a80f060 100644
--- a/podcasts/models.py
+++ b/podcasts/models.py
@@ -71,3 +71,29 @@ class PodcastQueue(models.Model):
def __str__(self):
return f'{self.user} queue pos {self.position}: {self.episode}'
+
+class PodcastSettings(models.Model):
+ """A user's podcast preferences. Where episodes go in the queue: when added
+ by hand (also when played from outside the queue), and when a feed with
+ auto_queue brings new ones."""
+ BACK = 'back'
+ FRONT = 'front'
+ AFTER_CURRENT = 'after_current'
+ POSITIONS = [
+ (BACK, 'Ans Ende'),
+ (FRONT, 'An den Anfang'),
+ (AFTER_CURRENT, 'Nach der laufenden Folge'),
+ ]
+
+ 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)
+
+ @classmethod
+ def for_user(cls, user):
+ obj, _ = cls.objects.get_or_create(user=user)
+ return obj
+
+ def __str__(self):
+ return f'Podcast settings of {self.user}'
diff --git a/podcasts/tests.py b/podcasts/tests.py
new file mode 100644
index 0000000..2828a4c
--- /dev/null
+++ b/podcasts/tests.py
@@ -0,0 +1,196 @@
+import json
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import feedparser
+from django.contrib.auth.models import User
+from django.test import TestCase
+
+from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
+from .views import _refresh_feed
+
+_parse = feedparser.parse
+
+
+def _rss(items):
+ entries = ''.join(
+ f'- {guid}{guid}{date}'
+ f'
'
+ for guid, date in items
+ )
+ return f'Feed{entries}'
+
+
+class QueuePositionTests(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')
+ base = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ self.eps = [
+ PodcastEpisode.objects.create(
+ feed=self.feed, guid=f'e{i}', title=f'E{i}',
+ audio_url=f'https://example.org/e{i}.mp3', pub_date=base + timedelta(days=i),
+ )
+ for i in range(6)
+ ]
+
+ def queue(self):
+ return list(PodcastQueue.objects.filter(user=self.user).order_by('position').values_list('episode_id', flat=True))
+
+ def add(self, *episodes, **extra):
+ body = {'episode_ids': [e.id for e in episodes], **extra}
+ return self.client.post('/podcasts/queue/add/', json.dumps(body), content_type='application/json')
+
+ def test_default_adds_to_the_back(self):
+ self.add(self.eps[0])
+ self.add(self.eps[1])
+ self.assertEqual(self.queue(), [self.eps[0].id, self.eps[1].id])
+
+ def test_front_from_setting(self):
+ self.add(self.eps[0], self.eps[1])
+ PodcastSettings.objects.update_or_create(user=self.user, defaults={'enqueue_position': 'front'})
+ self.add(self.eps[2])
+ self.assertEqual(self.queue(), [self.eps[2].id, self.eps[0].id, self.eps[1].id])
+
+ def test_after_current_uses_client_hint(self):
+ self.add(self.eps[0], self.eps[1], self.eps[2])
+ self.add(self.eps[3], where='after_current', current_episode_id=self.eps[1].id)
+ self.assertEqual(self.queue(), [e.id for e in (self.eps[0], self.eps[1], self.eps[3], self.eps[2])])
+
+ def test_after_current_falls_back_to_last_listened(self):
+ self.add(self.eps[0], self.eps[1], self.eps[2])
+ EpisodeProgress.objects.create(user=self.user, episode=self.eps[2], position_seconds=30)
+ self.add(self.eps[3], where='after_current')
+ self.assertEqual(self.queue(), [e.id for e in (self.eps[0], self.eps[1], self.eps[2], self.eps[3])])
+
+ def test_after_current_without_current_goes_to_front(self):
+ self.add(self.eps[0])
+ self.add(self.eps[1], where='after_current')
+ self.assertEqual(self.queue(), [self.eps[1].id, self.eps[0].id])
+
+ def test_already_queued_stays_where_it_is(self):
+ self.add(self.eps[0], self.eps[1])
+ r = self.add(self.eps[0], where='front')
+ self.assertFalse(r.json()['created'])
+ self.assertEqual(self.queue(), [self.eps[0].id, self.eps[1].id])
+
+ def test_single_episode_id_still_works(self):
+ r = self.client.post('/podcasts/queue/add/', json.dumps({'episode_id': self.eps[4].id}),
+ content_type='application/json')
+ self.assertTrue(r.json()['created'])
+ self.assertEqual(self.queue(), [self.eps[4].id])
+
+ def test_other_users_episode_is_not_found(self):
+ bob = User.objects.create_user(username='bob', password='pw12345678')
+ feed = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/b', title='B')
+ ep = PodcastEpisode.objects.create(feed=feed, guid='x', title='X', audio_url='https://example.org/x.mp3')
+ r = self.add(ep)
+ self.assertEqual(r.status_code, 404)
+
+ def test_unknown_position_is_rejected(self):
+ self.assertEqual(self.add(self.eps[0], where='middle').status_code, 400)
+
+ def test_settings_roundtrip(self):
+ r = self.client.get('/podcasts/settings/')
+ self.assertEqual(r.json()['enqueue_position'], 'back')
+ self.assertEqual(r.json()['auto_queue_position'], 'front')
+ r = self.client.post('/podcasts/settings/', json.dumps({'auto_queue_position': 'after_current'}),
+ content_type='application/json')
+ self.assertEqual(r.json()['auto_queue_position'], 'after_current')
+ self.assertEqual(PodcastSettings.objects.get(user=self.user).auto_queue_position, 'after_current')
+ bad = self.client.post('/podcasts/settings/', json.dumps({'enqueue_position': 'nowhere'}),
+ content_type='application/json')
+ self.assertEqual(bad.status_code, 400)
+
+
+class AutoQueueTests(TestCase):
+ """New episodes of an auto_queue feed land where the user's setting says."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.feed = PodcastFeed.objects.create(
+ user=self.user, rss_url='https://example.org/feed', title='Feed', auto_queue=True,
+ )
+ other = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/o', title='O')
+ self.old = [
+ PodcastEpisode.objects.create(feed=other, guid=f'o{i}', title=f'O{i}', audio_url=f'https://example.org/o{i}.mp3')
+ for i in range(3)
+ ]
+ for i, ep in enumerate(self.old):
+ PodcastQueue.objects.create(user=self.user, episode=ep, position=i)
+ self.rss = _rss([
+ ('new1', 'Mon, 05 Jan 2026 10:00:00 GMT'),
+ ('new2', 'Tue, 06 Jan 2026 10:00:00 GMT'),
+ ])
+
+ def refresh(self):
+ with patch('podcasts.views._fetch_feed', lambda url: _parse(self.rss)):
+ _refresh_feed(self.feed)
+ order = PodcastQueue.objects.filter(user=self.user).order_by('position')
+ return [q.episode.guid for q in order]
+
+ def test_front_is_the_default(self):
+ self.assertEqual(self.refresh(), ['new2', 'new1', 'o0', 'o1', 'o2'])
+
+ def test_back_appends_oldest_first(self):
+ PodcastSettings.objects.create(user=self.user, auto_queue_position='back')
+ self.assertEqual(self.refresh(), ['o0', 'o1', 'o2', 'new1', 'new2'])
+
+ def test_after_current(self):
+ PodcastSettings.objects.create(user=self.user, auto_queue_position='after_current')
+ EpisodeProgress.objects.create(user=self.user, episode=self.old[1], position_seconds=12)
+ self.assertEqual(self.refresh(), ['o0', 'o1', 'new2', 'new1', 'o2'])
+
+ def test_feed_without_auto_queue_queues_nothing(self):
+ self.feed.auto_queue = False
+ self.feed.save()
+ self.assertEqual(self.refresh(), ['o0', 'o1', 'o2'])
+
+
+class RefreshAllTests(TestCase):
+ """One request refreshes all feeds on the server; one request returns the state."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.client.force_login(self.user)
+ self.a = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='A')
+ self.b = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='B')
+ bob = User.objects.create_user(username='bob', password='pw12345678')
+ self.bobs = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='A')
+ self.fetched = []
+
+ def fake_fetch(self, url):
+ self.fetched.append(url)
+ if url.endswith('/b'):
+ raise ValueError('broken feed')
+ return _parse(_rss([(url[-1] + '1', 'Mon, 05 Jan 2026 10:00:00 GMT')]))
+
+ def test_refresh_all_reports_counts_and_skips_recent(self):
+ with patch('podcasts.views._fetch_feed', self.fake_fetch):
+ r = self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json')
+ self.assertEqual(r.json(), {'ok': True, 'refreshed': 1, 'failed': 1, 'new_episodes': 1})
+ self.assertEqual(sorted(self.fetched), ['https://example.org/a', 'https://example.org/b'])
+ # A was just refreshed: a second call within max_age only retries B.
+ self.fetched.clear()
+ with patch('podcasts.views._fetch_feed', self.fake_fetch):
+ self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json')
+ self.assertEqual(self.fetched, ['https://example.org/b'])
+
+ def test_a_shared_feed_url_is_fetched_once(self):
+ from .views import _refresh_feeds
+ with patch('podcasts.views._fetch_feed', self.fake_fetch):
+ refreshed, failed, new = _refresh_feeds([self.a, self.bobs])
+ self.assertEqual(self.fetched, ['https://example.org/a'])
+ self.assertEqual((refreshed, failed, new), (2, 0, 2))
+
+ def test_state_in_one_response(self):
+ ep = PodcastEpisode.objects.create(feed=self.a, guid='x', title='X', audio_url='https://example.org/x.mp3')
+ PodcastQueue.objects.create(user=self.user, episode=ep, position=0)
+ r = self.client.get('/podcasts/state/')
+ data = r.json()
+ self.assertEqual({f['title'] for f in data['feeds']}, {'A', 'B'})
+ self.assertEqual([q['episode__id'] for q in data['queue']], [ep.id])
+ self.assertEqual([e['id'] for e in data['inbox']], [ep.id])
+ self.assertTrue(data['inbox'][0]['in_queue'])
+ self.assertEqual(data['settings'], {'enqueue_position': 'back', 'auto_queue_position': 'front'})
diff --git a/podcasts/urls.py b/podcasts/urls.py
index 4d7db04..4f65147 100644
--- a/podcasts/urls.py
+++ b/podcasts/urls.py
@@ -7,6 +7,8 @@ urlpatterns = [
path('feeds/add/', views.add_feed, name='podcast_add_feed'),
path('feeds/import/', views.import_opml, name='podcast_import_opml'),
path('feeds/refresh/', views.refresh_feed_now, name='podcast_refresh_feed'),
+ path('feeds/refresh-all/', views.refresh_all_feeds, name='podcast_refresh_all_feeds'),
+ 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//episodes/', views.feed_episodes, name='podcast_feed_episodes'),
@@ -14,6 +16,7 @@ urlpatterns = [
path('queue/add/', views.queue_add, name='podcast_queue_add'),
path('queue/remove/', views.queue_remove, name='podcast_queue_remove'),
path('queue/reorder/', views.queue_reorder, name='podcast_queue_reorder'),
+ path('settings/', views.podcast_settings, name='podcast_settings'),
path('progress/save/', views.save_progress, name='podcast_save_progress'),
path('progress/mark-played/', views.mark_played, name='podcast_mark_played'),
path('progress/dismiss/', views.dismiss_episodes, name='podcast_dismiss_episodes'),
diff --git a/podcasts/views.py b/podcasts/views.py
index 299f0e8..5a89f4d 100644
--- a/podcasts/views.py
+++ b/podcasts/views.py
@@ -1,17 +1,96 @@
import json
+import logging
import urllib.parse
+from concurrent.futures import ThreadPoolExecutor
+from datetime import timedelta
import xml.etree.ElementTree as ET
import feedparser
import requests
from django.conf import settings
-from django.db.models import Count, F, Max, Q
+from django.db.models import Max, Q
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
-from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue
+from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
+
+log = logging.getLogger(__name__)
+
+FEED_TIMEOUT = 20 # seconds per feed download
+FEED_WORKERS = 8 # feeds downloaded at the same time
+
+
+# ---------------------------------------------------------------------------
+# Queue positions
+# ---------------------------------------------------------------------------
+
+QUEUE_POSITIONS = {key for key, _ in PodcastSettings.POSITIONS}
+
+
+def _queue_order(user):
+ """Episode ids in the user's queue, first to last."""
+ return list(
+ PodcastQueue.objects.filter(user=user)
+ .order_by('position', 'added_at', 'id')
+ .values_list('episode_id', flat=True)
+ )
+
+
+def _write_queue_order(user, order):
+ """Renumber the queue 0..n-1 in the given order."""
+ rows = {q.episode_id: q for q in PodcastQueue.objects.filter(user=user)}
+ changed = []
+ for pos, ep_id in enumerate(order):
+ row = rows.get(ep_id)
+ if row is not None and row.position != pos:
+ row.position = pos
+ changed.append(row)
+ if changed:
+ PodcastQueue.objects.bulk_update(changed, ['position'])
+
+
+def _current_episode_id(user, queued, hint=None):
+ """The episode being listened to: the client's word for it, else the
+ queued episode most recently listened to and not finished."""
+ if hint in queued:
+ return hint
+ p = (
+ EpisodeProgress.objects
+ .filter(user=user, episode_id__in=queued, played=False, position_seconds__gt=0)
+ .order_by('-updated_at')
+ .values_list('episode_id', flat=True)
+ .first()
+ )
+ return p
+
+
+def _enqueue(user, episode_ids, where, current_hint=None):
+ """Put episodes that aren't queued yet into the queue at `where`
+ (back, front or after_current), keeping their given order.
+ Returns the ids that were added."""
+ queued = _queue_order(user)
+ present = set(queued)
+ new = []
+ for ep_id in episode_ids:
+ if ep_id not in present and ep_id not in new:
+ new.append(ep_id)
+ if not new:
+ return []
+ if where == PodcastSettings.FRONT:
+ at = 0
+ elif where == PodcastSettings.AFTER_CURRENT:
+ current = _current_episode_id(user, queued, current_hint)
+ at = queued.index(current) + 1 if current in queued else 0
+ else:
+ at = len(queued)
+ PodcastQueue.objects.bulk_create(
+ [PodcastQueue(user=user, episode_id=ep_id, position=len(queued) + i) for i, ep_id in enumerate(new)],
+ ignore_conflicts=True,
+ )
+ _write_queue_order(user, queued[:at] + new + queued[at:])
+ return new
# ---------------------------------------------------------------------------
@@ -40,9 +119,53 @@ def _parse_duration(raw):
# Shared feed refresh helper
# ---------------------------------------------------------------------------
-def _refresh_feed(feed_obj):
+def _fetch_feed(url):
+ """Download and parse a feed (with a timeout, unlike feedparser's own fetch)."""
+ r = requests.get(url, timeout=FEED_TIMEOUT, headers={'User-Agent': 'diora podcast refresh'})
+ r.raise_for_status()
+ return feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers})
+
+
+def _refresh_feeds(feeds, chunk=32):
+ """Refresh many feeds at once: the downloads run in parallel and each
+ feed URL is fetched once, even when several users subscribed to it; the
+ database writes then happen one by one. Returns (refreshed, failed, new)."""
+ feeds = list(feeds)
+ refreshed = failed = new = 0
+ for start in range(0, len(feeds), chunk):
+ part = feeds[start:start + chunk]
+ urls = sorted({f.rss_url for f in part})
+ results = {}
+
+ def fetch(url):
+ try:
+ return url, _fetch_feed(url), None
+ except Exception as e: # network, HTTP or parser trouble: report and go on
+ return url, None, e
+
+ with ThreadPoolExecutor(max_workers=min(FEED_WORKERS, len(urls) or 1)) as pool:
+ for url, parsed, err in pool.map(fetch, urls):
+ results[url] = (parsed, err)
+
+ for feed in part:
+ parsed, err = results[feed.rss_url]
+ if err is not None:
+ failed += 1
+ log.warning('refreshing %s failed: %s', feed.rss_url, err)
+ continue
+ try:
+ new += _refresh_feed(feed, parsed)
+ refreshed += 1
+ except Exception as e:
+ failed += 1
+ log.warning('updating %s failed: %s', feed.rss_url, e)
+ return refreshed, failed, new
+
+
+def _refresh_feed(feed_obj, parsed=None):
"""Parse rss_url and upsert episodes. Returns count of new episodes."""
- parsed = feedparser.parse(feed_obj.rss_url)
+ if parsed is None:
+ parsed = _fetch_feed(feed_obj.rss_url)
channel = parsed.feed
feed_obj.title = channel.get('title', feed_obj.title or feed_obj.rss_url)[:300]
@@ -123,13 +246,16 @@ def _refresh_feed(feed_obj):
new_episodes_list.append(episode_obj)
if feed_obj.auto_queue and new_episodes_list:
- count = len(new_episodes_list)
- PodcastQueue.objects.filter(user=feed_obj.user).update(position=F('position') + count)
- for idx, ep_obj in enumerate(new_episodes_list):
- PodcastQueue.objects.get_or_create(
- user=feed_obj.user, episode=ep_obj,
- defaults={'position': idx},
- )
+ 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.
+ newest_first = sorted(
+ new_episodes_list,
+ key=lambda e: e.pub_date or e.discovered_at,
+ reverse=True,
+ )
+ ordered = newest_first if where != PodcastSettings.BACK else newest_first[::-1]
+ _enqueue(feed_obj.user, [e.id for e in ordered], where)
return new_count
@@ -174,8 +300,12 @@ def feed_list(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
+ return JsonResponse({'feeds': _feeds_data(request.user)})
+
+
+def _feeds_data(user):
feeds = list(
- request.user.podcast_feeds
+ 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')
)
@@ -187,8 +317,7 @@ def feed_list(request):
f['added_at'] = f['added_at'].isoformat()
if f['latest_episode_at']:
f['latest_episode_at'] = f['latest_episode_at'].isoformat()
-
- return JsonResponse({'feeds': feeds})
+ return feeds
# ---------------------------------------------------------------------------
@@ -404,6 +533,48 @@ def refresh_feed_now(request):
return JsonResponse({'ok': True, 'new_episodes': new_episodes})
+@csrf_exempt
+@require_http_methods(['POST'])
+def refresh_all_feeds(request):
+ """Refresh all of the user's feeds on the server in one go. Feeds the
+ hourly job (or an earlier call) refreshed within `max_age` seconds are
+ skipped. Clients then fetch the result once (state/, inbox/ …)."""
+ 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)
+ try:
+ max_age = max(int(body.get('max_age', 300)), 0)
+ except (TypeError, ValueError):
+ return JsonResponse({'error': 'max_age must be a number'}, status=400)
+ cutoff = timezone.now() - timedelta(seconds=max_age)
+ feeds = request.user.podcast_feeds.filter(Q(last_refreshed_at__isnull=True) | Q(last_refreshed_at__lt=cutoff))
+ refreshed, failed, new = _refresh_feeds(feeds)
+ return JsonResponse({'ok': True, 'refreshed': refreshed, 'failed': failed, 'new_episodes': new})
+
+
+@csrf_exempt
+def podcast_state(request):
+ """Everything a client shows first, in one response: feeds, queue, the
+ first page of the inbox and the settings."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ limit = min(max(int(request.GET.get('inbox_limit', 100)), 0), 1000)
+ except ValueError:
+ return JsonResponse({'error': 'inbox_limit must be a number'}, status=400)
+ s = PodcastSettings.for_user(request.user)
+ return JsonResponse({
+ 'feeds': _feeds_data(request.user),
+ 'queue': _queue_data(request.user),
+ 'inbox': _inbox_data(request.user, 0, limit),
+ 'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position},
+ 'server_time': timezone.now().isoformat(),
+ })
+
+
# ---------------------------------------------------------------------------
# Queue
# ---------------------------------------------------------------------------
@@ -413,15 +584,21 @@ def queue_get(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
+ return JsonResponse({'queue': _queue_data(request.user)})
+
+
+def _queue_data(user):
items = list(
PodcastQueue.objects
- .filter(user=request.user)
+ .filter(user=user)
+ .order_by('position', 'added_at', 'id')
.select_related('episode__feed')
.values(
'id', 'position',
'episode__id', 'episode__title', 'episode__audio_url',
'episode__duration_seconds', 'episode__artwork_url',
- 'episode__feed__id', 'episode__feed__title',
+ 'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
+ 'episode__pub_date',
)
)
@@ -429,13 +606,14 @@ def queue_get(request):
progress_map = {
p['episode_id']: p['position_seconds']
for p in EpisodeProgress.objects.filter(
- user=request.user, episode_id__in=ep_ids,
+ user=user, episode_id__in=ep_ids,
).values('episode_id', 'position_seconds')
}
for item in items:
item['position_seconds'] = progress_map.get(item['episode__id'], 0)
-
- return JsonResponse({'queue': items})
+ if item['episode__pub_date']:
+ item['episode__pub_date'] = item['episode__pub_date'].isoformat()
+ return items
@csrf_exempt
@@ -449,19 +627,29 @@ def queue_add(request):
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
- episode_id = body.get('episode_id')
- try:
- episode = PodcastEpisode.objects.get(pk=episode_id, feed__user=request.user)
- except PodcastEpisode.DoesNotExist:
- return JsonResponse({'error': 'not found'}, status=404)
+ # One episode (episode_id) or several in order (episode_ids). Where they
+ # go: `where` if given, else the user's setting. `current_episode_id` is
+ # what the client is playing, for after_current.
+ ids = body.get('episode_ids')
+ if ids is None:
+ ids = [body.get('episode_id')]
+ if not isinstance(ids, list):
+ return JsonResponse({'error': 'episode_ids must be a list'}, status=400)
+ where = body.get('where') or PodcastSettings.for_user(request.user).enqueue_position
+ if where not in QUEUE_POSITIONS:
+ return JsonResponse({'error': 'unknown position'}, status=400)
- max_pos = PodcastQueue.objects.filter(user=request.user).count()
- _, created = PodcastQueue.objects.get_or_create(
- user=request.user,
- episode=episode,
- defaults={'position': max_pos},
+ found = set(
+ PodcastEpisode.objects.filter(pk__in=[i for i in ids if isinstance(i, int)], feed__user=request.user)
+ .values_list('id', flat=True)
)
- return JsonResponse({'ok': True, 'created': created})
+ if not found:
+ return JsonResponse({'error': 'not found'}, status=404)
+ added = _enqueue(
+ request.user, [i for i in ids if i in found], where,
+ current_hint=body.get('current_episode_id'),
+ )
+ return JsonResponse({'ok': True, 'created': bool(added), 'added': added})
@csrf_exempt
@@ -498,6 +686,35 @@ def queue_reorder(request):
return JsonResponse({'ok': True})
+@csrf_exempt
+def podcast_settings(request):
+ """GET the user's podcast settings; POST changes some of them."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ s = PodcastSettings.for_user(request.user)
+ if request.method == 'POST':
+ try:
+ body = json.loads(request.body)
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ fields = []
+ for name in ('enqueue_position', 'auto_queue_position'):
+ if name in body:
+ if body[name] not in QUEUE_POSITIONS:
+ return JsonResponse({'error': f'unknown {name}'}, status=400)
+ setattr(s, name, body[name])
+ fields.append(name)
+ if fields:
+ s.save(update_fields=fields)
+ 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,
+ 'positions': [{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS],
+ })
+
+
# ---------------------------------------------------------------------------
# Progress
# ---------------------------------------------------------------------------
@@ -612,19 +829,22 @@ def inbox(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
+ limit = min(int(request.GET.get('limit', getattr(settings, 'PODCAST_INBOX_PAGE_SIZE', 200))), 1000)
+ offset = max(int(request.GET.get('offset', 0)), 0)
+ return JsonResponse({'episodes': _inbox_data(request.user, offset, limit), 'offset': offset, 'limit': limit})
+
+
+def _inbox_data(user, offset, limit):
hidden_ids = set(
EpisodeProgress.objects.filter(
- user=request.user,
+ user=user,
).filter(
Q(played=True) | Q(dismissed=True)
).values_list('episode_id', flat=True)
)
- limit = min(int(request.GET.get('limit', getattr(settings, 'PODCAST_INBOX_PAGE_SIZE', 200))), 1000)
- offset = max(int(request.GET.get('offset', 0)), 0)
-
episodes = list(
- PodcastEpisode.objects.filter(feed__user=request.user)
+ PodcastEpisode.objects.filter(feed__user=user)
.exclude(id__in=hidden_ids)
.select_related('feed')
.order_by('-pub_date')
@@ -638,13 +858,13 @@ def inbox(request):
progress_map = {
ep['episode_id']: ep
for ep in EpisodeProgress.objects.filter(
- user=request.user,
+ user=user,
episode_id__in=[e['id'] for e in episodes],
).values('episode_id', 'position_seconds')
}
queued_ids = set(
PodcastQueue.objects.filter(
- user=request.user,
+ user=user,
episode_id__in=[e['id'] for e in episodes],
).values_list('episode_id', flat=True)
)
@@ -654,5 +874,4 @@ def inbox(request):
prog = progress_map.get(ep['id'], {})
ep['position_seconds'] = prog.get('position_seconds', 0)
ep['in_queue'] = ep['id'] in queued_ids
-
- return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit})
+ return episodes
diff --git a/static/js/app.js b/static/js/app.js
index 3982b5d..6d05415 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -2181,44 +2181,33 @@ async function refreshOpenFeed(btn) {
}
async function refreshAllFeedsBtn(btn) {
- if (btn) { btn.disabled = true; btn.textContent = '↻ 0/' + podcastFeeds.length; }
- let done = 0;
- for (const feed of podcastFeeds) {
- try {
- await fetch('/podcasts/feeds/refresh/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({feed_id: feed.id}),
- });
- } catch (e) {}
- done++;
- if (btn) btn.textContent = `↻ ${done}/${podcastFeeds.length}`;
- }
- await loadFeedList();
- if (podcastCurrentView === 'feeds') renderFeedList();
- if (podcastCurrentView === 'inbox') loadAndRenderInbox();
- if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId);
+ // One request: the server fetches every feed itself (in parallel, each
+ // feed address once) and the lists are then loaded once.
+ if (btn) { btn.disabled = true; btn.textContent = '↻ …'; }
+ try {
+ await fetch('/podcasts/feeds/refresh-all/', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
+ body: JSON.stringify({max_age: 60}),
+ });
+ } catch (e) {}
+ await reloadPodcastViews();
if (btn) { btn.disabled = false; btn.textContent = '↻ All'; }
}
-async function refreshAllFeeds() {
- if (!IS_AUTHENTICATED || !podcastFeeds.length) return;
- for (const feed of podcastFeeds) {
- try {
- await fetch('/podcasts/feeds/refresh/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({feed_id: feed.id}),
- });
- } catch (e) {}
- }
- // Reload feed metadata and refresh the currently open view
+async function reloadPodcastViews() {
await loadFeedList();
if (podcastCurrentView === 'feeds') renderFeedList();
if (podcastCurrentView === 'inbox') loadAndRenderInbox();
if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId);
}
+async function refreshAllFeeds() {
+ // The server refreshes all feeds every hour; just pick up what it found.
+ if (!IS_AUTHENTICATED || !podcastFeeds.length) return;
+ await reloadPodcastViews();
+}
+
async function removeFeed(feedId) {
if (!await customConfirm('Remove this podcast?', { danger: true })) return;
try {
diff --git a/static/js/sw.js b/static/js/sw.js
index 0068104..b43fd39 100644
--- a/static/js/sw.js
+++ b/static/js/sw.js
@@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
-const CACHE = 'diora-v50';
+const CACHE = 'diora-v51';
const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [
'/static/css/app.css',