AntennaPod-Import: „Neu“ wie in AntennaPod
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 5m21s

Folgen archivierter Podcasts (neue Folgen ignorieren), die AntennaPod nie
abgerufen hat, verlassen „Neu“, sofern sie vor dem Backup erschienen
sind; --quiet-diora-only macht das auch für Abos, die nur diora hat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-02 12:38:41 +02:00
parent 561776556f
commit 07aed26864
3 changed files with 108 additions and 3 deletions

View file

@ -260,6 +260,8 @@ class Stats:
downloads_created: int = 0 downloads_created: int = 0
downloads_missing: int = 0 downloads_missing: int = 0
downloads_offered: bool = True downloads_offered: bool = True
ignored_left_inbox: int = 0
diora_only_left_inbox: int = 0
backup_date: datetime | None = None backup_date: datetime | None = None
seconds: float = 0.0 seconds: float = 0.0
@ -292,13 +294,17 @@ def _columns(db, table):
class AntennaPodImport: class AntennaPodImport:
def __init__(self, user, path, backup_date, global_auto_download=False, offer_downloads=True): def __init__(self, user, path, backup_date, global_auto_download=False, offer_downloads=True,
quiet_diora_only=False):
self.user = user self.user = user
self.path = path self.path = path
self.backup_date = backup_date self.backup_date = backup_date
self.global_auto_download = global_auto_download self.global_auto_download = global_auto_download
# False: the app doesn't offer AntennaPod's downloads (they stay in AntennaPod). # False: the app doesn't offer AntennaPod's downloads (they stay in AntennaPod).
self.offer_downloads = offer_downloads self.offer_downloads = offer_downloads
# True: subscriptions only diora has keep their episodes, but the
# ones out before the backup leave the inbox.
self.quiet_diora_only = quiet_diora_only
self.stats = Stats(backup_date=backup_date) self.stats = Stats(backup_date=backup_date)
def run(self, dry_run=False): def run(self, dry_run=False):
@ -340,6 +346,7 @@ class AntennaPodImport:
states, not_new, item_episode, downloads = self._import_episodes( states, not_new, item_episode, downloads = self._import_episodes(
db, feed_map, set(queue_items), favorite_items) db, feed_map, set(queue_items), favorite_items)
self._import_progress(states, not_new) self._import_progress(states, not_new)
self._quiet_inbox(feed_map, states)
self._import_queue(queue_items, item_episode) self._import_queue(queue_items, item_episode)
self._import_downloads(settings, downloads, item_episode) self._import_downloads(settings, downloads, item_episode)
@ -629,6 +636,55 @@ class AntennaPodImport:
for ids in _chunks(dismiss_only): for ids in _chunks(dismiss_only):
EpisodeProgress.objects.filter(pk__in=ids).update(dismissed=True) EpisodeProgress.objects.filter(pk__in=ids).update(dismissed=True)
def _quiet_inbox(self, feed_map, states):
"""Episodes AntennaPod never showed as new, though diora has them in
the inbox: those of podcasts whose new episodes are ignored (AntennaPod
stopped fetching archived ones, so it never saw them), and with
quiet_diora_only those of podcasts AntennaPod doesn't have. Only ones
out before the backup; AntennaPod's own new ones stay."""
st = self.stats
user = self.user
backup = self.backup_date
imported = set(feed_map.values())
feeds = set(PodcastFeed.objects.filter(pk__in=imported, new_episodes=PodcastFeed.NEW_IGNORE)
.values_list('pk', flat=True))
diora_only = set()
if self.quiet_diora_only:
diora_only = set(PodcastFeed.objects.filter(user=user).exclude(pk__in=imported).values_list('pk', flat=True))
if not feeds and not diora_only:
return
keep = {ep_id for ep_id, s in states.items() if not s.dismissed}
hidden = EpisodeProgress.objects.filter(user=user).filter(Q(played=True) | Q(dismissed=True))
rows = (PodcastEpisode.objects.filter(feed_id__in=feeds | diora_only)
.filter(Q(pub_date__lte=backup) | Q(pub_date__isnull=True))
.exclude(id__in=hidden.values('episode_id'))
.values_list('id', 'feed_id'))
ids = []
for ep_id, feed_id in rows:
if ep_id in keep:
continue
ids.append(ep_id)
if feed_id in feeds:
st.ignored_left_inbox += 1
else:
st.diora_only_left_inbox += 1
# Changed in diora after the backup: diora's state stays.
newer = set()
for chunk in _chunks(ids):
newer.update(EpisodeProgress.objects.filter(user=user, episode_id__in=chunk, updated_at__gt=backup)
.values_list('episode_id', flat=True))
ids = [i for i in ids if i not in newer]
for chunk in _chunks(ids):
EpisodeProgress.objects.filter(user=user, episode_id__in=chunk, dismissed=False).update(dismissed=True)
have = set(EpisodeProgress.objects.filter(user=user, episode_id__in=chunk)
.values_list('episode_id', flat=True))
with _given_updated_at():
EpisodeProgress.objects.bulk_create(
[EpisodeProgress(user=user, episode_id=i, dismissed=True, updated_at=backup)
for i in chunk if i not in have],
ignore_conflicts=True,
)
# -- queue -------------------------------------------------------------- # -- queue --------------------------------------------------------------
def _import_queue(self, queue_items, item_episode): def _import_queue(self, queue_items, item_episode):
@ -725,7 +781,9 @@ def summary_lines(st, username, path, dry_run, global_auto_download):
f'Queue: {st.queue_backup} in backup ({st.queue_missing} not found); {st.queue_before} before, ' f'Queue: {st.queue_backup} in backup ({st.queue_missing} not found); {st.queue_before} before, '
f'{st.queue_after} after ({st.queue_appended} diora entries after AntennaPod\'s)' f'{st.queue_after} after ({st.queue_appended} diora entries after AntennaPod\'s)'
+ (f'; kept sorted by "{st.keep_sorted}"' if st.keep_sorted else ''), + (f'; kept sorted by "{st.keep_sorted}"' if st.keep_sorted else ''),
f'Inbox: {st.inbox_before} before, {st.inbox_after} after', f'Inbox: {st.inbox_before} before, {st.inbox_after} after; out of it: {st.ignored_left_inbox} of podcasts '
f'whose new episodes are ignored (AntennaPod never showed them)'
+ (f', {st.diora_only_left_inbox} of podcasts only diora has' if st.diora_only_left_inbox else ''),
f'Downloads: {st.downloads} on the phone in AntennaPod ({st.downloads_bytes / 1_000_000:.0f} MB) ' f'Downloads: {st.downloads} on the phone in AntennaPod ({st.downloads_bytes / 1_000_000:.0f} MB) '
+ ('-> offered to the app for download' if st.downloads_offered else '-> not offered (--no-downloads)') + ('-> offered to the app for download' if st.downloads_offered else '-> not offered (--no-downloads)')
+ (f' ({st.downloads_created} episodes created for them)' if st.downloads_created else '') + (f' ({st.downloads_created} episodes created for them)' if st.downloads_created else '')

View file

@ -25,6 +25,9 @@ class Command(BaseCommand):
help='Do everything inside a transaction that is rolled back, print the summary') help='Do everything inside a transaction that is rolled back, print the summary')
parser.add_argument('--global-auto-download', choices=('on', 'off'), default='off', parser.add_argument('--global-auto-download', choices=('on', 'off'), default='off',
help="Auto-download for feeds that use AntennaPod's global setting (default off)") help="Auto-download for feeds that use AntennaPod's global setting (default off)")
parser.add_argument('--quiet-diora-only', action='store_true',
help='Podcasts only diora has (not in the backup): their episodes out before the backup '
'leave the inbox (the subscriptions stay)')
parser.add_argument('--no-downloads', action='store_true', parser.add_argument('--no-downloads', action='store_true',
help="Don't offer AntennaPod's downloaded episodes to the app (they stay in AntennaPod)") help="Don't offer AntennaPod's downloaded episodes to the app (they stay in AntennaPod)")
parser.add_argument('--backup-date', parser.add_argument('--backup-date',
@ -53,7 +56,8 @@ class Command(BaseCommand):
global_auto_download = options['global_auto_download'] == 'on' global_auto_download = options['global_auto_download'] == 'on'
try: try:
stats = AntennaPodImport(user, path, backup_date, global_auto_download, stats = AntennaPodImport(user, path, backup_date, global_auto_download,
offer_downloads=not options['no_downloads']).run(dry_run=options['dry_run']) offer_downloads=not options['no_downloads'],
quiet_diora_only=options['quiet_diora_only']).run(dry_run=options['dry_run'])
except AntennaPodImportError as e: except AntennaPodImportError as e:
raise CommandError(str(e)) raise CommandError(str(e))
for line in summary_lines(stats, user.username, path, options['dry_run'], global_auto_download): for line in summary_lines(stats, user.username, path, options['dry_run'], global_auto_download):

View file

@ -576,6 +576,49 @@ class ImportDownloadsViewTests(TestCase):
self.assertEqual(len(PodcastSettings.objects.get(user=self.user).import_downloads), 4) self.assertEqual(len(PodcastSettings.objects.get(user=self.user).import_downloads), 4)
class QuietInboxTests(ImportTestCase):
"""What AntennaPod never showed as new leaves diora's inbox too."""
def inbox(self):
hidden = EpisodeProgress.objects.filter(user=self.user, dismissed=True).values('episode_id')
return sorted(PodcastEpisode.objects.filter(feed__user=self.user).exclude(id__in=hidden)
.values_list('guid', flat=True))
def setUp(self):
super().setUp()
archived = self.diora_feed('https://example.org/archived')
self.ap_feed = self.ap.feed('https://example.org/archived', keep_updated=0)
self.ap.item(self.ap_feed, 'ap-new', read=-1)
self.episode(archived, 'ap-new', pub_date=T0)
self.episode(archived, 'unknown-old', pub_date=T0 + timedelta(days=3)) # AntennaPod never fetched it
self.episode(archived, 'unknown-late', pub_date=BACKUP_DATE + timedelta(days=1))
self.episode(archived, 'undated')
self.ap.feed('https://example.org/kept')
kept = self.diora_feed('https://example.org/kept')
self.episode(kept, 'kept-unknown', pub_date=T0) # not archived: stays
only = self.diora_feed('https://example.org/only-diora')
self.episode(only, 'only-old', pub_date=T0)
self.episode(only, 'only-late', pub_date=BACKUP_DATE + timedelta(days=1))
def test_archived_podcasts(self):
out = self.run_import()
self.assertEqual(self.inbox(), ['ap-new', 'kept-unknown', 'only-late', 'only-old', 'unknown-late'])
self.assertIn('out of it: 2 of podcasts whose new episodes are ignored (AntennaPod never showed them)\n', out)
p = self.progress(PodcastEpisode.objects.get(guid='unknown-old'))
self.assertEqual((p.played, p.dismissed, p.updated_at), (False, True, BACKUP_DATE))
def test_quiet_diora_only(self):
out = self.run_import('--quiet-diora-only')
self.assertEqual(self.inbox(), ['ap-new', 'kept-unknown', 'only-late', 'unknown-late'])
self.assertIn(', 1 of podcasts only diora has', out)
def test_diora_newer_stays(self):
ep = PodcastEpisode.objects.get(guid='unknown-old')
EpisodeProgress.objects.create(user=self.user, episode=ep, position_seconds=30) # updated now
self.run_import()
self.assertIn('unknown-old', self.inbox())
class AfterFirstRefreshTests(ImportTestCase): class AfterFirstRefreshTests(ImportTestCase):
"""The way to move: import, let the new feeds be fetched, import again.""" """The way to move: import, let the new feeds be fetched, import again."""