Compare commits

..

6 commits

Author SHA1 Message Date
3577fca9ba Merge branch 'wt-notify'
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 6m28s
# Conflicts:
#	static/js/sw.js
#	static/podcasts/native-podcast.js
2026-10-02 18:03:07 +02:00
da60198bbb Podcast-Oberfläche aus diora-android (Benachrichtigen), sw v61
Der Chip "Benachrichtigen" auf der Podcast-Seite setzt notify auch im
Browser (die Mitteilungen kommen von den Telefonen). Die Einstellung
"Im Hintergrund abgleichen" gibt es nur in der App.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:23:00 +02:00
371c9b9222 Podcast-Einstellung "notify": über neue Folgen benachrichtigen
PodcastFeed.notify (Standard aus) wie auto_download: in feeds/ und
state/, änderbar über feeds/<id>/settings/ (nur true/false). Die Geräte
melden damit neue Folgen dieses Podcasts. Der AntennaPod-Import übernimmt
Feeds.episode_notification (an -> notify an; aus, AntennaPods Standard,
lässt dioras Einstellung stehen; ältere Backups ohne die Spalte: aus).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:45:51 +02:00
779b54623e Web: neue Version lädt erst, wenn nichts spielt; kurze Gerätenamen, sw v61
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 5m57s
Bisher lud die Seite bei einer neuen Version sofort neu – auch mitten im
Radio oder Podcast. Jetzt erst, wenn nichts spielt, kein Buch offen ist
und kein Textfeld den Fokus hat. Der Browser heißt im Player-Sync kurz
„Chrome · Windows“.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:37:38 +02:00
12 changed files with 1227 additions and 133 deletions

View file

@ -43,7 +43,7 @@ SUBSCRIBED = 0 # Feeds.state
MAX_FILESIZE = 4_000_000_000 MAX_FILESIZE = 4_000_000_000
SETTING_FIELDS = ('playback_speed', 'skip_intro', 'skip_outro', 'volume', 'include_words', SETTING_FIELDS = ('playback_speed', 'skip_intro', 'skip_outro', 'volume', 'include_words',
'exclude_words', 'min_duration', 'new_episodes', 'auto_download') 'exclude_words', 'min_duration', 'new_episodes', 'auto_download', 'notify')
# Columns read from the backup, with the value to assume if an older # Columns read from the backup, with the value to assume if an older
# AntennaPod didn't have the column yet. # AntennaPod didn't have the column yet.
@ -52,7 +52,7 @@ FEED_COLUMNS = {
'image_url': '', 'keep_updated': 1, 'include_filter': '', 'exclude_filter': '', 'image_url': '', 'keep_updated': 1, 'include_filter': '', 'exclude_filter': '',
'minimal_duration_filter': -1, 'new_episodes_action': 0, 'feed_playback_speed': -1, 'minimal_duration_filter': -1, 'new_episodes_action': 0, 'feed_playback_speed': -1,
'feed_skip_intro': 0, 'feed_skip_ending': 0, 'feed_volume_adaption': 0, 'auto_download': 1, 'feed_skip_intro': 0, 'feed_skip_ending': 0, 'feed_volume_adaption': 0, 'auto_download': 1,
'state': SUBSCRIBED, 'episode_notification': 0, 'state': SUBSCRIBED,
} }
@ -191,6 +191,10 @@ def feed_settings(ap, global_auto_download=False):
s['auto_download'] = bool(global_auto_download) s['auto_download'] = bool(global_auto_download)
elif code == AUTO_DOWNLOAD_OFF: elif code == AUTO_DOWNLOAD_OFF:
s['auto_download'] = False s['auto_download'] = False
# Feeds.episode_notification: 1 = a notification for each new episode
# (0, AntennaPod's default, leaves diora's setting alone).
if ap['episode_notification']:
s['notify'] = True
return s, not_kept_updated return s, not_kept_updated
@ -769,7 +773,7 @@ class AntennaPodImport:
SETTING_LABELS = { SETTING_LABELS = {
'playback_speed': 'speed', 'skip_intro': 'skip intro', 'skip_outro': 'skip outro', 'volume': 'volume', 'playback_speed': 'speed', 'skip_intro': 'skip intro', 'skip_outro': 'skip outro', 'volume': 'volume',
'include_words': 'include words', 'exclude_words': 'exclude words', 'min_duration': 'min duration', 'include_words': 'include words', 'exclude_words': 'exclude words', 'min_duration': 'min duration',
'new_episodes': 'new episodes', 'auto_download': 'auto-download', 'new_episodes': 'new episodes', 'auto_download': 'auto-download', 'notify': 'notifications',
} }

View file

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0012_player_state'),
]
operations = [
migrations.AddField(
model_name='podcastfeed',
name='notify',
field=models.BooleanField(default=False),
),
]

View file

@ -51,6 +51,9 @@ class PodcastFeed(models.Model):
# Devices download this feed's new episodes (those in the inbox) on # Devices download this feed's new episodes (those in the inbox) on
# their own, for listening offline. # their own, for listening offline.
auto_download = models.BooleanField(default=False) auto_download = models.BooleanField(default=False)
# Devices tell about this feed's new episodes (a notification, like
# AntennaPod's per-podcast "episode notifications").
notify = models.BooleanField(default=False)
class Meta: class Meta:
unique_together = ('user', 'rss_url') unique_together = ('user', 'rss_url')

View file

@ -31,7 +31,8 @@ class Backup:
minimal_duration_filter INTEGER DEFAULT -1, new_episodes_action INTEGER DEFAULT 0, minimal_duration_filter INTEGER DEFAULT -1, new_episodes_action INTEGER DEFAULT 0,
feed_playback_speed REAL DEFAULT -1, feed_skip_intro INTEGER DEFAULT 0, feed_playback_speed REAL DEFAULT -1, feed_skip_intro INTEGER DEFAULT 0,
feed_skip_ending INTEGER DEFAULT 0, feed_volume_adaption INTEGER DEFAULT 0, feed_skip_ending INTEGER DEFAULT 0, feed_volume_adaption INTEGER DEFAULT 0,
auto_download INTEGER DEFAULT 0, state INTEGER DEFAULT 0, tags TEXT); auto_download INTEGER DEFAULT 0, episode_notification INTEGER DEFAULT 0, state INTEGER DEFAULT 0,
tags TEXT);
CREATE TABLE FeedItems (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, pubDate INTEGER, CREATE TABLE FeedItems (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, pubDate INTEGER,
read INTEGER, link TEXT, description TEXT, media INTEGER, feed INTEGER, read INTEGER, link TEXT, description TEXT, media INTEGER, feed INTEGER,
item_identifier TEXT, image_url TEXT); item_identifier TEXT, image_url TEXT);
@ -253,6 +254,26 @@ class FeedImportTests(ImportTestCase):
self.assertIn('default for new subscriptions: on\n', self.run_import('--global-auto-download', 'on')) self.assertIn('default for new subscriptions: on\n', self.run_import('--global-auto-download', 'on'))
self.assertIn('default for new subscriptions: on\n', self.run_import()) self.assertIn('default for new subscriptions: on\n', self.run_import())
def test_episode_notifications(self):
self.diora_feed('https://example.org/diora-on', notify=True)
self.diora_feed('https://example.org/on-both', notify=True)
self.ap.feed('https://example.org/diora-on', episode_notification=0)
self.ap.feed('https://example.org/on-both', episode_notification=1)
self.ap.feed('https://example.org/on', episode_notification=1)
self.ap.feed('https://example.org/off')
out = self.run_import()
values = dict(PodcastFeed.objects.filter(user=self.user).values_list('rss_url', 'notify'))
# AntennaPod's "off" is its default: diora's own setting stays.
self.assertEqual(values, {'https://example.org/diora-on': True, 'https://example.org/on-both': True,
'https://example.org/on': True, 'https://example.org/off': False})
self.assertIn('notifications 1', out)
def test_backup_without_episode_notification_column(self):
self.ap.db.execute('ALTER TABLE Feeds DROP COLUMN episode_notification')
self.ap.feed('https://example.org/old')
self.run_import()
self.assertFalse(PodcastFeed.objects.get(user=self.user, rss_url='https://example.org/old').notify)
def test_created_feeds_start_with_the_users_default(self): def test_created_feeds_start_with_the_users_default(self):
PodcastSettings.objects.create(user=self.user, auto_download_default=True) PodcastSettings.objects.create(user=self.user, auto_download_default=True)
self.ap.feed('https://example.org/explicit-off', auto_download=0) self.ap.feed('https://example.org/explicit-off', auto_download=0)

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()
@ -972,6 +1031,7 @@ class OpmlExportTests(TestCase):
FEED_DEFAULTS = { FEED_DEFAULTS = {
'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox', 'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox',
'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False, 'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False,
'notify': False,
} }
@ -997,7 +1057,7 @@ class FeedSettingsTests(TestCase):
self.assertEqual(r.json(), { self.assertEqual(r.json(), {
'feed_id': self.feed.id, 'playback_speed': 1.5, 'skip_intro': 30, 'skip_outro': 600, 'volume': 2.0, 'feed_id': self.feed.id, 'playback_speed': 1.5, 'skip_intro': 30, 'skip_outro': 600, 'volume': 2.0,
'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10, 'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10,
'auto_queue': False, 'auto_download': False, 'auto_queue': False, 'auto_download': False, 'notify': False,
}) })
self.feed.refresh_from_db() self.feed.refresh_from_db()
self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration), self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration),
@ -1023,6 +1083,7 @@ class FeedSettingsTests(TestCase):
('include_words', 'x' * 501), ('include_words', 5), ('exclude_words', ['a']), ('include_words', 'x' * 501), ('include_words', 5), ('exclude_words', ['a']),
('min_duration', -1), ('min_duration', 601), ('min_duration', -1), ('min_duration', 601),
('auto_download', 1), ('auto_download', 'true'), ('auto_download', None), ('auto_download', 1), ('auto_download', 'true'), ('auto_download', None),
('notify', 1), ('notify', 'false'), ('notify', None),
] ]
for name, value in bad: for name, value in bad:
with self.subTest(name=name, value=value): with self.subTest(name=name, value=value):
@ -1057,6 +1118,17 @@ class FeedSettingsTests(TestCase):
self.feed.refresh_from_db() self.feed.refresh_from_db()
self.assertFalse(self.feed.auto_download) self.assertFalse(self.feed.auto_download)
def test_notify_on_and_off(self):
self.assertIs(self.post({'notify': True}).json()['notify'], True)
self.feed.refresh_from_db()
self.assertTrue(self.feed.notify)
# Other settings (auto_download too) leave it alone.
self.assertIs(self.post({'auto_download': True}).json()['notify'], True)
self.assertIs(self.post({'notify': False}).json()['notify'], False)
self.feed.refresh_from_db()
self.assertFalse(self.feed.notify)
self.assertTrue(self.feed.auto_download)
def test_new_episodes_and_auto_queue_stay_in_step(self): def test_new_episodes_and_auto_queue_stay_in_step(self):
self.assertEqual(self.post({'new_episodes': 'queue'}).json()['auto_queue'], True) self.assertEqual(self.post({'new_episodes': 'queue'}).json()['auto_queue'], True)
self.feed.refresh_from_db() self.feed.refresh_from_db()
@ -1085,12 +1157,12 @@ class FeedSettingsTests(TestCase):
def test_feeds_and_state_carry_the_settings(self): def test_feeds_and_state_carry_the_settings(self):
self.post({'playback_speed': 1.25, 'skip_outro': 20, 'volume': 0.5, 'new_episodes': 'queue', self.post({'playback_speed': 1.25, 'skip_outro': 20, 'volume': 0.5, 'new_episodes': 'queue',
'exclude_words': 'Trailer', 'min_duration': 5, 'auto_download': True}) 'exclude_words': 'Trailer', 'min_duration': 5, 'auto_download': True, 'notify': True})
PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/2', title='Zweiter') PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/2', title='Zweiter')
expected = { expected = {
self.feed.id: {'feed_id': self.feed.id, **FEED_DEFAULTS, 'playback_speed': 1.25, 'skip_outro': 20, self.feed.id: {'feed_id': self.feed.id, **FEED_DEFAULTS, 'playback_speed': 1.25, 'skip_outro': 20,
'volume': 0.5, 'new_episodes': 'queue', 'exclude_words': 'Trailer', 'min_duration': 5, 'volume': 0.5, 'new_episodes': 'queue', 'exclude_words': 'Trailer', 'min_duration': 5,
'auto_queue': True, 'auto_download': True}, 'auto_queue': True, 'auto_download': True, 'notify': True},
} }
for url in ('/podcasts/feeds/', '/podcasts/state/'): for url in ('/podcasts/feeds/', '/podcasts/state/'):
with self.subTest(url=url): with self.subTest(url=url):
@ -2190,3 +2262,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
@ -725,6 +725,7 @@ def feed_list(request):
FEED_SETTING_FIELDS = ( FEED_SETTING_FIELDS = (
'playback_speed', 'skip_intro', 'skip_outro', 'volume', 'playback_speed', 'skip_intro', 'skip_outro', 'volume',
'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download', 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download',
'notify',
) )
@ -733,15 +734,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']:
@ -896,6 +919,7 @@ FEED_SETTING_PARSERS = {
'exclude_words': _word_list, 'exclude_words': _word_list,
'min_duration': lambda v: _whole(v, 0, 600), 'min_duration': lambda v: _whole(v, 0, 600),
'auto_download': _boolean, 'auto_download': _boolean,
'notify': _boolean,
} }
@ -958,7 +982,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 +999,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 +1009,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 +1184,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 +1232,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 +1259,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 +1282,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 +1314,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

@ -1352,10 +1352,26 @@ if ('serviceWorker' in navigator) {
}); });
}); });
// When a new SW activates it sends SW_ACTIVATED — reload to get fresh assets, // When a new SW activates it sends SW_ACTIVATED — reload to get fresh assets,
// but only if the reader isn't open (would interrupt reading). // but never in the middle of something: not while a book is open, radio or a
// podcast plays, or a text field has the focus — then as soon as that's over.
let reloadPending = false;
const busy = () => {
if (currentBookId || isPlaying) return true;
const host = window.DioraPodcastHost;
if (host && host.playerState) {
try { if (JSON.parse(host.playerState()).playing) return true; } catch (_) {}
}
const el = document.activeElement;
return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
};
const reloadWhenIdle = () => {
if (busy()) setTimeout(reloadWhenIdle, 5000);
else window.location.reload();
};
navigator.serviceWorker.addEventListener('message', e => { navigator.serviceWorker.addEventListener('message', e => {
if (e.data?.type === 'SW_ACTIVATED' && !currentBookId) { if (e.data?.type === 'SW_ACTIVATED' && !reloadPending) {
window.location.reload(); reloadPending = true;
reloadWhenIdle();
} }
}); });
} }

View file

@ -937,11 +937,12 @@
: /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Web'; : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Web';
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows' const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows'
: /Macintosh|Mac OS X/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : ''; : /Macintosh|Mac OS X/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : '';
return os ? browser + '/' + os : browser; return os ? browser + ' · ' + os : browser;
} }
const DEVICE_ID = deviceId(); const DEVICE_ID = deviceId();
const DEVICE_NAME = 'Browser (' + browserName() + ')'; // Short, it stands in the bar ("läuft auf Chrome · Windows").
const DEVICE_NAME = browserName();
const sync = { const sync = {
state: null, // the server's state, as last taken in state: null, // the server's state, as last taken in

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-v60'; const CACHE = 'diora-v64';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app.js', '/static/js/app.js',

File diff suppressed because it is too large Load diff

View file

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