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; }