diff --git a/podcasts/antennapod.py b/podcasts/antennapod.py index 456a92e..357d631 100644 --- a/podcasts/antennapod.py +++ b/podcasts/antennapod.py @@ -43,7 +43,7 @@ SUBSCRIBED = 0 # Feeds.state MAX_FILESIZE = 4_000_000_000 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 # AntennaPod didn't have the column yet. @@ -52,7 +52,7 @@ FEED_COLUMNS = { 'image_url': '', 'keep_updated': 1, 'include_filter': '', 'exclude_filter': '', '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, - '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) elif code == AUTO_DOWNLOAD_OFF: 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 @@ -769,7 +773,7 @@ class AntennaPodImport: SETTING_LABELS = { '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', - 'new_episodes': 'new episodes', 'auto_download': 'auto-download', + 'new_episodes': 'new episodes', 'auto_download': 'auto-download', 'notify': 'notifications', } diff --git a/podcasts/migrations/0013_feed_notify.py b/podcasts/migrations/0013_feed_notify.py new file mode 100644 index 0000000..b1df205 --- /dev/null +++ b/podcasts/migrations/0013_feed_notify.py @@ -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), + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 54c8a38..30f4c85 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -51,6 +51,9 @@ class PodcastFeed(models.Model): # Devices download this feed's new episodes (those in the inbox) on # their own, for listening offline. 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: unique_together = ('user', 'rss_url') diff --git a/podcasts/test_antennapod.py b/podcasts/test_antennapod.py index 91207c1..2fe86b6 100644 --- a/podcasts/test_antennapod.py +++ b/podcasts/test_antennapod.py @@ -31,7 +31,8 @@ class Backup: 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_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, read INTEGER, link TEXT, description TEXT, media INTEGER, feed INTEGER, 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()) + 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): PodcastSettings.objects.create(user=self.user, auto_download_default=True) self.ap.feed('https://example.org/explicit-off', auto_download=0) diff --git a/podcasts/tests.py b/podcasts/tests.py index 096dc13..05d215f 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -1031,6 +1031,7 @@ class OpmlExportTests(TestCase): FEED_DEFAULTS = { '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, + 'notify': False, } @@ -1056,7 +1057,7 @@ class FeedSettingsTests(TestCase): self.assertEqual(r.json(), { '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, - 'auto_queue': False, 'auto_download': False, + 'auto_queue': False, 'auto_download': False, 'notify': False, }) self.feed.refresh_from_db() 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']), ('min_duration', -1), ('min_duration', 601), ('auto_download', 1), ('auto_download', 'true'), ('auto_download', None), + ('notify', 1), ('notify', 'false'), ('notify', None), ] for name, value in bad: with self.subTest(name=name, value=value): @@ -1116,6 +1118,17 @@ class FeedSettingsTests(TestCase): self.feed.refresh_from_db() 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): self.assertEqual(self.post({'new_episodes': 'queue'}).json()['auto_queue'], True) self.feed.refresh_from_db() @@ -1144,12 +1157,12 @@ class FeedSettingsTests(TestCase): def test_feeds_and_state_carry_the_settings(self): 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') expected = { 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, - 'auto_queue': True, 'auto_download': True}, + 'auto_queue': True, 'auto_download': True, 'notify': True}, } for url in ('/podcasts/feeds/', '/podcasts/state/'): with self.subTest(url=url): diff --git a/podcasts/views.py b/podcasts/views.py index 1bfbc90..e94602c 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -725,6 +725,7 @@ def feed_list(request): FEED_SETTING_FIELDS = ( 'playback_speed', 'skip_intro', 'skip_outro', 'volume', 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download', + 'notify', ) @@ -918,6 +919,7 @@ FEED_SETTING_PARSERS = { 'exclude_words': _word_list, 'min_duration': lambda v: _whole(v, 0, 600), 'auto_download': _boolean, + 'notify': _boolean, } diff --git a/static/js/sw.js b/static/js/sw.js index 1160419..9398b7e 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v63'; +const CACHE = 'diora-v64'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index d76d0cf..965897d 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -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 // 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}))}, ...dlSettingsItems(), ]), + backgroundSyncItem(), group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [ {icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml}, {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) { if (!N.podcastSetPrefValue) return null; 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})}, ]))); } + // 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?', [ {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)}, @@ -3101,6 +3130,26 @@ 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) { refreshing = false; if (!ok) toast(navigator.onLine ? 'Abrufen hat nicht geklappt' : 'Offline — es gilt der letzte Stand');