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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v61'; const CACHE = 'diora-v62';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app.js', '/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 // 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. // and copy again. The browser host is static/js/podcasts-web.js.
@ -412,7 +412,7 @@
}), }),
); );
const acts = el('div', 'nui-pod-selactions'); 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); const b = el('button', 'btn btn-sm' + (a.danger ? ' btn-danger' : ''), a.label);
b.type = 'button'; b.type = 'button';
b.disabled = !selected.size; b.disabled = !selected.size;
@ -432,6 +432,179 @@
return t; 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) { function refresh(fetchFeeds) {
refreshing = true; refreshing = true;
render(); render();
@ -470,6 +643,9 @@
function render() { function render() {
if (!root) return; if (!root) return;
const scroll = scrollTop(); 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 = ''; root.textContent = '';
const bar = el('div', 'nui-tab-bar'); const bar = el('div', 'nui-tab-bar');
@ -512,6 +688,11 @@
else renderSearch(); else renderSearch();
} }
scrollTop(scroll); 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). // 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: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
{label: 'Ausblenden', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }}, {label: 'Ausblenden', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }},
DL && {label: 'Laden', run: ids => downloadMany(chosen(ids))},
])); ]));
} else { } else {
const head = el('div', 'nui-pod-head'); const head = el('div', 'nui-pod-head');
@ -685,34 +867,100 @@
root.append(list); 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() { 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) { if (!items.length) {
empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.'); empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
return; 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'); const list = el('div', 'nui-pod-list');
for (const f of items) { for (const f of items) list.append(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));
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);
}
root.append(list); root.append(list);
} }
async function openFeed(f) { async function openFeed(f) {
feedView = {feed: f, episodes: null}; feedView = {feed: f, episodes: null};
selected = null;
render(); render();
scrollTop(0); scrollTop(0);
try { try {
@ -735,11 +983,11 @@
function renderFeed() { function renderFeed() {
const f = feedView.feed; const f = feedView.feed;
const head = el('div', 'nui-pod-feedhead'); 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'); const info = el('div', 'nui-pod-main');
info.append(el('div', 'nui-pod-title', f.title)); info.append(el('div', 'nui-pod-title', f.title));
if (f.author) info.append(el('div', 'nui-pod-meta', f.author)); 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); root.append(head);
const live = feeds().find(x => x.id === f.id) || f; const live = feeds().find(x => x.id === f.id) || f;
@ -819,23 +1067,152 @@
function renderFeedEpisodes(f) { function renderFeedEpisodes(f) {
if (feedView.episodes == null) { empty('Folgen werden geladen …'); return; } 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; } 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) { for (const o of feedView.episodes) {
// Feed episodes don't name their podcast; queue entries need it. // Feed episodes don't name their podcast; queue entries need it.
o.feed__id = f.id; o.feed__id = f.id;
o.feed__title = f.title; o.feed__title = f.title;
o.feed__artwork_url = f.artwork_url; o.feed__artwork_url = f.artwork_url;
const e = ep(o); }
const row = episodeRow(e, {showDate: true, noSelect: true}); const prefs = feedListPrefs(f.id);
row.append( const filters = (prefs.filters || []).filter(id => FILTERS.some(x => x.id === id && !x.all && (!x.app || DL)));
inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') const sort = EP_SORTS.some(s => s.id === prefs.sort && !s.all) ? prefs.sort : 'new';
: button('+', 'Einreihen', () => { enqueue(e); o.in_queue = true; }), const all = feedView.episodes;
button('⋯', 'Mehr', () => episodeMenu(e))); 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); list.append(row);
} }
root.append(list); 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) { function volumeLabel(v) {
if (!v || v === 1) return ''; 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] || ''; 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/) ---------------------- // ---- all episodes, filtered (server: GET /podcasts/episodes/) ----------------------
// Filters combine (all have to match); "Geladen" asks for the episodes downloaded here.
const FILTERS = [ let epFilters = [];
{id: 'unplayed', label: 'Ungehört'}, let epSort = 'new';
{id: 'in_progress', label: 'Angefangen'}, try {
{id: 'favorites', label: 'Favoriten'}, const kept = localStorage.getItem('nui_pod_filters');
{id: 'history', label: 'Verlauf'}, if (kept != null) epFilters = JSON.parse(kept);
{id: 'played', label: 'Gehört'}, else {
{id: 'all', label: 'Alle'}, // Before filters combined: one of them.
]; const old = localStorage.getItem('nui_pod_filter');
let epFilter = 'unplayed'; epFilters = old == null ? ['unplayed'] : old === 'all' ? [] : [old];
try { epFilter = localStorage.getItem('nui_pod_filter') || 'unplayed'; } catch (e) {} if (old === 'history') epSort = 'played';
let epList = null; // {filter, items, total, loading, error} }
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 epKey = () => epFilters.slice().sort().join(',') + '|' + epSort;
const offset = append && epList && epList.filter === filter ? epList.items.length : 0;
if (!append) epList = {filter, items: [], total: 0, loading: true}; function setEpisodeList(filters, sort) {
else epList.loading = true; // The history goes by the last listening; leaving it, by date again.
render(); 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 { 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); if (!r.ok) throw new Error(r.status);
const d = await r.json(); const d = await r.json();
if (!epList || epList.filter !== filter) return; if (!epList || epList.key !== key) return;
epList = {filter, items: (append ? epList.items : []).concat(d.episodes || []), total: d.total || 0}; epList = {key, items: (append ? epList.items : []).concat(d.episodes || []), total: d.total || 0};
} catch (e) { } catch (e) {
if (!epList || epList.filter !== filter) return; if (!epList || epList.key !== key) return;
epList.loading = false; 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(); render();
} }
function renderEpisodes() { function renderEpisodes() {
const chips = el('div', 'nui-pod-filters'); if (!selected) root.append(filterRow(epFilters, next => setEpisodeList(next), true));
for (const f of FILTERS) { if (!epList || epList.key !== epKey()) { loadEpisodes(false, true); empty('Wird geladen …'); return; }
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 (epList.error && !epList.items.length) { empty(epList.error); return; } if (epList.error && !epList.items.length) { empty(epList.error); return; }
if (!epList.items.length) { // What changed here since (marked, starred, queued) shows at once.
empty(epList.loading ? 'Wird geladen …' : { const items = epList.items.filter(o => matchesAll(o, epFilters)).map(ep);
unplayed: 'Keine ungehörten Folgen.', const total = Math.max(items.length, epList.total - (epList.items.length - items.length));
in_progress: 'Keine angefangenen Folgen.', if (!items.length) {
favorites: 'Noch keine Favoriten. Im ⋯-Menü einer Folge markierst du sie mit ★.', empty(epList.loading ? 'Wird geladen …' : noneText(epFilters));
history: 'Noch nichts gehört.',
played: 'Noch keine gehörten Folgen.',
all: 'Keine Folgen.',
}[epFilter]);
return; 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'); const list = el('div', 'nui-pod-list');
for (const o of epList.items) { for (const e of items) {
const e = ep(o); const o = e.raw;
const row = episodeRow(e, {showFeed: true, showDate: epFilter !== 'history', noSelect: true}); const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played'});
if (epFilter === 'history' && o.last_played_at) { if (epSort === 'played' && o.last_played_at) {
row.querySelector('.nui-pod-meta').textContent = 'gehört ' + day(o.last_played_at) + ' · ' + (e.feed || ''); 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 (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
row.append( if (!selected) {
inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') row.append(
: button('+', 'Einreihen', () => enqueue(e)), inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
button('⋯', 'Mehr', () => episodeMenu(e))); : button('+', 'Einreihen', () => enqueue(e)),
button('⋯', 'Mehr', () => episodeMenu(e)));
}
list.append(row); list.append(row);
} }
root.append(list); root.append(list);
@ -1014,7 +1423,7 @@
const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden'); const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden');
more.type = 'button'; more.type = 'button';
more.disabled = !!epList.loading; more.disabled = !!epList.loading;
more.addEventListener('click', () => loadEpisodes(epFilter, true)); more.addEventListener('click', () => loadEpisodes(true));
root.append(more); root.append(more);
} }
} }
@ -1043,11 +1452,12 @@
const cached = storedDetails(e.id); const cached = storedDetails(e.id);
if (cached) keepDetails(e.id, Object.assign({}, cached, {favorite: d.favorite})); if (cached) keepDetails(e.id, Object.assign({}, cached, {favorite: d.favorite}));
if (done) done(d.favorite); if (done) done(d.favorite);
if (view === 'episodes' && epList) { if (epList) {
const item = epList.items.find(x => x.id === e.id); const item = epList.items.find(x => x.id === e.id);
if (item) item.favorite = d.favorite; if (item) item.favorite = d.favorite;
render();
} }
// The ★ in the lists (Folgen, one podcast, search) and their filters.
render();
} catch (err) { } catch (err) {
toast(navigator.onLine ? 'Favorit ließ sich nicht setzen' : 'Favoriten brauchen eine Verbindung'); toast(navigator.onLine ? 'Favorit ließ sich nicht setzen' : 'Favoriten brauchen eine Verbindung');
} }
@ -1172,14 +1582,52 @@
input.click(); 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() { function renderSearch() {
const form = el('form', 'nui-pod-search'); const form = el('form', 'nui-pod-search');
const input = el('input', 'search-input'); const input = el('input', 'search-input');
input.type = 'search'; input.type = 'search';
input.id = 'nui-pod-q'; 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.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'); const go = el('button', 'btn', 'Suchen');
go.type = 'submit'; go.type = 'submit';
form.append(input, go); form.append(input, go);
@ -1188,16 +1636,55 @@
search(input.value.trim()); search(input.value.trim());
}); });
root.append(form); root.append(form);
if (!searchResults) { const s = searchResults;
empty('Sucht im Podcast-Verzeichnis von Apple. Eine Feed-Adresse (https://…) abonnierst du direkt.'); 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; return;
} }
if (searchResults.loading) { empty('Suche läuft …'); return; }
if (searchResults.error) { empty(searchResults.error); return; } if (s.feeds.length) {
if (!searchResults.items.length) { empty('Nichts gefunden.'); return; } 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 subscribed = new Set(feeds().map(f => f.rss_url));
const list = el('div', 'nui-pod-list'); 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'); const row = el('div', 'nui-pod-row');
row.append(art(r.artwork_url)); row.append(art(r.artwork_url));
const main = el('div', 'nui-pod-main'); const main = el('div', 'nui-pod-main');
@ -1211,19 +1698,53 @@
root.append(list); root.append(list);
} }
async function search(q) { function search(q) {
if (!q) return; if (!q) return;
if (/^https?:\/\//i.test(q)) { subscribe(q, 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(); 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 { try {
const r = await fetch('/podcasts/search/?q=' + encodeURIComponent(q)); const r = await fetch(`/podcasts/episodes/?q=${encodeURIComponent(s.q)}&notes=1&limit=30&offset=${offset}`);
const data = await r.json(); if (!r.ok) throw new Error(r.status);
searchResults = {q, items: data.results || [], error: data.error ? 'Die Suche ist gerade nicht erreichbar.' : null}; const d = await r.json();
s.eps = {items: (more ? s.eps.items : []).concat(d.episodes || []), total: d.total || 0};
} catch (e) { } 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) { 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. (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */ The web's own additions are in podcasts-web.css. */
@ -572,3 +572,20 @@ body.eink .nui-pod-feederr-box { border-color: #000; }
body.eink .nui-pod-offer { border-color: #000; } body.eink .nui-pod-offer { border-color: #000; }
body.eink .nui-pod-seg { color: #444; } body.eink .nui-pod-seg { color: #444; }
body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; } body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; }
/* lists: search sections, the number at a podcast, its info page */
.nui-pod-sec { margin-top: 14px; padding: 4px 2px; font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--nui-muted); border-bottom: 1px solid var(--border); }
.nui-pod-search + .nui-pod-sec { margin-top: 4px; }
.nui-pod-sec-note { padding: 10px 2px; font-size: 13px; color: var(--nui-muted); }
.nui-pod-feedcount { flex: none; }
.nui-pod-infobtn { flex: none; font-size: 20px; color: var(--nui-muted); }
.nui-pod-info-desc { font-size: 14px; font-weight: 400; line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; padding: 4px 0 10px; border-bottom: 1px solid var(--border); }
.nui-pod-facts { font-weight: 400; display: grid; grid-template-columns: max-content 1fr; gap: 8px 14px; margin: 10px 0 4px; font-size: 14px; }
.nui-pod-facts dt { color: var(--nui-muted); }
.nui-pod-facts dd { margin: 0; min-width: 0; font-weight: 400; overflow-wrap: anywhere; }
.nui-pod-facts a { color: var(--accent); }
.nui-pod-info-url { display: block; font-size: 12px; color: var(--nui-muted); }
.nui-pod-info-actions { display: flex; gap: 8px; margin-top: 6px; }
.nui-pod-info-actions .btn { min-height: 36px; padding: 0 14px; font-size: 14px; }
body.eink .nui-pod-sec { color: #000; border-bottom-color: #000; }
body.eink .nui-pod-facts a { color: #000; text-decoration: underline; }