Podcasts: Favoriten, Folgenliste mit Filtern und Verlauf, Hörstatistik, OPML-Export
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Failing after 27s

- EpisodeProgress.favorite (Migration 0006) und POST episodes/<id>/favorite/
  (setzen oder umschalten; ändert updated_at nicht, damit Verlauf und
  Statistik nicht verfälscht werden)
- GET episodes/: alle Folgen aller Abos, Filter all/unplayed/in_progress/
  played/favorites/history, feed_id, q, offset/limit/total; zwei Abfragen
  pro Seite, ohne Shownotes
- Folgendetails liefern zusätzlich favorite und last_played_at
- GET stats/: Hörzeit gesamt und pro Podcast, optional nur die letzten N Tage
- GET feeds/export.opml: Abos als OPML 2.0 (lässt sich wieder importieren)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-02 08:12:27 +02:00
parent fd0a103fb2
commit f30bf2fcdc
5 changed files with 576 additions and 2 deletions

View file

@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-10-02 12:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0005_episode_chapters_transcripts'),
]
operations = [
migrations.AddField(
model_name='episodeprogress',
name='favorite',
field=models.BooleanField(default=False),
),
]

View file

@ -58,6 +58,7 @@ class EpisodeProgress(models.Model):
position_seconds = models.IntegerField(default=0) position_seconds = models.IntegerField(default=0)
played = models.BooleanField(default=False) played = models.BooleanField(default=False)
dismissed = models.BooleanField(default=False) dismissed = models.BooleanField(default=False)
favorite = models.BooleanField(default=False)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:

View file

@ -535,3 +535,331 @@ class EpisodeDetailTests(TestCase):
def test_login_required(self): def test_login_required(self):
self.client.logout() self.client.logout()
self.assertEqual(self.get().status_code, 401) self.assertEqual(self.get().status_code, 401)
def test_favorite_and_last_played_at(self):
r = self.get().json()
self.assertEqual((r['favorite'], r['last_played_at']), (False, None))
p = EpisodeProgress.objects.create(user=self.user, episode=self.ep, position_seconds=5, favorite=True)
EpisodeProgress.objects.filter(pk=p.pk).update(updated_at=datetime(2026, 2, 3, 8, tzinfo=timezone.utc))
r = self.get().json()
self.assertEqual((r['favorite'], r['last_played_at']), (True, '2026-02-03T08:00:00+00:00'))
# ---------------------------------------------------------------------------
# Favorites, episode list, statistics, OPML export
# ---------------------------------------------------------------------------
def _at(month, day):
return datetime(2026, month, day, 9, tzinfo=timezone.utc)
def _progress(user, episode, updated_at, **fields):
p = EpisodeProgress.objects.create(user=user, episode=episode, **fields)
# auto_now would overwrite updated_at on save(); update() doesn't.
EpisodeProgress.objects.filter(pk=p.pk).update(updated_at=updated_at)
return p
class FavoriteTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed', title='Feed')
self.ep = PodcastEpisode.objects.create(feed=feed, guid='a', title='A', audio_url='https://example.org/a.mp3')
def post(self, body=None, ep=None):
return self.client.post(f'/podcasts/episodes/{(ep or self.ep).id}/favorite/',
'' if body is None else json.dumps(body), content_type='application/json')
def test_toggle_creates_the_progress_row(self):
self.assertFalse(EpisodeProgress.objects.exists())
self.assertEqual(self.post().json(), {'ok': True, 'favorite': True})
p = EpisodeProgress.objects.get(user=self.user, episode=self.ep)
self.assertTrue(p.favorite)
self.assertEqual((p.played, p.dismissed, p.position_seconds), (False, False, 0))
self.assertEqual(self.post({}).json(), {'ok': True, 'favorite': False})
self.assertFalse(EpisodeProgress.objects.get(pk=p.pk).favorite)
def test_set_explicitly(self):
self.assertEqual(self.post({'favorite': True}).json()['favorite'], True)
self.assertEqual(self.post({'favorite': True}).json()['favorite'], True)
self.assertTrue(EpisodeProgress.objects.get().favorite)
self.assertEqual(self.post({'favorite': False}).json()['favorite'], False)
self.assertEqual(self.post({'favorite': False}).json()['favorite'], False)
self.assertFalse(EpisodeProgress.objects.get().favorite)
def test_starring_keeps_the_listening_time(self):
p = _progress(self.user, self.ep, _at(2, 1), position_seconds=30)
self.post({'favorite': True})
p.refresh_from_db()
self.assertTrue(p.favorite)
self.assertEqual((p.position_seconds, p.updated_at), (30, _at(2, 1)))
def test_owner_only(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')
self.assertEqual(self.post({'favorite': True}, ep).status_code, 404)
self.assertFalse(EpisodeProgress.objects.exists())
self.assertEqual(self.client.post('/podcasts/episodes/999999/favorite/', '{}',
content_type='application/json').status_code, 404)
def test_login_and_post_required(self):
self.assertEqual(self.client.get(f'/podcasts/episodes/{self.ep.id}/favorite/').status_code, 405)
self.client.logout()
self.assertEqual(self.post({'favorite': True}).status_code, 401)
class EpisodeListTests(TestCase):
"""GET /podcasts/episodes/: all feeds, filters, history, search, pages."""
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha',
artwork_url='https://example.org/a.jpg')
self.fb = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta')
def ep(feed, guid, title, pub_date, **kw):
return PodcastEpisode.objects.create(feed=feed, guid=guid, title=title, pub_date=pub_date,
audio_url=f'https://example.org/{guid}.mp3',
description='x' * 1000, **kw)
self.a1 = ep(self.fa, 'a1', 'A1', _at(1, 1), duration_seconds=600)
self.a2 = ep(self.fa, 'a2', 'A2', _at(1, 2))
self.a3 = ep(self.fa, 'a3', 'A3', _at(1, 3))
self.a4 = ep(self.fa, 'a4', 'A4 ohne Datum', None)
self.b1 = ep(self.fb, 'b1', 'Bonus Talk', _at(1, 4))
self.b2 = ep(self.fb, 'b2', 'B2', _at(1, 5))
_progress(self.user, self.a1, _at(2, 1), played=True)
_progress(self.user, self.a2, _at(2, 3), position_seconds=100)
_progress(self.user, self.b1, _at(2, 2), position_seconds=50)
_progress(self.user, self.a3, _at(2, 5), favorite=True)
_progress(self.user, self.b2, _at(2, 4), dismissed=True) # e.g. back catalogue of a new subscription
PodcastQueue.objects.create(user=self.user, episode=self.a2, position=0)
# Someone else's listening must not show up.
bob = User.objects.create_user(username='bob', password='pw12345678')
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='Alpha')
xb = PodcastEpisode.objects.create(feed=fbob, guid='a1', title='A1', audio_url='https://example.org/a1.mp3',
pub_date=_at(1, 9))
_progress(bob, xb, _at(3, 1), played=True, favorite=True)
_progress(bob, self.a3, _at(3, 1), played=True, position_seconds=10) # can't happen, but must not leak
def get(self, **params):
r = self.client.get('/podcasts/episodes/', params)
self.assertEqual(r.status_code, 200)
return r.json()
def ids(self, **params):
return [e['id'] for e in self.get(**params)['episodes']]
def test_all_newest_first_without_date_last(self):
data = self.get()
self.assertEqual([e['id'] for e in data['episodes']],
[e.id for e in (self.b2, self.b1, self.a3, self.a2, self.a1, self.a4)])
self.assertEqual((data['offset'], data['limit'], data['total']), (0, 100, 6))
self.assertEqual(self.ids(filter='all'), [e['id'] for e in data['episodes']])
def test_episode_fields(self):
eps = {e['id']: e for e in self.get()['episodes']}
self.assertEqual(eps[self.a2.id], {
'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'duration_seconds': 0,
'pub_date': '2026-01-02T09:00:00+00:00', 'artwork_url': '',
'feed__id': self.fa.id, 'feed__title': 'Alpha', 'feed__artwork_url': 'https://example.org/a.jpg',
'position_seconds': 100, 'played': False, 'favorite': False, 'in_queue': True,
'last_played_at': '2026-02-03T09:00:00+00:00',
})
a4 = eps[self.a4.id]
self.assertEqual((a4['pub_date'], a4['last_played_at'], a4['position_seconds'], a4['played'],
a4['favorite'], a4['in_queue']), (None, None, 0, False, False, False))
self.assertEqual(eps[self.a3.id]['favorite'], True)
self.assertEqual(eps[self.a3.id]['played'], False) # bob's row doesn't count
self.assertEqual(eps[self.a1.id]['played'], True)
def test_unplayed(self):
# Dismissed episodes (b2) are still listed.
self.assertEqual(self.ids(filter='unplayed'), [e.id for e in (self.b2, self.b1, self.a3, self.a2, self.a4)])
def test_in_progress_by_publication_date(self):
self.assertEqual(self.ids(filter='in_progress'), [self.b1.id, self.a2.id])
def test_played(self):
self.assertEqual(self.ids(filter='played'), [self.a1.id])
def test_favorites(self):
self.assertEqual(self.ids(filter='favorites'), [self.a3.id])
def test_history_last_listened_first(self):
data = self.get(filter='history')
self.assertEqual([e['id'] for e in data['episodes']], [self.a2.id, self.b1.id, self.a1.id])
self.assertEqual([e['last_played_at'] for e in data['episodes']],
['2026-02-03T09:00:00+00:00', '2026-02-02T09:00:00+00:00', '2026-02-01T09:00:00+00:00'])
self.assertEqual(data['total'], 3)
# Listening again moves an episode to the top.
self.client.post('/podcasts/progress/save/', json.dumps({'episode_id': self.a1.id, 'position_seconds': 20}),
content_type='application/json')
self.assertEqual(self.ids(filter='history'), [self.a1.id, self.a2.id, self.b1.id])
def test_feed_id_and_q(self):
self.assertEqual(self.ids(feed_id=self.fb.id), [self.b2.id, self.b1.id])
self.assertEqual(self.ids(q='BONUS'), [self.b1.id])
self.assertEqual(self.ids(q='ohne dat'), [self.a4.id])
self.assertEqual(self.ids(filter='history', feed_id=self.fa.id), [self.a2.id, self.a1.id])
self.assertEqual(self.ids(filter='in_progress', q='a2'), [self.a2.id])
data = self.get(feed_id=self.fb.id, q='nothing')
self.assertEqual((data['episodes'], data['total']), ([], 0))
bobs_feed = PodcastFeed.objects.get(user__username='bob')
self.assertEqual(self.ids(feed_id=bobs_feed.id), [])
def test_pages(self):
data = self.get(offset=1, limit=2)
self.assertEqual([e['id'] for e in data['episodes']], [self.b1.id, self.a3.id])
self.assertEqual((data['offset'], data['limit'], data['total']), (1, 2, 6))
data = self.get(offset=5, limit=2)
self.assertEqual(([e['id'] for e in data['episodes']], data['total']), ([self.a4.id], 6))
data = self.get(offset=10)
self.assertEqual((data['episodes'], data['total']), ([], 6))
self.assertEqual(self.get(limit=10000)['limit'], 500)
self.assertEqual(self.get(offset=-3)['offset'], 0)
data = self.get(filter='history', limit=1, offset=1)
self.assertEqual(([e['id'] for e in data['episodes']], data['total']), ([self.b1.id], 3))
def test_query_count_does_not_grow_with_the_page(self):
from .views import _episode_list_data
for i in range(20):
e = PodcastEpisode.objects.create(feed=self.fb, guid=f'n{i}', title=f'N{i}',
audio_url=f'https://example.org/n{i}.mp3', pub_date=_at(1, 10))
_progress(self.user, e, _at(2, 10), position_seconds=i + 1)
PodcastQueue.objects.create(user=self.user, episode=e, position=i + 1)
with self.assertNumQueries(2):
episodes, total = _episode_list_data(self.user, 'history')
self.assertEqual((len(episodes), total), (23, 23))
def test_bad_parameters(self):
self.assertEqual(self.client.get('/podcasts/episodes/', {'filter': 'new'}).status_code, 400)
self.assertEqual(self.client.get('/podcasts/episodes/', {'limit': 'ten'}).status_code, 400)
self.assertEqual(self.client.get('/podcasts/episodes/', {'feed_id': 'x'}).status_code, 400)
def test_login_required(self):
self.client.logout()
self.assertEqual(self.client.get('/podcasts/episodes/').status_code, 401)
class StatsTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha',
artwork_url='https://example.org/a.jpg')
self.fb = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta')
self.fc = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/c', title='Gamma')
def ep(feed, guid, duration):
return PodcastEpisode.objects.create(feed=feed, guid=guid, title=guid, duration_seconds=duration,
audio_url=f'https://example.org/{guid}.mp3')
now = datetime.now(timezone.utc)
_progress(self.user, ep(self.fa, 'a1', 3600), now, played=True, position_seconds=50) # 3600
_progress(self.user, ep(self.fa, 'a2', 1000), now, position_seconds=300) # 300, started
_progress(self.user, ep(self.fa, 'a3', 0), now, played=True, position_seconds=120) # 120: no length
_progress(self.user, ep(self.fb, 'b1', 900), now - timedelta(days=30), position_seconds=200) # 200, started
_progress(self.user, ep(self.fc, 'c1', 500), now, dismissed=True, favorite=True) # 0
bob = User.objects.create_user(username='bob', password='pw12345678')
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='Alpha')
_progress(bob, ep(fbob, 'x', 999), now, played=True)
def test_all_time(self):
r = self.client.get('/podcasts/stats/')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), {
'total_seconds': 4220,
'episodes_played': 2,
'episodes_started': 2,
'since': None,
'feeds': [
{'feed_id': self.fa.id, 'title': 'Alpha', 'artwork_url': 'https://example.org/a.jpg',
'seconds': 4020, 'episodes_played': 2},
{'feed_id': self.fb.id, 'title': 'Beta', 'artwork_url': '', 'seconds': 200, 'episodes_played': 0},
],
})
def test_last_days(self):
before = datetime.now(timezone.utc)
data = self.client.get('/podcasts/stats/', {'days': 7}).json()
self.assertEqual((data['total_seconds'], data['episodes_played'], data['episodes_started']), (4020, 2, 1))
self.assertEqual([f['feed_id'] for f in data['feeds']], [self.fa.id])
since = datetime.fromisoformat(data['since'])
self.assertAlmostEqual((before - since).total_seconds(), 7 * 86400, delta=60)
data = self.client.get('/podcasts/stats/', {'days': 60}).json()
self.assertEqual(data['total_seconds'], 4220)
self.assertEqual([f['feed_id'] for f in data['feeds']], [self.fa.id, self.fb.id])
def test_empty(self):
EpisodeProgress.objects.filter(user=self.user).delete()
self.assertEqual(self.client.get('/podcasts/stats/').json(), {
'total_seconds': 0, 'episodes_played': 0, 'episodes_started': 0, 'since': None, 'feeds': [],
})
def test_bad_days_and_login(self):
self.assertEqual(self.client.get('/podcasts/stats/', {'days': 'week'}).status_code, 400)
self.assertEqual(self.client.get('/podcasts/stats/', {'days': -1}).status_code, 400)
self.client.logout()
self.assertEqual(self.client.get('/podcasts/stats/').status_code, 401)
class OpmlExportTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed?a=1&b=2',
title='Tom & Jerry "Live" <Folge>', link='https://example.org/?x=1&y=2')
PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/plain', title="Bob's Podcast")
bob = User.objects.create_user(username='bob', password='pw12345678')
PodcastFeed.objects.create(user=bob, rss_url='https://example.org/secret', title='Secret')
def test_export(self):
import xml.etree.ElementTree as ET
r = self.client.get('/podcasts/feeds/export.opml')
self.assertEqual(r.status_code, 200)
self.assertEqual(r['Content-Type'], 'text/x-opml; charset=utf-8')
self.assertEqual(r['Content-Disposition'], 'attachment; filename="diora-podcasts.opml"')
raw = r.content.decode('utf-8')
self.assertIn('Tom &amp; Jerry &quot;Live&quot; &lt;Folge&gt;', raw)
self.assertIn('xmlUrl="https://example.org/feed?a=1&amp;b=2"', raw)
self.assertNotIn('Secret', raw)
root = ET.fromstring(r.content)
self.assertEqual(root.tag, 'opml')
self.assertEqual(root.get('version'), '2.0')
self.assertEqual(root.findtext('head/title'), 'diora Podcasts')
outlines = [o.attrib for o in root.iter('outline')]
self.assertEqual(outlines, [
{'type': 'rss', 'text': "Bob's Podcast", 'title': "Bob's Podcast",
'xmlUrl': 'https://example.org/plain', 'htmlUrl': ''},
{'type': 'rss', 'text': 'Tom & Jerry "Live" <Folge>', 'title': 'Tom & Jerry "Live" <Folge>',
'xmlUrl': 'https://example.org/feed?a=1&b=2', 'htmlUrl': 'https://example.org/?x=1&y=2'},
])
def test_round_trip_through_import(self):
from django.core.files.uploadedfile import SimpleUploadedFile
opml = self.client.get('/podcasts/feeds/export.opml').content
carol = User.objects.create_user(username='carol', password='pw12345678')
self.client.force_login(carol)
r = self.client.post('/podcasts/feeds/import/',
{'file': SimpleUploadedFile('diora-podcasts.opml', opml, 'text/x-opml')})
self.assertEqual(r.json(), {'ok': True, 'added': 2, 'skipped': 0})
mine = lambda u: set(PodcastFeed.objects.filter(user=u).values_list('title', 'rss_url'))
self.assertEqual(mine(carol), mine(self.user))
# Importing the same file again adds nothing.
r = self.client.post('/podcasts/feeds/import/',
{'file': SimpleUploadedFile('diora-podcasts.opml', opml, 'text/x-opml')})
self.assertEqual(r.json(), {'ok': True, 'added': 0, 'skipped': 2})
def test_no_feeds(self):
import xml.etree.ElementTree as ET
PodcastFeed.objects.filter(user=self.user).delete()
root = ET.fromstring(self.client.get('/podcasts/feeds/export.opml').content)
self.assertEqual(list(root.iter('outline')), [])
def test_login_required(self):
self.client.logout()
self.assertEqual(self.client.get('/podcasts/feeds/export.opml').status_code, 401)

View file

@ -6,13 +6,17 @@ urlpatterns = [
path('feeds/', views.feed_list, name='podcast_feed_list'), path('feeds/', views.feed_list, name='podcast_feed_list'),
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/export.opml', views.export_opml, name='podcast_export_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('feeds/refresh-all/', views.refresh_all_feeds, name='podcast_refresh_all_feeds'),
path('state/', views.podcast_state, name='podcast_state'), 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'),
path('episodes/', views.episode_list, name='podcast_episode_list'),
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'), path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),
path('stats/', views.podcast_stats, name='podcast_stats'),
path('queue/', views.queue_get, name='podcast_queue_get'), path('queue/', views.queue_get, name='podcast_queue_get'),
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'),

View file

@ -8,8 +8,11 @@ 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 Max, Q from django.db.models import (
from django.http import JsonResponse Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When,
)
from django.db.models.functions import Coalesce
from django.http import HttpResponse, 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
@ -641,10 +644,192 @@ def episode_detail(request, pk):
'season_number': ep.season_number, 'season_number': ep.season_number,
'position_seconds': progress.position_seconds if progress else 0, 'position_seconds': progress.position_seconds if progress else 0,
'played': progress.played if progress else False, 'played': progress.played if progress else False,
'favorite': progress.favorite if progress else False,
'last_played_at': progress.updated_at.isoformat() if progress else None,
'in_queue': PodcastQueue.objects.filter(user=request.user, episode=ep).exists(), 'in_queue': PodcastQueue.objects.filter(user=request.user, episode=ep).exists(),
}) })
# ---------------------------------------------------------------------------
# Favorites
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def set_favorite(request, pk):
"""Star or unstar an episode: {"favorite": true|false}, no value toggles."""
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)
if not isinstance(body, dict):
return JsonResponse({'error': 'invalid JSON'}, status=400)
try:
episode = PodcastEpisode.objects.get(pk=pk, feed__user=request.user)
except PodcastEpisode.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
progress, _ = EpisodeProgress.objects.get_or_create(user=request.user, episode=episode)
wanted = body.get('favorite')
favorite = (not progress.favorite) if wanted is None else bool(wanted)
if favorite != progress.favorite:
# update() leaves updated_at alone: a star isn't listening, and the
# history and the statistics go by updated_at.
EpisodeProgress.objects.filter(pk=progress.pk).update(favorite=favorite)
return JsonResponse({'ok': True, 'favorite': favorite})
# ---------------------------------------------------------------------------
# Episodes of all feeds: filters, history, search
# ---------------------------------------------------------------------------
EPISODE_FILTERS = {
'all': Q(),
'unplayed': Q(played=False),
'in_progress': Q(position_seconds__gt=0, played=False),
'played': Q(played=True),
'favorites': Q(favorite=True),
'history': Q(played=True) | Q(position_seconds__gt=0),
}
EPISODE_LIST_LIMIT = 100
EPISODE_LIST_MAX = 500
EPISODE_LIST_FIELDS = (
'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url',
'position_seconds', 'played', 'favorite', 'in_queue', 'last_played_at',
)
def _int_param(request, name, default):
raw = request.GET.get(name, '').strip()
return default if raw == '' else int(raw)
def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT):
"""(episodes, total) for the episode list. Two queries, whatever the
page size: the count and the page. The user's progress row comes in by
a join, queue membership by a subquery. No shownotes (up to 50 KB each)."""
qs = (
PodcastEpisode.objects.filter(feed__user=user)
.annotate(mine=FilteredRelation('progress', condition=Q(progress__user=user)))
.annotate(
position_seconds=Coalesce('mine__position_seconds', Value(0)),
played=Coalesce('mine__played', Value(False)),
favorite=Coalesce('mine__favorite', Value(False)),
last_played_at=F('mine__updated_at'),
in_queue=Exists(PodcastQueue.objects.filter(user=user, episode=OuterRef('pk'))),
)
.filter(EPISODE_FILTERS[filter_name])
)
if feed_id is not None:
qs = qs.filter(feed_id=feed_id)
if q:
qs = qs.filter(title__icontains=q)
if filter_name == 'history':
qs = qs.order_by(F('last_played_at').desc(nulls_last=True), '-id')
else:
qs = qs.order_by(F('pub_date').desc(nulls_last=True), '-id')
total = qs.count()
episodes = list(qs.values(*EPISODE_LIST_FIELDS)[offset:offset + limit]) if limit else []
for ep in episodes:
ep['pub_date'] = ep['pub_date'].isoformat() if ep['pub_date'] else None
ep['last_played_at'] = ep['last_played_at'].isoformat() if ep['last_played_at'] else None
ep['played'] = bool(ep['played'])
ep['favorite'] = bool(ep['favorite'])
ep['in_queue'] = bool(ep['in_queue'])
return episodes, total
@csrf_exempt
@require_http_methods(['GET'])
def episode_list(request):
"""Episodes of all the user's feeds, filtered (all, unplayed, in_progress,
played, favorites, history), optionally of one feed or matching `q`."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
filter_name = request.GET.get('filter', '').strip() or 'all'
if filter_name not in EPISODE_FILTERS:
return JsonResponse({'error': 'unknown filter'}, status=400)
try:
offset = max(_int_param(request, 'offset', 0), 0)
limit = min(max(_int_param(request, 'limit', EPISODE_LIST_LIMIT), 0), EPISODE_LIST_MAX)
feed_id = _int_param(request, 'feed_id', None)
except ValueError:
return JsonResponse({'error': 'offset, limit and feed_id must be numbers'}, status=400)
episodes, total = _episode_list_data(
request.user, filter_name, feed_id, request.GET.get('q', '').strip(), offset, limit,
)
return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit, 'total': total})
# ---------------------------------------------------------------------------
# Listening statistics
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['GET'])
def podcast_stats(request):
"""How much the user listened, in total and per feed; `days` limits it to
progress rows updated within the last N days."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
rows = EpisodeProgress.objects.filter(user=request.user, episode__feed__user=request.user)
since = None
raw_days = request.GET.get('days', '').strip()
if raw_days:
try:
days = int(raw_days)
except ValueError:
return JsonResponse({'error': 'days must be a number'}, status=400)
if days < 0:
return JsonResponse({'error': 'days must not be negative'}, status=400)
since = timezone.now() - timedelta(days=days)
rows = rows.filter(updated_at__gte=since)
# A played episode counts with its full length (or as far as it got, if
# the feed gave no length); anything else as far as it got.
listened = Case(
When(played=True, episode__duration_seconds__gt=0, then=F('episode__duration_seconds')),
default=F('position_seconds'),
output_field=IntegerField(),
)
per_feed = (
rows.order_by()
.values('episode__feed_id', 'episode__feed__title', 'episode__feed__artwork_url')
.annotate(
seconds=Sum(listened),
played_count=Count('id', filter=Q(played=True)),
started_count=Count('id', filter=Q(played=False, position_seconds__gt=0)),
)
)
total_seconds = episodes_played = episodes_started = 0
feeds = []
for row in per_feed:
seconds = row['seconds'] or 0
total_seconds += seconds
episodes_played += row['played_count']
episodes_started += row['started_count']
if seconds > 0:
feeds.append({
'feed_id': row['episode__feed_id'],
'title': row['episode__feed__title'],
'artwork_url': row['episode__feed__artwork_url'],
'seconds': seconds,
'episodes_played': row['played_count'],
})
feeds.sort(key=lambda f: (-f['seconds'], f['title'].lower(), f['feed_id']))
return JsonResponse({
'total_seconds': total_seconds,
'episodes_played': episodes_played,
'episodes_started': episodes_started,
'since': since.isoformat() if since else None,
'feeds': feeds,
})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Import OPML # Import OPML
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -685,6 +870,44 @@ def import_opml(request):
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped}) return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})
# ---------------------------------------------------------------------------
# Export OPML
# ---------------------------------------------------------------------------
def _xml_text(s):
"""Drop characters XML 1.0 can't carry at all (escaping is ElementTree's job)."""
return ''.join(c for c in s if c in '\t\n\r' or (' ' <= c <= '퟿') or ('' <= c <= '<EFBFBD>')
or ord(c) >= 0x10000)
@csrf_exempt
@require_http_methods(['GET'])
def export_opml(request):
"""The user's subscriptions as an OPML 2.0 file, for other podcast apps
(and for import_opml)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
root = ET.Element('opml', version='2.0')
head = ET.SubElement(root, 'head')
ET.SubElement(head, 'title').text = 'diora Podcasts'
body = ET.SubElement(root, 'body')
for feed in request.user.podcast_feeds.order_by('title', 'id').only('title', 'rss_url', 'link'):
title = _xml_text(feed.title or feed.rss_url)
ET.SubElement(body, 'outline', {
'type': 'rss',
'text': title,
'title': title,
'xmlUrl': _xml_text(feed.rss_url),
'htmlUrl': _xml_text(feed.link),
})
ET.indent(root)
xml = '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding='unicode') + '\n'
response = HttpResponse(xml.encode('utf-8'), content_type='text/x-opml; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="diora-podcasts.opml"'
return response
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Refresh feed now # Refresh feed now
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------