Compare commits

..

3 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
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
8 changed files with 117 additions and 9 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

@ -1031,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,
} }
@ -1056,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),
@ -1082,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):
@ -1116,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()
@ -1144,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):

View file

@ -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',
) )
@ -918,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,
} }

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-v63'; const CACHE = 'diora-v64';
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 3d1281c 2026-10-02: web-native/static/native-podcast.js // Copied from diora-android 6b0855e 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.
@ -274,6 +274,7 @@
action: () => N.podcastSettings(JSON.stringify({auto_download_default: !s.auto_download_default}))}, action: () => N.podcastSettings(JSON.stringify({auto_download_default: !s.auto_download_default}))},
...dlSettingsItems(), ...dlSettingsItems(),
]), ]),
backgroundSyncItem(),
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [ group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
{icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml}, {icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml},
{icon: '⇧', label: 'Abos importieren (OPML)', action: importOpml}, {icon: '⇧', label: 'Abos importieren (OPML)', action: importOpml},
@ -282,6 +283,25 @@
]); ]);
} }
// The app's background job (BackgroundSync.kt): new episodes, their notifications and downloads with the app closed.
const BG_SYNC = [
{value: 0, label: 'Aus', hint: 'nur wenn die App offen ist'},
{value: 15, label: 'Alle 15 Minuten'},
{value: 60, label: 'Jede Stunde', hint: 'Standard'},
{value: 240, label: 'Alle 4 Stunden'},
];
function backgroundSyncItem() {
if (!N.podcastBackgroundSync || !N.podcastSetBackgroundSync) return null;
const now = N.podcastBackgroundSync();
const label = (BG_SYNC.find(c => c.value === now) || BG_SYNC[2]).label;
return {icon: '⟲', label: 'Im Hintergrund abgleichen', hint: label + ' · neue Folgen, Mitteilungen, Downloads',
action: () => openSheet('Im Hintergrund abgleichen', BG_SYNC.map(c => ({
icon: c.value === now ? '●' : '○', label: c.label, hint: c.hint, active: c.value === now,
action: () => { N.podcastSetBackgroundSync(c.value); settingsMenu(); },
})))};
}
function skipItem(name, label, choices) { function skipItem(name, label, choices) {
if (!N.podcastSetPrefValue) return null; if (!N.podcastSetPrefValue) return null;
const now = devicePrefs()[name]; const now = devicePrefs()[name];
@ -1078,6 +1098,15 @@
{icon: !live.auto_download ? '●' : '○', label: 'Nein', hint: 'nur die Warteschlange wird geladen', action: () => save({auto_download: false})}, {icon: !live.auto_download ? '●' : '○', label: 'Nein', hint: 'nur die Warteschlange wird geladen', action: () => save({auto_download: false})},
]))); ])));
} }
// AntennaPod's episode notifications: the phone tells when a new episode is in „Neu“.
if ('notify' in live) {
row.append(chip('Benachrichtigen', live.notify ? 'an' : 'aus', !!live.notify, () =>
openSheet(`Bei neuen Folgen von „${plain(live.title)}“ benachrichtigen?`, [
{icon: live.notify ? '●' : '○', label: 'Ja', hint: 'eine Mitteilung auf dem Telefon, sobald eine neue Folge in „Neu“ ist',
action: () => save({notify: true})},
{icon: !live.notify ? '●' : '○', label: 'Nein', action: () => save({notify: false})},
])));
}
row.append(chip('Filter', words || 'aus', !!words, () => openSheet('Welche neuen Folgen zählen?', [ row.append(chip('Filter', words || 'aus', !!words, () => openSheet('Welche neuen Folgen zählen?', [
{icon: '+', label: 'Nur mit diesen Wörtern im Titel', hint: live.include_words || 'alle', {icon: '+', label: 'Nur mit diesen Wörtern im Titel', hint: live.include_words || 'alle',
action: () => ask('include_words', 'Nur Folgen, deren Titel eines dieser Wörter enthält (mit Komma getrennt):', live.include_words)}, action: () => ask('include_words', 'Nur Folgen, deren Titel eines dieser Wörter enthält (mit Komma getrennt):', live.include_words)},
@ -3101,6 +3130,26 @@
render(); render();
}; };
// A new-episode notification was tapped: that podcast's page (gone meanwhile: „Neu“).
window.__dioraOpenFeed = function (feedId) {
if (typeof showTab !== 'function') return;
closeStats();
closeDownloads();
closeNotes();
closePlayer();
selected = null;
showTab('podcasts');
const f = feeds().find(x => x.id === feedId);
if (f) {
openFeed(f);
} else {
feedView = null;
view = 'inbox';
render();
scrollTop(0);
}
};
window.__dioraPodcastsRefreshed = function (ok) { window.__dioraPodcastsRefreshed = function (ok) {
refreshing = false; refreshing = false;
if (!ok) toast(navigator.onLine ? 'Abrufen hat nicht geklappt' : 'Offline — es gilt der letzte Stand'); if (!ok) toast(navigator.onLine ? 'Abrufen hat nicht geklappt' : 'Offline — es gilt der letzte Stand');