AntennaPod-Import: „Neu“ wie in AntennaPod
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:
parent
561776556f
commit
07aed26864
3 changed files with 108 additions and 3 deletions
|
|
@ -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 '')
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue