Podcast-Listen: Zähler pro Abo, kombinierbare Folgen-Filter, Suche in Shownotes, mehrere Favoriten; Oberfläche aus diora-android, sw v62

- feeds/ und state/: pro Abo episode_count, unplayed_count, new_count (wie
  „Neu“), last_played_at und link – zwei Abfragen, egal wie viele Abos.
- feeds/<id>/episodes/: favorite pro Folge; description, link, rss_url und
  last_refreshed_at des Podcasts für die Infoseite.
- episodes/: filter mit Komma kombinierbar (alle müssen passen), neuer Filter
  queued, ids= (die auf einem Gerät geladenen), sort= (new, old, long, short,
  title, played), notes=1 sucht q auch in den Shownotes (Titeltreffer zuerst).
- POST episodes/favorite/: mehrere Folgen auf einmal (ent)favorisieren.
- Tests dafür; Podcast-Oberfläche aus diora-android übernommen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-02 16:23:00 +02:00
parent 779b54623e
commit 69b22cf2e3
6 changed files with 939 additions and 118 deletions

View file

@ -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='<p>Heute mit dem Gast Bonusmaterial</p>')
# By title only unless asked; with notes=1 title matches first.
self.assertEqual(self.ids(q='bonus'), [self.b1.id])
self.assertEqual(self.ids(q='bonus', notes='1'), [self.b1.id, self.a1.id])
self.assertEqual(self.ids(q='gast', notes='1'), [self.a1.id])
self.assertEqual(self.ids(q='gast', notes='1', filter='unplayed'), [])
self.assertEqual(self.get(q='bonus', notes='1')['total'], 2)
# The notes never come along in the list.
self.assertNotIn('description', self.get(q='gast', notes='1')['episodes'][0])
def test_combined_query_count(self):
from .views import _episode_list_data
with self.assertNumQueries(2):
episodes, total = _episode_list_data(self.user, ['unplayed', 'queued'], q='a', notes=True,
sort='short', ids=[self.a2.id, self.a3.id])
self.assertEqual(([e['id'] for e in episodes], total), ([self.a2.id], 1))
def test_login_required(self):
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/<id>/episodes/)."""
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha',
link='https://example.org/', description='Ein Podcast über A.')
self.fb = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta')
self.empty = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/c', title='Leer')
eps = [PodcastEpisode.objects.create(feed=self.fa, guid=f'a{i}', title=f'A{i}', pub_date=_at(1, i + 1),
audio_url=f'https://example.org/a{i}.mp3') for i in range(5)]
self.b1 = PodcastEpisode.objects.create(feed=self.fb, guid='b1', title='B1', audio_url='https://example.org/b1.mp3')
_progress(self.user, eps[0], _at(2, 1), played=True)
_progress(self.user, eps[1], _at(2, 3), position_seconds=40)
_progress(self.user, eps[2], _at(2, 5), dismissed=True)
_progress(self.user, eps[3], _at(2, 9), favorite=True) # a star isn't listening
self.eps = eps
# Bob's progress on alice's episode (can't happen) and his own feed don't count.
bob = User.objects.create_user(username='bob', password='pw12345678')
_progress(bob, eps[4], _at(3, 1), played=True)
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='Alpha')
xb = PodcastEpisode.objects.create(feed=fbob, guid='x', title='X', audio_url='https://example.org/x.mp3')
_progress(bob, xb, _at(3, 1), played=True)
def test_counts_in_feeds_and_state(self):
for url in ('/podcasts/feeds/', '/podcasts/state/'):
with self.subTest(url=url):
feeds = {f['id']: f for f in self.client.get(url).json()['feeds']}
a, b, c = feeds[self.fa.id], feeds[self.fb.id], feeds[self.empty.id]
self.assertEqual((a['episode_count'], a['unplayed_count'], a['new_count']), (5, 4, 3))
self.assertEqual(a['last_played_at'], '2026-02-03T09:00:00+00:00')
self.assertEqual(a['link'], 'https://example.org/')
self.assertEqual((b['episode_count'], b['unplayed_count'], b['new_count'], b['last_played_at']),
(1, 1, 1, None))
self.assertEqual((c['episode_count'], c['unplayed_count'], c['new_count'], c['last_played_at']),
(0, 0, 0, None))
# The shownotes-sized description stays out of the state.
self.assertNotIn('description', a)
def test_new_count_matches_the_inbox(self):
inbox = self.client.get('/podcasts/state/').json()['inbox']
feeds = {f['id']: f for f in self.client.get('/podcasts/state/').json()['feeds']}
for feed in (self.fa, self.fb):
self.assertEqual(feeds[feed.id]['new_count'], sum(1 for e in inbox if e['feed__id'] == feed.id))
def test_two_queries_however_many_feeds(self):
from .views import _feeds_data
for i in range(10):
f = PodcastFeed.objects.create(user=self.user, rss_url=f'https://example.org/n{i}', title=f'N{i}')
e = PodcastEpisode.objects.create(feed=f, guid='e', title='E', audio_url='https://example.org/e.mp3')
_progress(self.user, e, _at(2, 2), played=True)
with self.assertNumQueries(2):
feeds = _feeds_data(self.user)
self.assertEqual(len(feeds), 13)
self.assertEqual({f['unplayed_count'] for f in feeds if f['title'].startswith('N')}, {0})
def test_feed_page_has_favorites_and_info(self):
data = self.client.get(f'/podcasts/feeds/{self.fa.id}/episodes/').json()
favs = {e['id']: e['favorite'] for e in data['episodes']}
self.assertEqual(favs[self.eps[3].id], True)
self.assertEqual(favs[self.eps[0].id], False)
self.assertEqual(favs[self.eps[4].id], False) # bob's row isn't alice's
self.assertEqual({k: data['feed'][k] for k in ('description', 'link', 'rss_url', 'last_refreshed_at')}, {
'description': 'Ein Podcast über A.', 'link': 'https://example.org/', 'rss_url': 'https://example.org/a',
'last_refreshed_at': None,
})
class BulkFavoriteTests(TestCase):
"""POST /podcasts/episodes/favorite/: star or unstar several at once."""
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed', title='Feed')
self.a, self.b, self.c = (
PodcastEpisode.objects.create(feed=feed, guid=g, title=g, audio_url=f'https://example.org/{g}.mp3')
for g in 'abc'
)
bob = User.objects.create_user(username='bob', password='pw12345678')
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/feed', title='Feed')
self.bobs = PodcastEpisode.objects.create(feed=fbob, guid='x', title='X', audio_url='https://example.org/x.mp3')
def post(self, body):
return self.client.post('/podcasts/episodes/favorite/', json.dumps(body), content_type='application/json')
def test_star_and_unstar(self):
p = _progress(self.user, self.a, _at(2, 1), position_seconds=30)
r = self.post({'episode_ids': [self.a.id, self.b.id, self.bobs.id, 999999], 'favorite': True})
self.assertEqual(r.json(), {'ok': True, 'favorite': True, 'count': 2})
self.assertEqual(set(EpisodeProgress.objects.filter(user=self.user, favorite=True)
.values_list('episode_id', flat=True)), {self.a.id, self.b.id})
# The listening time of an existing row stays (history, statistics).
p.refresh_from_db()
self.assertEqual((p.position_seconds, p.updated_at), (30, _at(2, 1)))
self.assertFalse(EpisodeProgress.objects.filter(episode=self.bobs).exists())
# Again: nothing doubles.
self.post({'episode_ids': [self.a.id, self.b.id], 'favorite': True})
self.assertEqual(EpisodeProgress.objects.filter(user=self.user).count(), 2)
r = self.post({'episode_ids': [self.a.id, self.b.id, self.c.id], 'favorite': False})
self.assertEqual(r.json(), {'ok': True, 'favorite': False, 'count': 3})
self.assertFalse(EpisodeProgress.objects.filter(favorite=True).exists())
# Unstarring doesn't make rows for episodes that had none.
self.assertFalse(EpisodeProgress.objects.filter(episode=self.c).exists())
# The list shows it.
self.post({'episode_ids': [self.c.id], 'favorite': True})
favs = self.client.get('/podcasts/episodes/', {'filter': 'favorites'}).json()['episodes']
self.assertEqual([e['id'] for e in favs], [self.c.id])
def test_bad_requests(self):
for body in ({}, {'episode_ids': [self.a.id]}, {'episode_ids': [], 'favorite': True},
{'episode_ids': self.a.id, 'favorite': True}, {'episode_ids': ['x'], 'favorite': True},
{'episode_ids': [self.a.id], 'favorite': 'yes'}, [1, 2]):
with self.subTest(body=body):
self.assertEqual(self.post(body).status_code, 400)
self.assertEqual(self.client.get('/podcasts/episodes/favorite/').status_code, 405)
self.client.logout()
self.assertEqual(self.post({'episode_ids': [self.a.id], 'favorite': True}).status_code, 401)

View file

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

View file

@ -16,7 +16,7 @@ from django.db import IntegrityError, transaction
from django.db.models import (
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/<id>/settings/
mine = listened.get(f['id'], {})
f['unplayed_count'] = max(f['episode_count'] - mine.get('played_count', 0), 0)
f['new_count'] = max(f['episode_count'] - mine.get('hidden_count', 0), 0)
last = mine.get('last_played_at')
f['last_played_at'] = last.isoformat() if last else None
if f['last_refreshed_at']:
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})

View file

@ -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',

View file

@ -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);
}
function renderFeeds() {
const items = feeds().slice().sort((a, b) => String(b.latest_episode_at || '').localeCompare(String(a.latest_episode_at || '')));
if (!items.length) {
empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
return;
// 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];
}
const list = el('div', 'nui-pod-list');
for (const f of items) {
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(f.author);
if (f.latest_episode_at) meta.push('neueste ' + day(f.latest_episode_at));
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'));
row.addEventListener('click', () => openFeed(f));
list.append(row);
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 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) 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});
}
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}
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();
let epFilters = [];
let epSort = 'new';
try {
const r = await fetch(`/podcasts/episodes/?filter=${filter}&limit=100&offset=${offset}`);
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}
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 {
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', '★ '));
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();
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};
} catch (e) {
searchResults = {q, items: [], error: 'Die Suche braucht eine Verbindung.'};
searchEpisodes(s, false);
searchDirectory(s);
}
render();
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/episodes/?q=${encodeURIComponent(s.q)}&notes=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) {
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};
}
}
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) {

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 57fc424 2026-10-02: the podcast parts of web-native/static/native.css
/* Copied from diora-android 99528f2 2026-10-02: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
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; }