diff --git a/podcasts/migrations/0006_episodeprogress_favorite.py b/podcasts/migrations/0006_episodeprogress_favorite.py new file mode 100644 index 0000000..3c66309 --- /dev/null +++ b/podcasts/migrations/0006_episodeprogress_favorite.py @@ -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), + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index d63e427..b349b7b 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -58,6 +58,7 @@ class EpisodeProgress(models.Model): position_seconds = models.IntegerField(default=0) played = models.BooleanField(default=False) dismissed = models.BooleanField(default=False) + favorite = models.BooleanField(default=False) updated_at = models.DateTimeField(auto_now=True) class Meta: diff --git a/podcasts/tests.py b/podcasts/tests.py index 5e42d6c..1e1ba9c 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -535,3 +535,331 @@ class EpisodeDetailTests(TestCase): def test_login_required(self): self.client.logout() 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" ', 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 & Jerry "Live" <Folge>', raw) + self.assertIn('xmlUrl="https://example.org/feed?a=1&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" ', 'title': 'Tom & Jerry "Live" ', + '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) diff --git a/podcasts/urls.py b/podcasts/urls.py index 1f67788..d0948f0 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -6,13 +6,17 @@ urlpatterns = [ path('feeds/', views.feed_list, name='podcast_feed_list'), path('feeds/add/', views.add_feed, name='podcast_add_feed'), 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-all/', views.refresh_all_feeds, name='podcast_refresh_all_feeds'), path('state/', views.podcast_state, name='podcast_state'), path('feeds//remove/', views.remove_feed, name='podcast_remove_feed'), path('feeds//set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'), path('feeds//episodes/', views.feed_episodes, name='podcast_feed_episodes'), + path('episodes/', views.episode_list, name='podcast_episode_list'), path('episodes//', views.episode_detail, name='podcast_episode_detail'), + path('episodes//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/add/', views.queue_add, name='podcast_queue_add'), path('queue/remove/', views.queue_remove, name='podcast_queue_remove'), diff --git a/podcasts/views.py b/podcasts/views.py index 3892dc6..90b1a80 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -8,8 +8,11 @@ import xml.etree.ElementTree as ET import feedparser import requests from django.conf import settings -from django.db.models import Max, Q -from django.http import JsonResponse +from django.db.models import ( + 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.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods @@ -641,10 +644,192 @@ def episode_detail(request, pk): 'season_number': ep.season_number, 'position_seconds': progress.position_seconds if progress else 0, '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(), }) +# --------------------------------------------------------------------------- +# 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 # --------------------------------------------------------------------------- @@ -685,6 +870,44 @@ def import_opml(request): 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 <= '�') + 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 = '\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 # ---------------------------------------------------------------------------