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
This commit is contained in:
mrwnslz 2026-10-02 18:03:07 +02:00
commit 3577fca9ba
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
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',
}

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
# 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')

View file

@ -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)

View file

@ -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):

View file

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

View file

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

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
// 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');