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