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>
This commit is contained in:
mrwnslz 2026-10-02 15:45:51 +02:00
parent 262dd0d207
commit 371c9b9222
6 changed files with 66 additions and 7 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

@ -972,6 +972,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 +998,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 +1024,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 +1059,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 +1098,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',
) )
@ -896,6 +897,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,
} }