Compare commits

..

2 commits

Author SHA1 Message Date
d3e432e165 Podcast-Oberfläche aus diora-android 3d1281c (Listen + Abonnieren-Vorschau), sw v63
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 6m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:29:15 +02:00
69b22cf2e3 Podcast-Listen: Zähler pro Abo, kombinierbare Folgen-Filter, Suche in Shownotes, mehrere Favoriten; Oberfläche aus diora-android, sw v62
- feeds/ und state/: pro Abo episode_count, unplayed_count, new_count (wie
  „Neu“), last_played_at und link – zwei Abfragen, egal wie viele Abos.
- feeds/<id>/episodes/: favorite pro Folge; description, link, rss_url und
  last_refreshed_at des Podcasts für die Infoseite.
- episodes/: filter mit Komma kombinierbar (alle müssen passen), neuer Filter
  queued, ids= (die auf einem Gerät geladenen), sort= (new, old, long, short,
  title, played), notes=1 sucht q auch in den Shownotes (Titeltreffer zuerst).
- POST episodes/favorite/: mehrere Folgen auf einmal (ent)favorisieren.
- Tests dafür; Podcast-Oberfläche aus diora-android übernommen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:23:00 +02:00
6 changed files with 1090 additions and 121 deletions

View file

@ -837,8 +837,67 @@ class EpisodeListTests(TestCase):
def test_bad_parameters(self): def test_bad_parameters(self):
self.assertEqual(self.client.get('/podcasts/episodes/', {'filter': 'new'}).status_code, 400) self.assertEqual(self.client.get('/podcasts/episodes/', {'filter': 'new'}).status_code, 400)
self.assertEqual(self.client.get('/podcasts/episodes/', {'filter': 'unplayed,new'}).status_code, 400)
self.assertEqual(self.client.get('/podcasts/episodes/', {'limit': 'ten'}).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) self.assertEqual(self.client.get('/podcasts/episodes/', {'feed_id': 'x'}).status_code, 400)
self.assertEqual(self.client.get('/podcasts/episodes/', {'ids': '1,x'}).status_code, 400)
self.assertEqual(self.client.get('/podcasts/episodes/', {'sort': 'random'}).status_code, 400)
def test_queued(self):
self.assertEqual(self.ids(filter='queued'), [self.a2.id])
def test_filters_combine(self):
# Several filters, joined by commas, all have to match.
self.assertEqual(self.ids(filter='unplayed,queued'), [self.a2.id])
self.assertEqual(self.ids(filter='in_progress,queued'), [self.a2.id])
self.assertEqual(self.ids(filter='unplayed,favorites'), [self.a3.id])
self.assertEqual(self.ids(filter='played,favorites'), [])
self.assertEqual(self.ids(filter='history,in_progress'), [self.a2.id, self.b1.id])
self.assertEqual(self.ids(filter='all,favorites'), [self.a3.id])
self.assertEqual(self.ids(filter='unplayed,'), self.ids(filter='unplayed'))
self.assertEqual(self.get(filter='unplayed,queued')['total'], 1)
def test_sorting(self):
newest = [e.id for e in (self.b2, self.b1, self.a3, self.a2, self.a1, self.a4)]
self.assertEqual(self.ids(sort='new'), newest)
# Oldest first; without a date still last.
self.assertEqual(self.ids(sort='old'), [e.id for e in (self.a1, self.a2, self.a3, self.b1, self.b2, self.a4)])
PodcastEpisode.objects.filter(pk=self.a2.pk).update(duration_seconds=1200)
PodcastEpisode.objects.filter(pk=self.b1.pk).update(duration_seconds=300)
# By length; unknown lengths (0) last, newest first among them.
self.assertEqual(self.ids(sort='long')[:3], [self.a2.id, self.a1.id, self.b1.id])
self.assertEqual(self.ids(sort='short')[:3], [self.b1.id, self.a1.id, self.a2.id])
self.assertEqual(self.ids(sort='short')[3:], [self.b2.id, self.a3.id, self.a4.id])
self.assertEqual(self.ids(sort='title'), [e.id for e in (self.a1, self.a2, self.a3, self.a4, self.b2, self.b1)])
# The history sorts by date too when asked; by default by the last listening.
self.assertEqual(self.ids(filter='history', sort='new'), [self.b1.id, self.a2.id, self.a1.id])
self.assertEqual(self.ids(filter='unplayed', sort='played'), [e.id for e in (self.a3, self.b2, self.a2, self.b1, self.a4)])
def test_ids(self):
# The episodes downloaded on a device; someone else's stay out.
bobs = PodcastEpisode.objects.get(feed__user__username='bob')
ids = f'{self.a1.id},{self.b1.id},{bobs.id}'
self.assertEqual(self.ids(ids=ids), [self.b1.id, self.a1.id])
self.assertEqual(self.ids(ids=ids, filter='unplayed'), [self.b1.id])
self.assertEqual(self.ids(ids=''), [])
def test_search_in_shownotes(self):
PodcastEpisode.objects.filter(pk=self.a1.pk).update(description='<p>Heute mit dem Gast Bonusmaterial</p>')
# By title only unless asked; with notes=1 title matches first.
self.assertEqual(self.ids(q='bonus'), [self.b1.id])
self.assertEqual(self.ids(q='bonus', notes='1'), [self.b1.id, self.a1.id])
self.assertEqual(self.ids(q='gast', notes='1'), [self.a1.id])
self.assertEqual(self.ids(q='gast', notes='1', filter='unplayed'), [])
self.assertEqual(self.get(q='bonus', notes='1')['total'], 2)
# The notes never come along in the list.
self.assertNotIn('description', self.get(q='gast', notes='1')['episodes'][0])
def test_combined_query_count(self):
from .views import _episode_list_data
with self.assertNumQueries(2):
episodes, total = _episode_list_data(self.user, ['unplayed', 'queued'], q='a', notes=True,
sort='short', ids=[self.a2.id, self.a3.id])
self.assertEqual(([e['id'] for e in episodes], total), ([self.a2.id], 1))
def test_login_required(self): def test_login_required(self):
self.client.logout() self.client.logout()
@ -2190,3 +2249,125 @@ class AutoDownloadDefaultTests(TestCase):
self.set_default(False) self.set_default(False)
opml('https://example.org/a2') opml('https://example.org/a2')
self.assertTrue(auto_download('https://example.org/a2')) self.assertTrue(auto_download('https://example.org/a2'))
class FeedCountsTests(TestCase):
"""Per feed in feeds/ and state/: episodes, unplayed, new, last listened;
and what a podcast's own page needs (feeds/<id>/episodes/)."""
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',
link='https://example.org/', description='Ein Podcast über A.')
self.fb = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta')
self.empty = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/c', title='Leer')
eps = [PodcastEpisode.objects.create(feed=self.fa, guid=f'a{i}', title=f'A{i}', pub_date=_at(1, i + 1),
audio_url=f'https://example.org/a{i}.mp3') for i in range(5)]
self.b1 = PodcastEpisode.objects.create(feed=self.fb, guid='b1', title='B1', audio_url='https://example.org/b1.mp3')
_progress(self.user, eps[0], _at(2, 1), played=True)
_progress(self.user, eps[1], _at(2, 3), position_seconds=40)
_progress(self.user, eps[2], _at(2, 5), dismissed=True)
_progress(self.user, eps[3], _at(2, 9), favorite=True) # a star isn't listening
self.eps = eps
# Bob's progress on alice's episode (can't happen) and his own feed don't count.
bob = User.objects.create_user(username='bob', password='pw12345678')
_progress(bob, eps[4], _at(3, 1), played=True)
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='Alpha')
xb = PodcastEpisode.objects.create(feed=fbob, guid='x', title='X', audio_url='https://example.org/x.mp3')
_progress(bob, xb, _at(3, 1), played=True)
def test_counts_in_feeds_and_state(self):
for url in ('/podcasts/feeds/', '/podcasts/state/'):
with self.subTest(url=url):
feeds = {f['id']: f for f in self.client.get(url).json()['feeds']}
a, b, c = feeds[self.fa.id], feeds[self.fb.id], feeds[self.empty.id]
self.assertEqual((a['episode_count'], a['unplayed_count'], a['new_count']), (5, 4, 3))
self.assertEqual(a['last_played_at'], '2026-02-03T09:00:00+00:00')
self.assertEqual(a['link'], 'https://example.org/')
self.assertEqual((b['episode_count'], b['unplayed_count'], b['new_count'], b['last_played_at']),
(1, 1, 1, None))
self.assertEqual((c['episode_count'], c['unplayed_count'], c['new_count'], c['last_played_at']),
(0, 0, 0, None))
# The shownotes-sized description stays out of the state.
self.assertNotIn('description', a)
def test_new_count_matches_the_inbox(self):
inbox = self.client.get('/podcasts/state/').json()['inbox']
feeds = {f['id']: f for f in self.client.get('/podcasts/state/').json()['feeds']}
for feed in (self.fa, self.fb):
self.assertEqual(feeds[feed.id]['new_count'], sum(1 for e in inbox if e['feed__id'] == feed.id))
def test_two_queries_however_many_feeds(self):
from .views import _feeds_data
for i in range(10):
f = PodcastFeed.objects.create(user=self.user, rss_url=f'https://example.org/n{i}', title=f'N{i}')
e = PodcastEpisode.objects.create(feed=f, guid='e', title='E', audio_url='https://example.org/e.mp3')
_progress(self.user, e, _at(2, 2), played=True)
with self.assertNumQueries(2):
feeds = _feeds_data(self.user)
self.assertEqual(len(feeds), 13)
self.assertEqual({f['unplayed_count'] for f in feeds if f['title'].startswith('N')}, {0})
def test_feed_page_has_favorites_and_info(self):
data = self.client.get(f'/podcasts/feeds/{self.fa.id}/episodes/').json()
favs = {e['id']: e['favorite'] for e in data['episodes']}
self.assertEqual(favs[self.eps[3].id], True)
self.assertEqual(favs[self.eps[0].id], False)
self.assertEqual(favs[self.eps[4].id], False) # bob's row isn't alice's
self.assertEqual({k: data['feed'][k] for k in ('description', 'link', 'rss_url', 'last_refreshed_at')}, {
'description': 'Ein Podcast über A.', 'link': 'https://example.org/', 'rss_url': 'https://example.org/a',
'last_refreshed_at': None,
})
class BulkFavoriteTests(TestCase):
"""POST /podcasts/episodes/favorite/: star or unstar several at once."""
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.a, self.b, self.c = (
PodcastEpisode.objects.create(feed=feed, guid=g, title=g, audio_url=f'https://example.org/{g}.mp3')
for g in 'abc'
)
bob = User.objects.create_user(username='bob', password='pw12345678')
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/feed', title='Feed')
self.bobs = PodcastEpisode.objects.create(feed=fbob, guid='x', title='X', audio_url='https://example.org/x.mp3')
def post(self, body):
return self.client.post('/podcasts/episodes/favorite/', json.dumps(body), content_type='application/json')
def test_star_and_unstar(self):
p = _progress(self.user, self.a, _at(2, 1), position_seconds=30)
r = self.post({'episode_ids': [self.a.id, self.b.id, self.bobs.id, 999999], 'favorite': True})
self.assertEqual(r.json(), {'ok': True, 'favorite': True, 'count': 2})
self.assertEqual(set(EpisodeProgress.objects.filter(user=self.user, favorite=True)
.values_list('episode_id', flat=True)), {self.a.id, self.b.id})
# The listening time of an existing row stays (history, statistics).
p.refresh_from_db()
self.assertEqual((p.position_seconds, p.updated_at), (30, _at(2, 1)))
self.assertFalse(EpisodeProgress.objects.filter(episode=self.bobs).exists())
# Again: nothing doubles.
self.post({'episode_ids': [self.a.id, self.b.id], 'favorite': True})
self.assertEqual(EpisodeProgress.objects.filter(user=self.user).count(), 2)
r = self.post({'episode_ids': [self.a.id, self.b.id, self.c.id], 'favorite': False})
self.assertEqual(r.json(), {'ok': True, 'favorite': False, 'count': 3})
self.assertFalse(EpisodeProgress.objects.filter(favorite=True).exists())
# Unstarring doesn't make rows for episodes that had none.
self.assertFalse(EpisodeProgress.objects.filter(episode=self.c).exists())
# The list shows it.
self.post({'episode_ids': [self.c.id], 'favorite': True})
favs = self.client.get('/podcasts/episodes/', {'filter': 'favorites'}).json()['episodes']
self.assertEqual([e['id'] for e in favs], [self.c.id])
def test_bad_requests(self):
for body in ({}, {'episode_ids': [self.a.id]}, {'episode_ids': [], 'favorite': True},
{'episode_ids': self.a.id, 'favorite': True}, {'episode_ids': ['x'], 'favorite': True},
{'episode_ids': [self.a.id], 'favorite': 'yes'}, [1, 2]):
with self.subTest(body=body):
self.assertEqual(self.post(body).status_code, 400)
self.assertEqual(self.client.get('/podcasts/episodes/favorite/').status_code, 405)
self.client.logout()
self.assertEqual(self.post({'episode_ids': [self.a.id], 'favorite': True}).status_code, 401)

View file

@ -15,6 +15,7 @@ urlpatterns = [
path('feeds/<int:pk>/settings/', views.feed_settings, name='podcast_feed_settings'), path('feeds/<int:pk>/settings/', views.feed_settings, name='podcast_feed_settings'),
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/', views.episode_list, name='podcast_episode_list'),
path('episodes/favorite/', views.set_favorites, name='podcast_set_favorites'),
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>/transcript/', views.episode_transcript, name='podcast_episode_transcript'), path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'), path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),

View file

@ -16,7 +16,7 @@ from django.db import IntegrityError, transaction
from django.db.models import ( from django.db.models import (
Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When, Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce, Lower
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
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
@ -733,15 +733,37 @@ def _feed_settings(feed):
def _feeds_data(user): def _feeds_data(user):
"""The user's feeds with their settings and, per feed, how many episodes
it has, how many of them are unplayed and how many new (as in the inbox:
neither played nor dismissed), and when the user last listened to one.
Two queries, however many feeds: the feeds with their episode count, and
the user's progress rows counted per feed."""
feeds = list( feeds = list(
user.podcast_feeds user.podcast_feeds
.annotate(latest_episode_at=Max('episodes__pub_date')) .annotate(latest_episode_at=Max('episodes__pub_date'), episode_count=Count('episodes'))
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', .values('id', 'title', 'artwork_url', 'rss_url', 'link', 'last_refreshed_at', 'author', 'added_at',
'latest_episode_at', 'last_error', 'last_error_at', 'error_count', *FEED_SETTING_FIELDS) 'latest_episode_at', 'episode_count', 'last_error', 'last_error_at', 'error_count',
*FEED_SETTING_FIELDS)
) )
listened = {
row['episode__feed_id']: row
for row in EpisodeProgress.objects.filter(user=user, episode__feed__user=user)
.order_by()
.values('episode__feed_id')
.annotate(
played_count=Count('id', filter=Q(played=True)),
hidden_count=Count('id', filter=Q(played=True) | Q(dismissed=True)),
last_played_at=Max('updated_at', filter=Q(played=True) | Q(position_seconds__gt=0)),
)
}
for f in feeds: for f in feeds:
f['feed_id'] = f['id'] # as in feeds/<id>/settings/ f['feed_id'] = f['id'] # as in feeds/<id>/settings/
mine = listened.get(f['id'], {})
f['unplayed_count'] = max(f['episode_count'] - mine.get('played_count', 0), 0)
f['new_count'] = max(f['episode_count'] - mine.get('hidden_count', 0), 0)
last = mine.get('last_played_at')
f['last_played_at'] = last.isoformat() if last else None
if f['last_refreshed_at']: if f['last_refreshed_at']:
f['last_refreshed_at'] = f['last_refreshed_at'].isoformat() f['last_refreshed_at'] = f['last_refreshed_at'].isoformat()
if f['last_error_at']: if f['last_error_at']:
@ -958,7 +980,7 @@ def feed_episodes(request, pk):
for ep in EpisodeProgress.objects.filter( for ep in EpisodeProgress.objects.filter(
user=request.user, user=request.user,
episode__feed=feed, episode__feed=feed,
).values('episode_id', 'position_seconds', 'played') ).values('episode_id', 'position_seconds', 'played', 'favorite')
} }
# Batch-fetch queue membership # Batch-fetch queue membership
@ -975,6 +997,7 @@ def feed_episodes(request, pk):
prog = progress_map.get(ep['id'], {}) prog = progress_map.get(ep['id'], {})
ep['position_seconds'] = prog.get('position_seconds', 0) ep['position_seconds'] = prog.get('position_seconds', 0)
ep['played'] = prog.get('played', False) ep['played'] = prog.get('played', False)
ep['favorite'] = prog.get('favorite', False)
ep['in_queue'] = ep['id'] in queued_ids ep['in_queue'] = ep['id'] in queued_ids
return JsonResponse({ return JsonResponse({
@ -984,6 +1007,11 @@ def feed_episodes(request, pk):
'artwork_url': feed.artwork_url, 'artwork_url': feed.artwork_url,
'author': feed.author, 'author': feed.author,
'auto_queue': feed.auto_queue, 'auto_queue': feed.auto_queue,
# For the podcast's info page.
'description': feed.description,
'link': feed.link,
'rss_url': feed.rss_url,
'last_refreshed_at': feed.last_refreshed_at.isoformat() if feed.last_refreshed_at else None,
}, },
'episodes': episodes, 'episodes': episodes,
}) })
@ -1154,6 +1182,43 @@ def set_favorite(request, pk):
return JsonResponse({'ok': True, 'favorite': favorite}) return JsonResponse({'ok': True, 'favorite': favorite})
@csrf_exempt
@require_http_methods(['POST'])
def set_favorites(request):
"""Star or unstar several episodes at once (choosing several in a list):
{"episode_ids": [...], "favorite": true}. Like set_favorite, it leaves
the listening time (updated_at) of existing rows alone."""
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)
episode_ids = body.get('episode_ids')
favorite = body.get('favorite')
if not isinstance(episode_ids, list) or not episode_ids or not isinstance(favorite, bool):
return JsonResponse({'error': 'episode_ids (a list) and favorite (true/false) required'}, status=400)
try:
wanted = {int(i) for i in episode_ids[:EPISODE_IDS_MAX]}
except (TypeError, ValueError):
return JsonResponse({'error': 'episode_ids must be numbers'}, status=400)
valid = set(
PodcastEpisode.objects.filter(id__in=wanted, feed__user=request.user).values_list('id', flat=True)
)
with transaction.atomic():
rows = EpisodeProgress.objects.filter(user=request.user, episode_id__in=valid)
rows.exclude(favorite=favorite).update(favorite=favorite)
if favorite:
have = set(rows.values_list('episode_id', flat=True))
EpisodeProgress.objects.bulk_create(
[EpisodeProgress(user=request.user, episode_id=i, favorite=True) for i in sorted(valid - have)],
ignore_conflicts=True,
)
return JsonResponse({'ok': True, 'favorite': favorite, 'count': len(valid)})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Episodes of all feeds: filters, history, search # Episodes of all feeds: filters, history, search
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -1165,7 +1230,19 @@ EPISODE_FILTERS = {
'played': Q(played=True), 'played': Q(played=True),
'favorites': Q(favorite=True), 'favorites': Q(favorite=True),
'history': Q(played=True) | Q(position_seconds__gt=0), 'history': Q(played=True) | Q(position_seconds__gt=0),
'queued': Q(in_queue=True),
} }
# Episodes of unknown length (0) come last when sorting by length.
_UNKNOWN_LENGTH_LAST = Case(When(duration_seconds__gt=0, then=Value(0)), default=Value(1), output_field=IntegerField())
EPISODE_SORTS = {
'new': (F('pub_date').desc(nulls_last=True), '-id'),
'old': (F('pub_date').asc(nulls_last=True), 'id'),
'long': (_UNKNOWN_LENGTH_LAST, '-duration_seconds', F('pub_date').desc(nulls_last=True), '-id'),
'short': (_UNKNOWN_LENGTH_LAST, 'duration_seconds', F('pub_date').desc(nulls_last=True), '-id'),
'title': (Lower('title'), 'id'),
'played': (F('last_played_at').desc(nulls_last=True), '-id'),
}
EPISODE_IDS_MAX = 1000
EPISODE_LIST_LIMIT = 100 EPISODE_LIST_LIMIT = 100
EPISODE_LIST_MAX = 500 EPISODE_LIST_MAX = 500
EPISODE_LIST_FIELDS = ( EPISODE_LIST_FIELDS = (
@ -1180,10 +1257,19 @@ def _int_param(request, name, default):
return default if raw == '' else int(raw) 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): def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT,
sort=None, ids=None, notes=False):
"""(episodes, total) for the episode list. Two queries, whatever the """(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 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).""" a join, queue membership by a subquery. No shownotes (up to 50 KB each).
filter_name: one of EPISODE_FILTERS, or several (a list, or joined by
commas), which all have to match. sort: one of EPISODE_SORTS; by default
the newest first, in the history the last listened first. ids: only these
episodes (the ones downloaded on a device). notes: `q` also matches the
shownotes; title matches come first."""
names = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name)
names = [n for n in names if n and n != 'all']
qs = ( qs = (
PodcastEpisode.objects.filter(feed__user=user) PodcastEpisode.objects.filter(feed__user=user)
.annotate(mine=FilteredRelation('progress', condition=Q(progress__user=user))) .annotate(mine=FilteredRelation('progress', condition=Q(progress__user=user)))
@ -1194,16 +1280,22 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
last_played_at=F('mine__updated_at'), last_played_at=F('mine__updated_at'),
in_queue=Exists(PodcastQueue.objects.filter(user=user, episode=OuterRef('pk'))), in_queue=Exists(PodcastQueue.objects.filter(user=user, episode=OuterRef('pk'))),
) )
.filter(EPISODE_FILTERS[filter_name])
) )
for name in names:
qs = qs.filter(EPISODE_FILTERS[name])
if feed_id is not None: if feed_id is not None:
qs = qs.filter(feed_id=feed_id) qs = qs.filter(feed_id=feed_id)
if q: if ids is not None:
qs = qs.filter(id__in=ids)
order = EPISODE_SORTS[sort or ('played' if 'history' in names else 'new')]
if q and notes:
qs = qs.filter(Q(title__icontains=q) | Q(description__icontains=q)).annotate(
title_hit=Case(When(title__icontains=q, then=Value(0)), default=Value(1), output_field=IntegerField()),
)
order = ('title_hit', *order)
elif q:
qs = qs.filter(title__icontains=q) qs = qs.filter(title__icontains=q)
if filter_name == 'history': qs = qs.order_by(*order)
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() total = qs.count()
episodes = list(qs.values(*EPISODE_LIST_FIELDS)[offset:offset + limit]) if limit else [] episodes = list(qs.values(*EPISODE_LIST_FIELDS)[offset:offset + limit]) if limit else []
@ -1220,20 +1312,29 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
@require_http_methods(['GET']) @require_http_methods(['GET'])
def episode_list(request): def episode_list(request):
"""Episodes of all the user's feeds, filtered (all, unplayed, in_progress, """Episodes of all the user's feeds, filtered (all, unplayed, in_progress,
played, favorites, history), optionally of one feed or matching `q`.""" played, favorites, history, queued; several joined by commas all have to
match), optionally of one feed, of some episodes (`ids`, comma-separated)
or matching `q` (in the title; with notes=1 also in the shownotes), sorted
by `sort` (new, old, long, short, title, played)."""
if not request.user.is_authenticated: if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401) return JsonResponse({'error': 'authentication required'}, status=401)
filter_name = request.GET.get('filter', '').strip() or 'all' names = [n.strip() for n in request.GET.get('filter', '').split(',') if n.strip()]
if filter_name not in EPISODE_FILTERS: if any(n not in EPISODE_FILTERS for n in names):
return JsonResponse({'error': 'unknown filter'}, status=400) return JsonResponse({'error': 'unknown filter'}, status=400)
sort = request.GET.get('sort', '').strip() or None
if sort is not None and sort not in EPISODE_SORTS:
return JsonResponse({'error': 'unknown sort'}, status=400)
try: try:
offset = max(_int_param(request, 'offset', 0), 0) offset = max(_int_param(request, 'offset', 0), 0)
limit = min(max(_int_param(request, 'limit', EPISODE_LIST_LIMIT), 0), EPISODE_LIST_MAX) limit = min(max(_int_param(request, 'limit', EPISODE_LIST_LIMIT), 0), EPISODE_LIST_MAX)
feed_id = _int_param(request, 'feed_id', None) feed_id = _int_param(request, 'feed_id', None)
raw_ids = request.GET.get('ids')
ids = None if raw_ids is None else [int(i) for i in raw_ids.split(',') if i.strip()][:EPISODE_IDS_MAX]
except ValueError: except ValueError:
return JsonResponse({'error': 'offset, limit and feed_id must be numbers'}, status=400) return JsonResponse({'error': 'offset, limit, feed_id and ids must be numbers'}, status=400)
episodes, total = _episode_list_data( episodes, total = _episode_list_data(
request.user, filter_name, feed_id, request.GET.get('q', '').strip(), offset, limit, request.user, names, feed_id, request.GET.get('q', '').strip(), offset, limit,
sort=sort, ids=ids, notes=request.GET.get('notes') == '1',
) )
return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit, 'total': total}) return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit, 'total': total})

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v61'; const CACHE = 'diora-v63';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app.js', '/static/js/app.js',

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 57fc424 2026-10-02: the podcast parts of web-native/static/native.css /* Copied from diora-android 99528f2 2026-10-02: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here. (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */ The web's own additions are in podcasts-web.css. */
@ -572,3 +572,20 @@ body.eink .nui-pod-feederr-box { border-color: #000; }
body.eink .nui-pod-offer { border-color: #000; } body.eink .nui-pod-offer { border-color: #000; }
body.eink .nui-pod-seg { color: #444; } body.eink .nui-pod-seg { color: #444; }
body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; } body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; }
/* lists: search sections, the number at a podcast, its info page */
.nui-pod-sec { margin-top: 14px; padding: 4px 2px; font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--nui-muted); border-bottom: 1px solid var(--border); }
.nui-pod-search + .nui-pod-sec { margin-top: 4px; }
.nui-pod-sec-note { padding: 10px 2px; font-size: 13px; color: var(--nui-muted); }
.nui-pod-feedcount { flex: none; }
.nui-pod-infobtn { flex: none; font-size: 20px; color: var(--nui-muted); }
.nui-pod-info-desc { font-size: 14px; font-weight: 400; line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; padding: 4px 0 10px; border-bottom: 1px solid var(--border); }
.nui-pod-facts { font-weight: 400; display: grid; grid-template-columns: max-content 1fr; gap: 8px 14px; margin: 10px 0 4px; font-size: 14px; }
.nui-pod-facts dt { color: var(--nui-muted); }
.nui-pod-facts dd { margin: 0; min-width: 0; font-weight: 400; overflow-wrap: anywhere; }
.nui-pod-facts a { color: var(--accent); }
.nui-pod-info-url { display: block; font-size: 12px; color: var(--nui-muted); }
.nui-pod-info-actions { display: flex; gap: 8px; margin-top: 6px; }
.nui-pod-info-actions .btn { min-height: 36px; padding: 0 14px; font-size: 14px; }
body.eink .nui-pod-sec { color: #000; border-bottom-color: #000; }
body.eink .nui-pod-facts a { color: #000; text-decoration: underline; }