Podcasts: Favoriten, Folgenliste mit Filtern und Verlauf, Hörstatistik, OPML-Export
- 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:
parent
fd0a103fb2
commit
f30bf2fcdc
5 changed files with 576 additions and 2 deletions
18
podcasts/migrations/0006_episodeprogress_favorite.py
Normal file
18
podcasts/migrations/0006_episodeprogress_favorite.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 & 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" <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)
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue