Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
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:
parent
3717640a4e
commit
176771a407
8 changed files with 533 additions and 76 deletions
|
|
@ -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')
|
||||
|
|
|
|||
27
podcasts/migrations/0004_podcastsettings.py
Normal file
27
podcasts/migrations/0004_podcastsettings.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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
196
podcasts/tests.py
Normal 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'})
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
// 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/', {
|
||||
await fetch('/podcasts/feeds/refresh-all/', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||
body: JSON.stringify({feed_id: feed.id}),
|
||||
body: JSON.stringify({max_age: 60}),
|
||||
});
|
||||
} 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);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue