From 07aed268646e0c409fef27cee0cc257c65209dd7 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Fri, 2 Oct 2026 12:38:41 +0200 Subject: [PATCH] =?UTF-8?q?AntennaPod-Import:=20=E2=80=9ENeu=E2=80=9C=20wi?= =?UTF-8?q?e=20in=20AntennaPod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- podcasts/antennapod.py | 62 ++++++++++++++++++- .../management/commands/import_antennapod.py | 6 +- podcasts/test_antennapod.py | 43 +++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/podcasts/antennapod.py b/podcasts/antennapod.py index e44b127..2302dc6 100644 --- a/podcasts/antennapod.py +++ b/podcasts/antennapod.py @@ -260,6 +260,8 @@ class Stats: downloads_created: int = 0 downloads_missing: int = 0 downloads_offered: bool = True + ignored_left_inbox: int = 0 + diora_only_left_inbox: int = 0 backup_date: datetime | None = None seconds: float = 0.0 @@ -292,13 +294,17 @@ def _columns(db, table): 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.path = path self.backup_date = backup_date self.global_auto_download = global_auto_download # False: the app doesn't offer AntennaPod's downloads (they stay in AntennaPod). 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) def run(self, dry_run=False): @@ -340,6 +346,7 @@ class AntennaPodImport: states, not_new, item_episode, downloads = self._import_episodes( db, feed_map, set(queue_items), favorite_items) self._import_progress(states, not_new) + self._quiet_inbox(feed_map, states) self._import_queue(queue_items, item_episode) self._import_downloads(settings, downloads, item_episode) @@ -629,6 +636,55 @@ class AntennaPodImport: for ids in _chunks(dismiss_only): 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 -------------------------------------------------------------- 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'{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'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) ' + ('-> 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 '') diff --git a/podcasts/management/commands/import_antennapod.py b/podcasts/management/commands/import_antennapod.py index 4d61207..461968d 100644 --- a/podcasts/management/commands/import_antennapod.py +++ b/podcasts/management/commands/import_antennapod.py @@ -25,6 +25,9 @@ class Command(BaseCommand): help='Do everything inside a transaction that is rolled back, print the summary') parser.add_argument('--global-auto-download', choices=('on', 'off'), 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', help="Don't offer AntennaPod's downloaded episodes to the app (they stay in AntennaPod)") parser.add_argument('--backup-date', @@ -53,7 +56,8 @@ class Command(BaseCommand): global_auto_download = options['global_auto_download'] == 'on' try: 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: raise CommandError(str(e)) for line in summary_lines(stats, user.username, path, options['dry_run'], global_auto_download): diff --git a/podcasts/test_antennapod.py b/podcasts/test_antennapod.py index 9df20b8..3d39dbd 100644 --- a/podcasts/test_antennapod.py +++ b/podcasts/test_antennapod.py @@ -576,6 +576,49 @@ class ImportDownloadsViewTests(TestCase): 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): """The way to move: import, let the new feeds be fetched, import again."""