Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
All checks were successful
Build and push Docker image / build (push) Successful in 19s
Test / test (push) Successful in 2m0s

Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
  Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
  für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
  nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
  automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
  (where) und die laufende Folge (current_episode_id); ohne Angabe gilt
  die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.

Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
  auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
  über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
  übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
  Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
  Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
  selbst anzustoßen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-02 07:26:16 +02:00
parent 3717640a4e
commit 176771a407
8 changed files with 533 additions and 76 deletions

View file

@ -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')

View file

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

View file

@ -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}'

196
podcasts/tests.py Normal file
View file

@ -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'<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
)
return f'<?xml version="1.0"?><rss version="2.0"><channel><title>Feed</title>{entries}</channel></rss>'
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'})

View file

@ -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/<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>/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'),

View file

@ -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

View file

@ -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 {

View file

@ -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',