From 69b22cf2e38f16b0eca1c0b5050725d111e245de Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Fri, 2 Oct 2026 16:23:00 +0200 Subject: [PATCH] =?UTF-8?q?Podcast-Listen:=20Z=C3=A4hler=20pro=20Abo,=20ko?= =?UTF-8?q?mbinierbare=20Folgen-Filter,=20Suche=20in=20Shownotes,=20mehrer?= =?UTF-8?q?e=20Favoriten;=20Oberfl=C3=A4che=20aus=20diora-android,=20sw=20?= =?UTF-8?q?v62?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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//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 --- podcasts/tests.py | 181 ++++++++ podcasts/urls.py | 1 + podcasts/views.py | 137 +++++- static/js/sw.js | 2 +- static/podcasts/native-podcast.js | 717 ++++++++++++++++++++++++++---- static/podcasts/podcast-ui.css | 19 +- 6 files changed, 939 insertions(+), 118 deletions(-) diff --git a/podcasts/tests.py b/podcasts/tests.py index d6186ad..096dc13 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -837,8 +837,67 @@ class EpisodeListTests(TestCase): 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': 'unplayed,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) + 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='

Heute mit dem Gast Bonusmaterial

') + # 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): self.client.logout() @@ -2190,3 +2249,125 @@ class AutoDownloadDefaultTests(TestCase): self.set_default(False) opml('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//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) diff --git a/podcasts/urls.py b/podcasts/urls.py index 72c9f86..d739850 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path('feeds//settings/', views.feed_settings, name='podcast_feed_settings'), path('feeds//episodes/', views.feed_episodes, name='podcast_feed_episodes'), path('episodes/', views.episode_list, name='podcast_episode_list'), + path('episodes/favorite/', views.set_favorites, name='podcast_set_favorites'), path('episodes//', views.episode_detail, name='podcast_episode_detail'), path('episodes//transcript/', views.episode_transcript, name='podcast_episode_transcript'), path('episodes//favorite/', views.set_favorite, name='podcast_set_favorite'), diff --git a/podcasts/views.py b/podcasts/views.py index eb2f932..1bfbc90 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -16,7 +16,7 @@ from django.db import IntegrityError, transaction 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.db.models.functions import Coalesce, Lower from django.http import HttpResponse, JsonResponse, StreamingHttpResponse from django.utils import timezone from django.views.decorators.csrf import csrf_exempt @@ -733,15 +733,37 @@ def _feed_settings(feed): 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( user.podcast_feeds - .annotate(latest_episode_at=Max('episodes__pub_date')) - .values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', - 'latest_episode_at', 'last_error', 'last_error_at', 'error_count', *FEED_SETTING_FIELDS) + .annotate(latest_episode_at=Max('episodes__pub_date'), episode_count=Count('episodes')) + .values('id', 'title', 'artwork_url', 'rss_url', 'link', 'last_refreshed_at', 'author', 'added_at', + '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: f['feed_id'] = f['id'] # as in feeds//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']: f['last_refreshed_at'] = f['last_refreshed_at'].isoformat() if f['last_error_at']: @@ -958,7 +980,7 @@ def feed_episodes(request, pk): for ep in EpisodeProgress.objects.filter( user=request.user, episode__feed=feed, - ).values('episode_id', 'position_seconds', 'played') + ).values('episode_id', 'position_seconds', 'played', 'favorite') } # Batch-fetch queue membership @@ -975,6 +997,7 @@ def feed_episodes(request, pk): prog = progress_map.get(ep['id'], {}) ep['position_seconds'] = prog.get('position_seconds', 0) ep['played'] = prog.get('played', False) + ep['favorite'] = prog.get('favorite', False) ep['in_queue'] = ep['id'] in queued_ids return JsonResponse({ @@ -984,6 +1007,11 @@ def feed_episodes(request, pk): 'artwork_url': feed.artwork_url, 'author': feed.author, '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, }) @@ -1154,6 +1182,43 @@ def set_favorite(request, pk): 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 # --------------------------------------------------------------------------- @@ -1165,7 +1230,19 @@ EPISODE_FILTERS = { 'played': Q(played=True), 'favorites': Q(favorite=True), '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_MAX = 500 EPISODE_LIST_FIELDS = ( @@ -1180,10 +1257,19 @@ def _int_param(request, name, default): 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 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 = ( PodcastEpisode.objects.filter(feed__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'), 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: 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) - 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') + qs = qs.order_by(*order) total = qs.count() 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']) 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`.""" + 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: return JsonResponse({'error': 'authentication required'}, status=401) - filter_name = request.GET.get('filter', '').strip() or 'all' - if filter_name not in EPISODE_FILTERS: + names = [n.strip() for n in request.GET.get('filter', '').split(',') if n.strip()] + if any(n not in EPISODE_FILTERS for n in names): 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: 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) + 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: - 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( - 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}) diff --git a/static/js/sw.js b/static/js/sw.js index 7c776ea..ebee697 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v61'; +const CACHE = 'diora-v62'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index bbade4e..52a5cf1 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android 38d03c6 2026-10-02: web-native/static/native-podcast.js +// Copied from diora-android 99528f2 2026-10-02: web-native/static/native-podcast.js // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // and copy again. The browser host is static/js/podcasts-web.js. @@ -412,7 +412,7 @@ }), ); const acts = el('div', 'nui-pod-selactions'); - for (const a of actions) { + for (const a of actions.filter(Boolean)) { const b = el('button', 'btn btn-sm' + (a.danger ? ' btn-danger' : ''), a.label); b.type = 'button'; b.disabled = !selected.size; @@ -432,6 +432,179 @@ return t; } + const nEpisodes = n => `${n} ${n === 1 ? 'Folge' : 'Folgen'}`; + + // Choosing several in a list of episodes (Folgen, one podcast): what to do with them. + function episodeSelection(items) { + const pick = ids => items.filter(e => ids.includes(e.id)); + const fresh = new Set(inbox().map(o => o.id)); + return selectionBar(items, [ + {label: 'Einreihen', run: ids => enqueueMany(pick(ids))}, + {label: 'Gehört', run: ids => markMany(pick(ids), true)}, + {label: 'Ungehört', run: ids => markMany(pick(ids), false)}, + DL && {label: 'Laden', run: ids => downloadMany(pick(ids))}, + {label: 'Favorit', run: ids => openSheet(nEpisodes(ids.length), [ + {icon: '★', label: 'Als Favorit markieren', action: () => favoriteMany(pick(ids), true)}, + {icon: '☆', label: 'Kein Favorit mehr', action: () => favoriteMany(pick(ids), false)}, + ])}, + // Only where some of them are new. + items.some(e => fresh.has(e.id)) && {label: 'Aus „Neu“', run: ids => { + const gone = ids.filter(id => fresh.has(id)); + if (gone.length) N.podcastDismiss(JSON.stringify(gone)); + toast(gone.length ? `${nEpisodes(gone.length)} aus „Neu“ entfernt` : 'Keine davon ist in „Neu“'); + stopSelecting(); + }}, + ].filter(Boolean)); + } + + function enqueueMany(list) { + const todo = list.filter(e => !inQueue(e.id)); + if (!todo.length) { toast('Schon alle in der Warteschlange'); stopSelecting(); return; } + const add = where => { + N.podcastEnqueue(JSON.stringify(todo.map(e => e.raw)), where, currentId()); + for (const e of todo) e.raw.in_queue = true; + toast(`${nEpisodes(todo.length)} eingereiht`); + stopSelecting(); + }; + openSheet(`${nEpisodes(todo.length)} einreihen`, [ + {icon: '+', label: 'Wie eingestellt', hint: (POSITIONS.find(p => p.value === settings().enqueue_position) || POSITIONS[0]).label, + action: () => add('')}, + {icon: '⤴', label: 'Als Nächstes', action: () => add('after_current')}, + {icon: '⇈', label: 'An den Anfang', action: () => add('front')}, + {icon: '⇊', label: 'Ans Ende', action: () => add('back')}, + ]); + } + + function markMany(list, played) { + const ids = list.map(e => e.id); + if (played) { + N.podcastMarkPlayedAll(JSON.stringify(ids), true); + } else { + // "Ungehört" one by one fetches the state again each time: all but the + // last in one go; downloaded ones singly, the app has to know of them. + const single = ids.filter(id => DL && downloads[id]); + const rest = ids.filter(id => !(DL && downloads[id])); + if (!single.length) single.push(rest.pop()); + if (rest.length) N.podcastMarkPlayedAll(JSON.stringify(rest), false); + for (const id of single) N.podcastMarkPlayed(id, false); + } + for (const e of list) { + e.raw.played = played; + if (played) e.raw.position_seconds = 0; + } + toast(`${nEpisodes(ids.length)} als ${played ? 'gehört' : 'ungehört'} markiert`); + stopSelecting(); + } + + function downloadMany(list) { + const todo = list.filter(e => (e.raw.audio_url || e.raw.episode__audio_url) && + !(downloads[e.id] && downloads[e.id].state !== 'failed')); + const waiting = {}; + for (const e of todo) { + N.podcastDownload(JSON.stringify(e.raw)); + waiting[e.id] = {state: 'queued', progress: 0, bytes: 0, total: 0, title: e.title, reason: 'queue'}; + } + toast(todo.length ? `${nEpisodes(todo.length)} ${todo.length === 1 ? 'wird' : 'werden'} geladen` : 'Schon alle geladen'); + selected = null; + window.__dioraDownloads(Object.assign({}, downloads, waiting)); + render(); + } + + async function favoriteMany(list, star) { + stopSelecting(); + try { + const r = await fetch('/podcasts/episodes/favorite/', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({episode_ids: list.map(e => e.id), favorite: star}), + }); + if (!r.ok) throw new Error(r.status); + for (const e of list) { + e.raw.favorite = star; + if (N.podcastFavoriteChanged) N.podcastFavoriteChanged(e.id, star); + const cached = storedDetails(e.id); + if (cached) keepDetails(e.id, Object.assign({}, cached, {favorite: star})); + } + toast(star ? `★ ${nEpisodes(list.length)} als Favorit markiert` : `${nEpisodes(list.length)}: kein Favorit mehr`); + } catch (err) { + toast(navigator.onLine ? 'Favoriten ließen sich nicht setzen' : 'Favoriten brauchen eine Verbindung'); + } + render(); + } + + // ---- filters and order of episode lists (Folgen, one podcast) ------------------------- + + const FILTERS = [ + {id: 'unplayed', label: 'Ungehört', test: o => !o.played, none: 'Keine ungehörten Folgen.'}, + {id: 'in_progress', label: 'Angefangen', test: o => !o.played && (o.position_seconds || 0) > 0, + none: 'Keine angefangenen Folgen.'}, + {id: 'favorites', label: 'Favoriten', test: o => !!o.favorite, + none: 'Noch keine Favoriten. Im ⋯-Menü einer Folge markierst du sie mit ★.'}, + {id: 'queued', label: 'Warteschlange', test: o => inQueue(o.id), none: 'Keine davon steht in der Warteschlange.'}, + {id: 'downloaded', label: 'Geladen', app: true, test: o => dlDone(o.id), none: 'Keine Folgen auf dem Gerät.'}, + {id: 'history', label: 'Verlauf', all: true, test: o => !!o.played || (o.position_seconds || 0) > 0, + none: 'Noch nichts gehört.'}, + {id: 'played', label: 'Gehört', test: o => !!o.played, none: 'Noch keine gehörten Folgen.'}, + ]; + // Filters that can't both be on: choosing one takes the others off. + const EXCLUDES = {unplayed: ['played'], in_progress: ['played'], played: ['unplayed', 'in_progress']}; + const EP_SORTS = [ + {id: 'new', label: 'Neueste zuerst', key: o => -(Date.parse(o.pub_date) || -Infinity)}, + {id: 'old', label: 'Älteste zuerst', key: o => Date.parse(o.pub_date) || Infinity}, + {id: 'long', label: 'Längste zuerst', key: o => (o.duration_seconds ? -o.duration_seconds : Infinity)}, + {id: 'short', label: 'Kürzeste zuerst', key: o => o.duration_seconds || Infinity}, + {id: 'title', label: 'Nach Titel', key: o => plain(o.title).toLocaleLowerCase('de')}, + {id: 'played', label: 'Zuletzt gehört', all: true}, + ]; + const sortLabel = id => (EP_SORTS.find(s => s.id === id) || EP_SORTS[0]).label; + + function dlDone(id) { + return DL && !!downloads[id] && downloads[id].state === 'done'; + } + + function matchesAll(o, filters) { + return filters.every(id => { const f = FILTERS.find(x => x.id === id); return !f || f.test(o); }); + } + + function sortEpisodes(list, sort) { + const s = EP_SORTS.find(x => x.id === sort); + if (!s || !s.key) return list; + const keyed = list.map((o, i) => [s.key(o), i, o]); + keyed.sort((a, b) => (typeof a[0] === 'string' ? a[0].localeCompare(b[0], 'de') : a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0) || + a[1] - b[1]); + return keyed.map(k => k[2]); + } + + // One row of chips: "Alle" and the filters, combinable. + function filterRow(active, onChange, allEpisodes) { + const chips = el('div', 'nui-pod-filters'); + const chip = (label, on, onClick) => { + const b = el('button', 'nui-pod-chip' + (on ? ' on' : ''), label); + b.type = 'button'; + b.setAttribute('aria-pressed', on ? 'true' : 'false'); + b.addEventListener('click', onClick); + chips.append(b); + }; + chip('Alle', !active.length, () => onChange([])); + for (const f of FILTERS) { + if ((f.app && !DL) || (f.all && !allEpisodes)) continue; + const on = active.includes(f.id); + chip(f.label, on, () => onChange(on ? active.filter(x => x !== f.id) + : active.filter(x => !(EXCLUDES[f.id] || []).includes(x)).concat(f.id))); + } + return chips; + } + + function sortMenuFor(current, allEpisodes, onPick) { + openSheet('Folgen sortieren', EP_SORTS.filter(s => allEpisodes || !s.all).map(s => ({ + icon: s.id === current ? '●' : '○', label: s.label, active: s.id === current, action: () => onPick(s.id), + }))); + } + + function noneText(filters) { + const f = filters.length === 1 && FILTERS.find(x => x.id === filters[0]); + return f ? f.none : filters.length ? 'Keine Folgen mit diesen Filtern.' : 'Keine Folgen.'; + } + function refresh(fetchFeeds) { refreshing = true; render(); @@ -470,6 +643,9 @@ function render() { if (!root) return; const scroll = scrollTop(); + // Search results arriving while typing: the field keeps its text and the cursor. + const q = $id('nui-pod-q'); + const typing = q && document.activeElement === q ? [q.selectionStart, q.selectionEnd] : null; root.textContent = ''; const bar = el('div', 'nui-tab-bar'); @@ -512,6 +688,11 @@ else renderSearch(); } scrollTop(scroll); + const q2 = typing && $id('nui-pod-q'); + if (q2) { + q2.focus(); + try { q2.setSelectionRange(typing[0], typing[1]); } catch (e) {} + } } // AntennaPod had episodes on the phone: offered once to fetch them again (Wi-Fi only). @@ -663,6 +844,7 @@ ])}, {label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }}, {label: 'Ausblenden', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }}, + DL && {label: 'Laden', run: ids => downloadMany(chosen(ids))}, ])); } else { const head = el('div', 'nui-pod-head'); @@ -685,34 +867,100 @@ root.append(list); } + // The subscriptions: sorted, each with a number (new, unplayed or downloaded + // episodes), both remembered on this device. + const byDate = key => (a, b) => String(b[key] || '').localeCompare(String(a[key] || '')); + const byTitle = (a, b) => plain(a.title).localeCompare(plain(b.title), 'de', {sensitivity: 'base'}); + const FEED_SORTS = [ + {id: 'latest', label: 'Neueste Folge zuerst', cmp: byDate('latest_episode_at')}, + {id: 'title', label: 'Nach Titel', cmp: byTitle}, + {id: 'count', label: 'Nach Zähler', hint: 'die meisten zuerst', + cmp: (a, b) => (feedCount(b) || 0) - (feedCount(a) || 0) || byTitle(a, b)}, + {id: 'played', label: 'Zuletzt gehört', cmp: (a, b) => byDate('last_played_at')(a, b) || byDate('latest_episode_at')(a, b)}, + {id: 'added', label: 'Zuletzt abonniert', cmp: byDate('added_at')}, + ]; + const COUNTERS = [ + {id: 'new', label: 'Neue Folgen', hint: 'wie in „Neu“', value: f => f.new_count}, + {id: 'unplayed', label: 'Ungehörte Folgen', value: f => f.unplayed_count}, + {id: 'downloaded', label: 'Geladene Folgen', app: true, + value: f => Object.values(downloads).filter(d => d.state === 'done' && d.feedId === f.id).length}, + {id: 'none', label: 'Keine Zahl', value: () => null}, + ]; + let feedSort = 'latest'; + let feedCounter = 'new'; + try { + feedSort = localStorage.getItem('nui_pod_feedsort') || feedSort; + feedCounter = localStorage.getItem('nui_pod_counter') || feedCounter; + } catch (e) {} + + function counter() { + return COUNTERS.find(c => c.id === feedCounter && (!c.app || DL)) || COUNTERS[0]; + } + + function feedCount(f) { + const c = counter(); + return c.id === 'none' ? COUNTERS[1].value(f) : c.value(f); + } + + function feedSortMenu() { + const pick = (key, value) => { + if (key === 'sort') feedSort = value; else feedCounter = value; + try { localStorage.setItem(key === 'sort' ? 'nui_pod_feedsort' : 'nui_pod_counter', value); } catch (e) {} + render(); + }; + openSheet('Abos sortieren', FEED_SORTS.map(s => ({ + icon: s.id === feedSort ? '●' : '○', label: s.label, hint: s.hint || null, active: s.id === feedSort, + action: () => pick('sort', s.id), + })).concat([{icon: '#', label: 'Zahl am Podcast', hint: counter().label, action: () => openSheet('Zahl am Podcast', + COUNTERS.filter(c => !c.app || DL).map(c => ({ + icon: c.id === counter().id ? '●' : '○', label: c.label, hint: c.hint || null, active: c.id === counter().id, + action: () => pick('counter', c.id), + })))}])); + } + + function feedRow(f) { + const row = el('div', 'nui-pod-row'); + row.append(art(f.artwork_url)); + const main = el('div', 'nui-pod-main'); + main.append(el('div', 'nui-pod-title', plain(f.title))); + const meta = []; + if (f.author) meta.push(plain(f.author)); + if (feedSort === 'played' && f.last_played_at) meta.push('gehört ' + day(f.last_played_at)); + else if (f.latest_episode_at) meta.push('neueste ' + day(f.latest_episode_at)); + main.append(el('div', 'nui-pod-meta', meta.join(' · '))); + if (f.last_error) main.append(el('div', 'nui-pod-feederr', '⚠ lädt nicht: ' + f.last_error)); + row.append(main); + if (f.new_episodes === 'ignore') row.append(el('span', 'nui-pod-badge', 'still')); + else if (f.auto_queue || f.new_episodes === 'queue') row.append(el('span', 'nui-pod-badge', 'auto')); + const n = counter().value(f); + if (n) { + const c = el('span', 'nui-pod-count nui-pod-feedcount', String(n)); + c.title = `${n} ${counter().label.replace(' Folgen', '').toLowerCase()}`; + row.append(c); + } + row.addEventListener('click', () => openFeed(f)); + return row; + } + function renderFeeds() { - const items = feeds().slice().sort((a, b) => String(b.latest_episode_at || '').localeCompare(String(a.latest_episode_at || ''))); + const sort = FEED_SORTS.find(s => s.id === feedSort) || FEED_SORTS[0]; + const items = feeds().slice().sort((a, b) => sort.cmp(a, b) || byTitle(a, b)); if (!items.length) { empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.'); return; } + const head = el('div', 'nui-pod-head'); + head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`), + listTools([button('⇅', 'Sortieren und Zahl wählen', feedSortMenu)])); + root.append(head); const list = el('div', 'nui-pod-list'); - for (const f of items) { - const row = el('div', 'nui-pod-row'); - row.append(art(f.artwork_url)); - const main = el('div', 'nui-pod-main'); - main.append(el('div', 'nui-pod-title', plain(f.title))); - const meta = []; - if (f.author) meta.push(f.author); - if (f.latest_episode_at) meta.push('neueste ' + day(f.latest_episode_at)); - main.append(el('div', 'nui-pod-meta', meta.join(' · '))); - if (f.last_error) main.append(el('div', 'nui-pod-feederr', '⚠ lädt nicht: ' + f.last_error)); - row.append(main); - if (f.new_episodes === 'ignore') row.append(el('span', 'nui-pod-badge', 'still')); - else if (f.auto_queue || f.new_episodes === 'queue') row.append(el('span', 'nui-pod-badge', 'auto')); - row.addEventListener('click', () => openFeed(f)); - list.append(row); - } + for (const f of items) list.append(feedRow(f)); root.append(list); } async function openFeed(f) { feedView = {feed: f, episodes: null}; + selected = null; render(); scrollTop(0); try { @@ -735,11 +983,11 @@ function renderFeed() { const f = feedView.feed; const head = el('div', 'nui-pod-feedhead'); - head.append(button('‹', 'Zurück zu den Abos', () => { feedView = null; render(); }), art(f.artwork_url, 'big')); + head.append(button('‹', 'Zurück zu den Abos', () => { feedView = null; selected = null; render(); }), art(f.artwork_url, 'big')); const info = el('div', 'nui-pod-main'); info.append(el('div', 'nui-pod-title', f.title)); if (f.author) info.append(el('div', 'nui-pod-meta', f.author)); - head.append(info); + head.append(info, button('ⓘ', 'Über diesen Podcast', () => openFeedInfo(f), 'nui-pod-infobtn')); root.append(head); const live = feeds().find(x => x.id === f.id) || f; @@ -819,23 +1067,152 @@ function renderFeedEpisodes(f) { if (feedView.episodes == null) { empty('Folgen werden geladen …'); return; } if (feedView.error) { empty('Die Folgen lassen sich gerade nicht laden. Das braucht eine Verbindung.'); return; } - const list = el('div', 'nui-pod-list'); for (const o of feedView.episodes) { // Feed episodes don't name their podcast; queue entries need it. o.feed__id = f.id; o.feed__title = f.title; o.feed__artwork_url = f.artwork_url; - const e = ep(o); - const row = episodeRow(e, {showDate: true, noSelect: true}); - row.append( - inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') - : button('+', 'Einreihen', () => { enqueue(e); o.in_queue = true; }), - button('⋯', 'Mehr', () => episodeMenu(e))); + } + const prefs = feedListPrefs(f.id); + const filters = (prefs.filters || []).filter(id => FILTERS.some(x => x.id === id && !x.all && (!x.app || DL))); + const sort = EP_SORTS.some(s => s.id === prefs.sort && !s.all) ? prefs.sort : 'new'; + const all = feedView.episodes; + const items = sortEpisodes(all.filter(o => matchesAll(o, filters)), sort).map(ep); + if (selected) { + root.append(episodeSelection(items)); + } else { + const save = changes => { saveFeedListPrefs(f.id, Object.assign({filters, sort}, changes)); render(); }; + root.append(filterRow(filters, next => save({filters: next}), false)); + const head = el('div', 'nui-pod-head'); + head.append(el('div', 'nui-pod-summary', (items.length === all.length ? nEpisodes(all.length) + : `${items.length} von ${nEpisodes(all.length)}`) + ' · ' + sortLabel(sort).toLowerCase()), + listTools([ + button('⇅', 'Sortieren', () => sortMenuFor(sort, false, id => save({sort: id}))), + items.length > 0 && button('☐', 'Mehrere auswählen', () => startSelecting()), + ])); + root.append(head); + } + if (!items.length) { empty(all.length ? noneText(filters) : 'Dieser Podcast hat noch keine Folgen.'); return; } + const list = el('div', 'nui-pod-list'); + for (const e of items) { + const o = e.raw; + const row = episodeRow(e, {showDate: true}); + if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ ')); + if (!selected) { + row.append( + inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') + : button('+', 'Einreihen', () => { enqueue(e); o.in_queue = true; }), + button('⋯', 'Mehr', () => episodeMenu(e))); + } list.append(row); } root.append(list); } + // A podcast's list: its filters and order, remembered per podcast on this device. + function feedListPrefs(id) { + try { return JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}')[id] || {}; } catch (e) { return {}; } + } + + function saveFeedListPrefs(id, prefs) { + try { + const all = JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}'); + if (!prefs.filters.length && prefs.sort === 'new') delete all[id]; else all[id] = prefs; + localStorage.setItem('nui_pod_feedlists', JSON.stringify(all)); + } catch (e) {} + } + + // ---- a podcast's info page: description, website, feed address, numbers ---------------- + // In the shownotes' sheet (closeNotes closes it, also with the back button). + + function openFeedInfo(f) { + const live = Object.assign({}, f, feedView && feedView.feed.id === f.id ? feedView.feed : {}, + feeds().find(x => x.id === f.id) || {}); + closeNotes(); + notesSheet = el('div'); + notesSheet.id = 'nui-pod-player'; + notesSheet.className = 'nui-pod-notes-sheet nui-pod-info'; + const panel = el('div', 'nui-pod-player-panel'); + const top = el('div', 'nui-pod-player-top'); + top.append(button('⌄', 'Schließen', closeNotes)); + const head = el('div', 'nui-pod-feedhead'); + const title = el('div', 'nui-pod-main'); + title.append(el('div', 'nui-pod-title', plain(live.title))); + if (live.author) title.append(el('div', 'nui-pod-meta', plain(live.author))); + head.append(art(live.artwork_url, 'big'), title); + panel.append(top, head); + + // Feed descriptions may carry HTML: only its text. + const text = live.description ? (new DOMParser().parseFromString(live.description, 'text/html').body.textContent || '').trim() : ''; + if (text) panel.append(el('div', 'nui-pod-info-desc', text)); + else if (feedView && feedView.feed.id === f.id && feedView.episodes == null) { + panel.append(el('div', 'nui-pod-meta', 'Beschreibung wird geladen …')); + } + + const facts = el('dl', 'nui-pod-facts'); + const fact = (label, ...value) => { + if (!value.length || value[0] == null || value[0] === '') return; + const dd = el('dd'); + dd.append(...value.map(v => (typeof v === 'string' ? document.createTextNode(v) : v))); + facts.append(el('dt', null, label), dd); + }; + const when = iso => (iso ? day(iso) + ', ' + new Date(iso).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}) : null); + if (live.link && /^https?:\/\//i.test(live.link)) { + const a = el('a', null, live.link.replace(/^https?:\/\/(www\.)?/i, '').replace(/\/$/, '')); + a.href = live.link; + a.target = '_blank'; + a.rel = 'noopener'; + fact('Website', a); + } + if (live.rss_url) { + const row = el('div', 'nui-pod-info-actions'); + const action = (label, onClick) => { + const b = el('button', 'btn', label); + b.type = 'button'; + b.addEventListener('click', onClick); + row.append(b); + }; + action('Kopieren', () => copyText(live.rss_url)); + if (navigator.share) action('Teilen', () => navigator.share({title: plain(live.title), url: live.rss_url}).catch(() => {})); + fact('Feed', el('span', 'nui-pod-info-url', live.rss_url), row); + } + const count = live.episode_count != null ? live.episode_count : feedView && feedView.feed.id === f.id && feedView.episodes + ? feedView.episodes.length : null; + if (count != null) { + fact('Folgen', [String(count), live.unplayed_count != null && `${live.unplayed_count} ungehört`, + live.new_count && `${live.new_count} neu`].filter(Boolean).join(' · ')); + } + if (live.latest_episode_at) fact('Neueste Folge', day(live.latest_episode_at)); + if (live.last_played_at) fact('Zuletzt gehört', day(live.last_played_at)); + if (live.added_at) fact('Abonniert', new Date(live.added_at).toLocaleDateString('de-DE', {day: 'numeric', month: 'long', year: 'numeric'})); + fact('Abgerufen', when(live.last_refreshed_at) || 'noch nie'); + if (live.last_error) { + fact('Fehler', el('span', 'nui-pod-feederr', `⚠ ${live.last_error}${live.last_error_at ? ' (' + day(live.last_error_at) + ')' : ''}` + + (live.error_count > 1 ? ` · ${live.error_count}× nacheinander` : ''))); + } + panel.append(facts); + notesSheet.append(panel); + notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); }); + document.body.append(notesSheet); + } + + async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + toast('Kopiert'); + return; + } catch (e) {} + const t = el('textarea'); + t.value = text; + t.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; + document.body.append(t); + t.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) {} + t.remove(); + toast(ok ? 'Kopiert' : 'Kopieren ging nicht'); + } + function volumeLabel(v) { if (!v || v === 1) return ''; return {0.2: 'viel leiser', 0.5: 'leiser', 1.5: '+5 dB', 2: '+10 dB', 2.5: '+15 dB'}[v] || ''; @@ -936,77 +1313,109 @@ } // ---- all episodes, filtered (server: GET /podcasts/episodes/) ---------------------- + // Filters combine (all have to match); "Geladen" asks for the episodes downloaded here. - const FILTERS = [ - {id: 'unplayed', label: 'Ungehört'}, - {id: 'in_progress', label: 'Angefangen'}, - {id: 'favorites', label: 'Favoriten'}, - {id: 'history', label: 'Verlauf'}, - {id: 'played', label: 'Gehört'}, - {id: 'all', label: 'Alle'}, - ]; - let epFilter = 'unplayed'; - try { epFilter = localStorage.getItem('nui_pod_filter') || 'unplayed'; } catch (e) {} - let epList = null; // {filter, items, total, loading, error} + let epFilters = []; + let epSort = 'new'; + try { + const kept = localStorage.getItem('nui_pod_filters'); + if (kept != null) epFilters = JSON.parse(kept); + else { + // Before filters combined: one of them. + const old = localStorage.getItem('nui_pod_filter'); + epFilters = old == null ? ['unplayed'] : old === 'all' ? [] : [old]; + if (old === 'history') epSort = 'played'; + } + epSort = localStorage.getItem('nui_pod_epsort') || epSort; + } catch (e) {} + if (!Array.isArray(epFilters)) epFilters = []; + epFilters = epFilters.filter(id => FILTERS.some(f => f.id === id && (!f.app || !!N.podcastDownloads))); + if (!EP_SORTS.some(s => s.id === epSort)) epSort = 'new'; + let epList = null; // {key, items, total, loading, error} - async function loadEpisodes(filter, append) { - const offset = append && epList && epList.filter === filter ? epList.items.length : 0; - if (!append) epList = {filter, items: [], total: 0, loading: true}; - else epList.loading = true; - render(); + const epKey = () => epFilters.slice().sort().join(',') + '|' + epSort; + + function setEpisodeList(filters, sort) { + // The history goes by the last listening; leaving it, by date again. + if (sort == null) { + sort = epSort; + if (filters.includes('history') && !epFilters.includes('history') && sort === 'new') sort = 'played'; + if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'new'; + } + epFilters = filters; + epSort = sort; try { - const r = await fetch(`/podcasts/episodes/?filter=${filter}&limit=100&offset=${offset}`); + localStorage.setItem('nui_pod_filters', JSON.stringify(epFilters)); + localStorage.setItem('nui_pod_epsort', epSort); + } catch (e) {} + loadEpisodes(false); + } + + async function loadEpisodes(append, quiet) { + const key = epKey(); + const offset = append && epList && epList.key === key ? epList.items.length : 0; + if (!append) epList = {key, items: [], total: 0, loading: true}; + else epList.loading = true; + if (!quiet) render(); + const params = new URLSearchParams({ + filter: epFilters.filter(id => id !== 'downloaded').join(','), sort: epSort, limit: '100', offset: String(offset), + }); + if (epFilters.includes('downloaded')) { + const ids = Object.keys(downloads).filter(id => downloads[id].state === 'done'); + if (!ids.length) { epList = {key, items: [], total: 0}; render(); return; } + params.set('ids', ids.join(',')); + } + try { + const r = await fetch(`/podcasts/episodes/?${params}`); + if (r.status === 400) throw new Error('old'); if (!r.ok) throw new Error(r.status); const d = await r.json(); - if (!epList || epList.filter !== filter) return; - epList = {filter, items: (append ? epList.items : []).concat(d.episodes || []), total: d.total || 0}; + if (!epList || epList.key !== key) return; + epList = {key, items: (append ? epList.items : []).concat(d.episodes || []), total: d.total || 0}; } catch (e) { - if (!epList || epList.filter !== filter) return; + if (!epList || epList.key !== key) return; epList.loading = false; - epList.error = 'Die Folgenliste braucht eine Verbindung.'; + epList.error = e.message === 'old' ? 'Der Server kennt diese Filter noch nicht.' : 'Die Folgenliste braucht eine Verbindung.'; } render(); } function renderEpisodes() { - const chips = el('div', 'nui-pod-filters'); - for (const f of FILTERS) { - const b = el('button', 'nui-pod-chip' + (f.id === epFilter ? ' on' : ''), f.label); - b.type = 'button'; - b.addEventListener('click', () => { - epFilter = f.id; - try { localStorage.setItem('nui_pod_filter', epFilter); } catch (e) {} - loadEpisodes(epFilter, false); - }); - chips.append(b); - } - root.append(chips); - if (!epList || epList.filter !== epFilter) { loadEpisodes(epFilter, false); empty('Wird geladen …'); return; } + if (!selected) root.append(filterRow(epFilters, next => setEpisodeList(next), true)); + if (!epList || epList.key !== epKey()) { loadEpisodes(false, true); empty('Wird geladen …'); return; } if (epList.error && !epList.items.length) { empty(epList.error); return; } - if (!epList.items.length) { - empty(epList.loading ? 'Wird geladen …' : { - unplayed: 'Keine ungehörten Folgen.', - in_progress: 'Keine angefangenen Folgen.', - favorites: 'Noch keine Favoriten. Im ⋯-Menü einer Folge markierst du sie mit ★.', - history: 'Noch nichts gehört.', - played: 'Noch keine gehörten Folgen.', - all: 'Keine Folgen.', - }[epFilter]); + // What changed here since (marked, starred, queued) shows at once. + const items = epList.items.filter(o => matchesAll(o, epFilters)).map(ep); + const total = Math.max(items.length, epList.total - (epList.items.length - items.length)); + if (!items.length) { + empty(epList.loading ? 'Wird geladen …' : noneText(epFilters)); return; } - root.append(el('div', 'nui-pod-summary', `${epList.total} ${epList.total === 1 ? 'Folge' : 'Folgen'}`)); + if (selected) { + root.append(episodeSelection(items)); + } else { + const head = el('div', 'nui-pod-head'); + head.append(el('div', 'nui-pod-summary', `${nEpisodes(total)} · ${sortLabel(epSort).toLowerCase()}`), + listTools([ + button('⇅', 'Sortieren', () => sortMenuFor(epSort, true, id => setEpisodeList(epFilters, id))), + button('☐', 'Mehrere auswählen', () => startSelecting()), + ])); + root.append(head); + } const list = el('div', 'nui-pod-list'); - for (const o of epList.items) { - const e = ep(o); - const row = episodeRow(e, {showFeed: true, showDate: epFilter !== 'history', noSelect: true}); - if (epFilter === 'history' && o.last_played_at) { + for (const e of items) { + const o = e.raw; + const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played'}); + if (epSort === 'played' && o.last_played_at) { row.querySelector('.nui-pod-meta').textContent = 'gehört ' + day(o.last_played_at) + ' · ' + (e.feed || ''); } if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ ')); - row.append( - inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') - : button('+', 'Einreihen', () => enqueue(e)), - button('⋯', 'Mehr', () => episodeMenu(e))); + if (!selected) { + row.append( + inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') + : button('+', 'Einreihen', () => enqueue(e)), + button('⋯', 'Mehr', () => episodeMenu(e))); + } list.append(row); } root.append(list); @@ -1014,7 +1423,7 @@ const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden'); more.type = 'button'; more.disabled = !!epList.loading; - more.addEventListener('click', () => loadEpisodes(epFilter, true)); + more.addEventListener('click', () => loadEpisodes(true)); root.append(more); } } @@ -1043,11 +1452,12 @@ const cached = storedDetails(e.id); if (cached) keepDetails(e.id, Object.assign({}, cached, {favorite: d.favorite})); if (done) done(d.favorite); - if (view === 'episodes' && epList) { + if (epList) { const item = epList.items.find(x => x.id === e.id); if (item) item.favorite = d.favorite; - render(); } + // The ★ in the lists (Folgen, one podcast, search) and their filters. + render(); } catch (err) { toast(navigator.onLine ? 'Favorit ließ sich nicht setzen' : 'Favoriten brauchen eine Verbindung'); } @@ -1172,14 +1582,52 @@ input.click(); } + // ---- search: own podcasts, their episodes (also in the shownotes), the directory ---- + // searchResults: {q, feeds, eps: {items, total, loading, local}, dir: {items, loading, error}}. + // Without a connection the episodes come from what this device knows (queue, + // "Neu", lists seen), the podcasts from the kept state. + + let searchDraft = null; + + function fold(s) { + return plain(s || '').toLocaleLowerCase('de').normalize('NFD').replace(/[̀-ͯ]/g, ''); + } + + // All words of the query, anywhere in the text. + function matcher(q) { + const words = fold(q).split(/\s+/).filter(Boolean); + return text => words.every(w => text.includes(w)); + } + + function knownEpisodes() { + const seen = new Map(); + const add = o => { if (o && !seen.has(shape(o).id)) seen.set(shape(o).id, o); }; + queue().forEach(add); + inbox().forEach(add); + if (epList) epList.items.forEach(add); + if (feedView && feedView.episodes) feedView.episodes.forEach(add); + return [...seen.values()]; + } + + function searchText(o) { + const e = shape(o); + const notes = o.description ? o.description.replace(/<[^>]*>/g, ' ') : ''; + return fold(e.title + ' ' + notes); + } + + function searchNote(text) { + root.append(el('div', 'nui-pod-sec-note', text)); + } + function renderSearch() { const form = el('form', 'nui-pod-search'); const input = el('input', 'search-input'); input.type = 'search'; input.id = 'nui-pod-q'; - input.placeholder = 'Podcast suchen oder Feed-Adresse einfügen …'; + input.placeholder = 'Abos, Folgen, Verzeichnis – oder Feed-Adresse …'; input.setAttribute('enterkeyhint', 'search'); - input.value = searchResults ? searchResults.q : ''; + input.value = searchDraft != null ? searchDraft : searchResults ? searchResults.q : ''; + input.addEventListener('input', () => { searchDraft = input.value; }); const go = el('button', 'btn', 'Suchen'); go.type = 'submit'; form.append(input, go); @@ -1188,16 +1636,55 @@ search(input.value.trim()); }); root.append(form); - if (!searchResults) { - empty('Sucht im Podcast-Verzeichnis von Apple. Eine Feed-Adresse (https://…) abonnierst du direkt.'); + const s = searchResults; + if (!s) { + empty('Sucht in deinen Abos und ihren Folgen (auch in den Shownotes) und im Podcast-Verzeichnis von Apple. ' + + 'Eine Feed-Adresse (https://…) abonnierst du direkt.'); return; } - if (searchResults.loading) { empty('Suche läuft …'); return; } - if (searchResults.error) { empty(searchResults.error); return; } - if (!searchResults.items.length) { empty('Nichts gefunden.'); return; } + + if (s.feeds.length) { + root.append(el('div', 'nui-pod-sec', `Deine Podcasts (${s.feeds.length})`)); + const list = el('div', 'nui-pod-list'); + for (const f of s.feeds) list.append(feedRow(f)); + root.append(list); + } + + const eps = s.eps; + root.append(el('div', 'nui-pod-sec', 'Folgen' + (eps.items.length ? ` (${eps.total})` : ''))); + if (eps.local) searchNote('Ohne Verbindung: nur Folgen, die dieses Gerät kennt.'); + if (!eps.items.length) { + searchNote(eps.loading ? 'Wird gesucht …' : 'Keine Folgen gefunden.'); + } else { + const list = el('div', 'nui-pod-list'); + for (const o of eps.items) { + const e = ep(o); + const row = episodeRow(e, {showFeed: true, showDate: true, noSelect: true}); + if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ ')); + row.append( + inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') + : button('+', 'Einreihen', () => enqueue(e)), + button('⋯', 'Mehr', () => episodeMenu(e))); + list.append(row); + } + root.append(list); + if (!eps.local && eps.items.length < eps.total) { + const more = el('button', 'btn nui-pod-more', eps.loading ? 'Wird geladen …' : 'Weitere Folgen'); + more.type = 'button'; + more.disabled = !!eps.loading; + more.addEventListener('click', () => searchEpisodes(s, true)); + root.append(more); + } + } + + const dir = s.dir; + root.append(el('div', 'nui-pod-sec', 'Im Podcast-Verzeichnis')); + if (dir.loading) { searchNote('Wird gesucht …'); return; } + if (dir.error) { searchNote(dir.error); return; } + if (!dir.items.length) { searchNote('Nichts gefunden.'); return; } const subscribed = new Set(feeds().map(f => f.rss_url)); const list = el('div', 'nui-pod-list'); - for (const r of searchResults.items) { + for (const r of dir.items) { const row = el('div', 'nui-pod-row'); row.append(art(r.artwork_url)); const main = el('div', 'nui-pod-main'); @@ -1211,19 +1698,53 @@ root.append(list); } - async function search(q) { + function search(q) { if (!q) return; if (/^https?:\/\//i.test(q)) { subscribe(q, q); return; } - searchResults = {q, loading: true}; + searchDraft = null; + const test = matcher(q); + const s = searchResults = { + q, + feeds: feeds().filter(f => test(fold(f.title) + ' ' + fold(f.author))), + eps: {items: [], total: 0, loading: true}, + dir: {items: [], loading: true}, + }; render(); + searchEpisodes(s, false); + searchDirectory(s); + } + + async function searchEpisodes(s, more) { + const offset = more ? s.eps.items.length : 0; + s.eps.loading = true; + if (more) render(); try { - const r = await fetch('/podcasts/search/?q=' + encodeURIComponent(q)); - const data = await r.json(); - searchResults = {q, items: data.results || [], error: data.error ? 'Die Suche ist gerade nicht erreichbar.' : null}; + const r = await fetch(`/podcasts/episodes/?q=${encodeURIComponent(s.q)}¬es=1&limit=30&offset=${offset}`); + if (!r.ok) throw new Error(r.status); + const d = await r.json(); + s.eps = {items: (more ? s.eps.items : []).concat(d.episodes || []), total: d.total || 0}; } catch (e) { - searchResults = {q, items: [], error: 'Die Suche braucht eine Verbindung.'}; + if (more) { + s.eps.loading = false; + toast('Weitere Folgen brauchen eine Verbindung.'); + } else { + const test = matcher(s.q); + const items = knownEpisodes().filter(o => test(searchText(o))); + s.eps = {items, total: items.length, local: true}; + } } - render(); + if (searchResults === s) render(); + } + + async function searchDirectory(s) { + try { + const r = await fetch('/podcasts/search/?q=' + encodeURIComponent(s.q)); + const data = await r.json(); + s.dir = {items: data.results || [], error: data.error ? 'Das Verzeichnis ist gerade nicht erreichbar.' : null}; + } catch (e) { + s.dir = {items: [], error: 'Das Verzeichnis braucht eine Verbindung.'}; + } + if (searchResults === s) render(); } async function subscribe(rssUrl, title) { diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 76b2c1c..8e02e97 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -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. 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-seg { color: #444; } 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; }