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 django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from podcasts.models import PodcastFeed
|
from podcasts.models import PodcastFeed
|
||||||
from podcasts.views import _refresh_feed
|
from podcasts.views import _refresh_feeds
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|
@ -26,9 +26,6 @@ class Command(BaseCommand):
|
||||||
self.stdout.write('No feeds to refresh.')
|
self.stdout.write('No feeds to refresh.')
|
||||||
return
|
return
|
||||||
|
|
||||||
for feed in qs:
|
# Parallel downloads, one per distinct feed URL (see _refresh_feeds).
|
||||||
try:
|
refreshed, failed, new = _refresh_feeds(qs)
|
||||||
new_ep = _refresh_feed(feed)
|
self.stdout.write(f'{refreshed} feeds refreshed, {failed} failed, +{new} episodes')
|
||||||
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}')
|
|
||||||
|
|
|
||||||
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):
|
def __str__(self):
|
||||||
return f'{self.user} queue pos {self.position}: {self.episode}'
|
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/add/', views.add_feed, name='podcast_add_feed'),
|
||||||
path('feeds/import/', views.import_opml, name='podcast_import_opml'),
|
path('feeds/import/', views.import_opml, name='podcast_import_opml'),
|
||||||
path('feeds/refresh/', views.refresh_feed_now, name='podcast_refresh_feed'),
|
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>/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>/set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'),
|
||||||
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
|
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/add/', views.queue_add, name='podcast_queue_add'),
|
||||||
path('queue/remove/', views.queue_remove, name='podcast_queue_remove'),
|
path('queue/remove/', views.queue_remove, name='podcast_queue_remove'),
|
||||||
path('queue/reorder/', views.queue_reorder, name='podcast_queue_reorder'),
|
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/save/', views.save_progress, name='podcast_save_progress'),
|
||||||
path('progress/mark-played/', views.mark_played, name='podcast_mark_played'),
|
path('progress/mark-played/', views.mark_played, name='podcast_mark_played'),
|
||||||
path('progress/dismiss/', views.dismiss_episodes, name='podcast_dismiss_episodes'),
|
path('progress/dismiss/', views.dismiss_episodes, name='podcast_dismiss_episodes'),
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,96 @@
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import timedelta
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
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.http import JsonResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods
|
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
|
# 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."""
|
"""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
|
channel = parsed.feed
|
||||||
|
|
||||||
feed_obj.title = channel.get('title', feed_obj.title or feed_obj.rss_url)[:300]
|
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)
|
new_episodes_list.append(episode_obj)
|
||||||
|
|
||||||
if feed_obj.auto_queue and new_episodes_list:
|
if feed_obj.auto_queue and new_episodes_list:
|
||||||
count = len(new_episodes_list)
|
where = PodcastSettings.for_user(feed_obj.user).auto_queue_position
|
||||||
PodcastQueue.objects.filter(user=feed_obj.user).update(position=F('position') + count)
|
# Newest first in front of what's there; oldest first when appended,
|
||||||
for idx, ep_obj in enumerate(new_episodes_list):
|
# so the end of the queue plays in the order the episodes came out.
|
||||||
PodcastQueue.objects.get_or_create(
|
newest_first = sorted(
|
||||||
user=feed_obj.user, episode=ep_obj,
|
new_episodes_list,
|
||||||
defaults={'position': idx},
|
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
|
return new_count
|
||||||
|
|
||||||
|
|
@ -174,8 +300,12 @@ def feed_list(request):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
return JsonResponse({'feeds': _feeds_data(request.user)})
|
||||||
|
|
||||||
|
|
||||||
|
def _feeds_data(user):
|
||||||
feeds = list(
|
feeds = list(
|
||||||
request.user.podcast_feeds
|
user.podcast_feeds
|
||||||
.annotate(latest_episode_at=Max('episodes__pub_date'))
|
.annotate(latest_episode_at=Max('episodes__pub_date'))
|
||||||
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue', 'latest_episode_at')
|
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue', 'latest_episode_at')
|
||||||
)
|
)
|
||||||
|
|
@ -187,8 +317,7 @@ def feed_list(request):
|
||||||
f['added_at'] = f['added_at'].isoformat()
|
f['added_at'] = f['added_at'].isoformat()
|
||||||
if f['latest_episode_at']:
|
if f['latest_episode_at']:
|
||||||
f['latest_episode_at'] = f['latest_episode_at'].isoformat()
|
f['latest_episode_at'] = f['latest_episode_at'].isoformat()
|
||||||
|
return feeds
|
||||||
return JsonResponse({'feeds': feeds})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -404,6 +533,48 @@ def refresh_feed_now(request):
|
||||||
return JsonResponse({'ok': True, 'new_episodes': new_episodes})
|
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
|
# Queue
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -413,15 +584,21 @@ def queue_get(request):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
return JsonResponse({'queue': _queue_data(request.user)})
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_data(user):
|
||||||
items = list(
|
items = list(
|
||||||
PodcastQueue.objects
|
PodcastQueue.objects
|
||||||
.filter(user=request.user)
|
.filter(user=user)
|
||||||
|
.order_by('position', 'added_at', 'id')
|
||||||
.select_related('episode__feed')
|
.select_related('episode__feed')
|
||||||
.values(
|
.values(
|
||||||
'id', 'position',
|
'id', 'position',
|
||||||
'episode__id', 'episode__title', 'episode__audio_url',
|
'episode__id', 'episode__title', 'episode__audio_url',
|
||||||
'episode__duration_seconds', 'episode__artwork_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 = {
|
progress_map = {
|
||||||
p['episode_id']: p['position_seconds']
|
p['episode_id']: p['position_seconds']
|
||||||
for p in EpisodeProgress.objects.filter(
|
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')
|
).values('episode_id', 'position_seconds')
|
||||||
}
|
}
|
||||||
for item in items:
|
for item in items:
|
||||||
item['position_seconds'] = progress_map.get(item['episode__id'], 0)
|
item['position_seconds'] = progress_map.get(item['episode__id'], 0)
|
||||||
|
if item['episode__pub_date']:
|
||||||
return JsonResponse({'queue': items})
|
item['episode__pub_date'] = item['episode__pub_date'].isoformat()
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
|
|
@ -449,19 +627,29 @@ def queue_add(request):
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
|
||||||
episode_id = body.get('episode_id')
|
# One episode (episode_id) or several in order (episode_ids). Where they
|
||||||
try:
|
# go: `where` if given, else the user's setting. `current_episode_id` is
|
||||||
episode = PodcastEpisode.objects.get(pk=episode_id, feed__user=request.user)
|
# what the client is playing, for after_current.
|
||||||
except PodcastEpisode.DoesNotExist:
|
ids = body.get('episode_ids')
|
||||||
return JsonResponse({'error': 'not found'}, status=404)
|
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()
|
found = set(
|
||||||
_, created = PodcastQueue.objects.get_or_create(
|
PodcastEpisode.objects.filter(pk__in=[i for i in ids if isinstance(i, int)], feed__user=request.user)
|
||||||
user=request.user,
|
.values_list('id', flat=True)
|
||||||
episode=episode,
|
|
||||||
defaults={'position': max_pos},
|
|
||||||
)
|
)
|
||||||
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
|
@csrf_exempt
|
||||||
|
|
@ -498,6 +686,35 @@ def queue_reorder(request):
|
||||||
return JsonResponse({'ok': True})
|
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
|
# Progress
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -612,19 +829,22 @@ def inbox(request):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
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(
|
hidden_ids = set(
|
||||||
EpisodeProgress.objects.filter(
|
EpisodeProgress.objects.filter(
|
||||||
user=request.user,
|
user=user,
|
||||||
).filter(
|
).filter(
|
||||||
Q(played=True) | Q(dismissed=True)
|
Q(played=True) | Q(dismissed=True)
|
||||||
).values_list('episode_id', flat=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(
|
episodes = list(
|
||||||
PodcastEpisode.objects.filter(feed__user=request.user)
|
PodcastEpisode.objects.filter(feed__user=user)
|
||||||
.exclude(id__in=hidden_ids)
|
.exclude(id__in=hidden_ids)
|
||||||
.select_related('feed')
|
.select_related('feed')
|
||||||
.order_by('-pub_date')
|
.order_by('-pub_date')
|
||||||
|
|
@ -638,13 +858,13 @@ def inbox(request):
|
||||||
progress_map = {
|
progress_map = {
|
||||||
ep['episode_id']: ep
|
ep['episode_id']: ep
|
||||||
for ep in EpisodeProgress.objects.filter(
|
for ep in EpisodeProgress.objects.filter(
|
||||||
user=request.user,
|
user=user,
|
||||||
episode_id__in=[e['id'] for e in episodes],
|
episode_id__in=[e['id'] for e in episodes],
|
||||||
).values('episode_id', 'position_seconds')
|
).values('episode_id', 'position_seconds')
|
||||||
}
|
}
|
||||||
queued_ids = set(
|
queued_ids = set(
|
||||||
PodcastQueue.objects.filter(
|
PodcastQueue.objects.filter(
|
||||||
user=request.user,
|
user=user,
|
||||||
episode_id__in=[e['id'] for e in episodes],
|
episode_id__in=[e['id'] for e in episodes],
|
||||||
).values_list('episode_id', flat=True)
|
).values_list('episode_id', flat=True)
|
||||||
)
|
)
|
||||||
|
|
@ -654,5 +874,4 @@ def inbox(request):
|
||||||
prog = progress_map.get(ep['id'], {})
|
prog = progress_map.get(ep['id'], {})
|
||||||
ep['position_seconds'] = prog.get('position_seconds', 0)
|
ep['position_seconds'] = prog.get('position_seconds', 0)
|
||||||
ep['in_queue'] = ep['id'] in queued_ids
|
ep['in_queue'] = ep['id'] in queued_ids
|
||||||
|
return episodes
|
||||||
return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit})
|
|
||||||
|
|
|
||||||
|
|
@ -2181,44 +2181,33 @@ async function refreshOpenFeed(btn) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAllFeedsBtn(btn) {
|
async function refreshAllFeedsBtn(btn) {
|
||||||
if (btn) { btn.disabled = true; btn.textContent = '↻ 0/' + podcastFeeds.length; }
|
// One request: the server fetches every feed itself (in parallel, each
|
||||||
let done = 0;
|
// feed address once) and the lists are then loaded once.
|
||||||
for (const feed of podcastFeeds) {
|
if (btn) { btn.disabled = true; btn.textContent = '↻ …'; }
|
||||||
try {
|
try {
|
||||||
await fetch('/podcasts/feeds/refresh/', {
|
await fetch('/podcasts/feeds/refresh-all/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||||
body: JSON.stringify({feed_id: feed.id}),
|
body: JSON.stringify({max_age: 60}),
|
||||||
});
|
});
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
done++;
|
await reloadPodcastViews();
|
||||||
if (btn) btn.textContent = `↻ ${done}/${podcastFeeds.length}`;
|
|
||||||
}
|
|
||||||
await loadFeedList();
|
|
||||||
if (podcastCurrentView === 'feeds') renderFeedList();
|
|
||||||
if (podcastCurrentView === 'inbox') loadAndRenderInbox();
|
|
||||||
if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId);
|
|
||||||
if (btn) { btn.disabled = false; btn.textContent = '↻ All'; }
|
if (btn) { btn.disabled = false; btn.textContent = '↻ All'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAllFeeds() {
|
async function reloadPodcastViews() {
|
||||||
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
|
|
||||||
await loadFeedList();
|
await loadFeedList();
|
||||||
if (podcastCurrentView === 'feeds') renderFeedList();
|
if (podcastCurrentView === 'feeds') renderFeedList();
|
||||||
if (podcastCurrentView === 'inbox') loadAndRenderInbox();
|
if (podcastCurrentView === 'inbox') loadAndRenderInbox();
|
||||||
if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId);
|
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) {
|
async function removeFeed(feedId) {
|
||||||
if (!await customConfirm('Remove this podcast?', { danger: true })) return;
|
if (!await customConfirm('Remove this podcast?', { danger: true })) return;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* 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 PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue