diora-web/podcasts/test_antennapod.py
mrwnslz 54fbd58293
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 6m39s
Podcast-Routine aus AntennaPod: Abspiel-Einstellungen fürs Konto, Listen und Stille pro Podcast; Oberfläche aus diora-android 304abf0, sw v65
- PodcastSettings (Migration 0014): skip_back / skip_forward (5–60 s),
  smart_played, speed_presets (0,5–3,5×), keep_skipped, delete_dequeue,
  mobile_downloads, mobile_streaming (allow/ask/never), times_by_speed;
  die ersten vier sind anfangs leer (null) und werden mit POST
  settings/ {"initial": {...}} nur dort gesetzt, wo noch nichts steht
- GET/POST /podcasts/settings/ und state.settings mit allen Feldern,
  geprüft, nichts ändert sich bei einem ungültigen Wert
- PodcastFeed: episode_sort, episode_filter, skip_silence in
  feeds/<id>/settings/ und den Feeds des States
- Tempo pro Podcast bis 3,5× (Server und AntennaPod-Import)
- AntennaPod-Import: Sortierung, Filter und Stille pro Podcast, soweit
  diora sie zeigen kann; nicht darstellbare werden gezählt
- Browser-Host: Sprungweiten, "gehört" und keep_skipped aus dem Konto,
  Tempo bis 3,5×, neue Einstellungen werden gespeichert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:46:55 +02:00

764 lines
44 KiB
Python

import os
import sqlite3
import tempfile
from datetime import datetime, timedelta, timezone
from io import StringIO
from unittest.mock import patch
from django.contrib.auth.models import User
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
from .antennapod import filter_words, normalize_url, parse_filter_terms
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
BACKUP_DATE = datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc)
T0 = datetime(2026, 8, 1, tzinfo=timezone.utc)
def ms(dt):
return int(dt.timestamp() * 1000)
class Backup:
"""A small AntennaPod database: the tables and columns the import reads."""
SCHEMA = """
CREATE TABLE Feeds (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, custom_title TEXT,
download_url TEXT, link TEXT, author TEXT, image_url TEXT, keep_updated INTEGER DEFAULT 1,
include_filter TEXT DEFAULT '', exclude_filter TEXT DEFAULT '',
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, episode_notification INTEGER DEFAULT 0, state INTEGER DEFAULT 0,
tags TEXT, sort_order TEXT, hide TEXT, feed_skip_silence INTEGER DEFAULT 1);
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);
CREATE TABLE FeedMedia (id INTEGER PRIMARY KEY AUTOINCREMENT, duration INTEGER, download_url TEXT,
position INTEGER, playback_completion_date INTEGER, feeditem INTEGER, played_duration INTEGER,
last_played_time INTEGER, downloaded INTEGER DEFAULT 0, filesize INTEGER DEFAULT 0);
CREATE TABLE Queue (id INTEGER PRIMARY KEY AUTOINCREMENT, feeditem INTEGER, feed INTEGER);
CREATE TABLE Favorites (id INTEGER PRIMARY KEY AUTOINCREMENT, feeditem INTEGER, feed INTEGER);
"""
def __init__(self, path):
self.path = path
self.db = sqlite3.connect(path)
self.db.executescript(self.SCHEMA)
def feed(self, url, title='Feed', **cols):
cols = {'download_url': url, 'title': title, **cols}
cur = self.db.execute(f'INSERT INTO Feeds ({",".join(cols)}) VALUES ({",".join("?" * len(cols))})',
list(cols.values()))
self.db.commit()
return cur.lastrowid
def item(self, feed, guid, read=0, audio=None, pub=T0, duration=1800, position=0, completed=None,
last_played=None, title=None, description='', image_url=None, media=True, downloaded=0, size=0):
cur = self.db.execute(
'INSERT INTO FeedItems (title, pubDate, read, description, feed, item_identifier, image_url) '
'VALUES (?,?,?,?,?,?,?)',
(title or f'Episode {guid}', ms(pub) if pub else 0, read, description, feed, guid, image_url))
item_id = cur.lastrowid
if media:
self.db.execute(
'INSERT INTO FeedMedia (duration, download_url, position, playback_completion_date, feeditem, '
'played_duration, last_played_time, downloaded, filesize) VALUES (?,?,?,?,?,?,?,?,?)',
(duration * 1000, audio or f'https://cdn.example.org/{guid}.mp3', position * 1000,
ms(completed) if completed else 0, item_id, 0, ms(last_played) if last_played else 0,
downloaded, size))
self.db.commit()
return item_id
def queue(self, *items):
for i in items:
self.db.execute('INSERT INTO Queue (feeditem, feed) SELECT id, feed FROM FeedItems WHERE id=?', (i,))
self.db.commit()
def favorite(self, *items):
for i in items:
self.db.execute('INSERT INTO Favorites (feeditem, feed) SELECT id, feed FROM FeedItems WHERE id=?', (i,))
self.db.commit()
def close(self):
self.db.close()
class ImportTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.tmp = tempfile.TemporaryDirectory()
self.ap = Backup(os.path.join(self.tmp.name, 'AntennaPodBackup.db'))
def tearDown(self):
self.ap.close()
self.tmp.cleanup()
def run_import(self, *args, user='alice'):
out = StringIO()
call_command('import_antennapod', self.ap.path, '--user', user,
'--backup-date', BACKUP_DATE.isoformat(), *args, stdout=out)
return out.getvalue()
def diora_feed(self, url='https://example.org/feed', **kw):
return PodcastFeed.objects.create(user=self.user, rss_url=url, title=kw.pop('title', 'Diora'), **kw)
def episode(self, feed, guid, audio=None, **kw):
return PodcastEpisode.objects.create(feed=feed, guid=guid, title=guid,
audio_url=audio or f'https://cdn.example.org/{guid}.mp3', **kw)
def progress(self, episode):
return EpisodeProgress.objects.filter(user=self.user, episode=episode).first()
def snapshot(self):
return (
list(PodcastFeed.objects.order_by('id').values()),
list(PodcastEpisode.objects.order_by('id').values()),
list(EpisodeProgress.objects.order_by('id').values()),
list(PodcastQueue.objects.order_by('id').values('id', 'user_id', 'episode_id', 'position')),
)
def queue(self):
return list(PodcastQueue.objects.filter(user=self.user).order_by('position')
.values_list('episode_id', flat=True))
class HelperTests(TestCase):
def test_normalize_url(self):
same = ['https://www.Example.org/feed/', 'http://example.org/feed', 'HTTPS://EXAMPLE.ORG/feed',
'https://example.org:443/feed#x', 'http://example.org:80/feed//']
self.assertEqual({normalize_url(u) for u in same}, {'example.org/feed'})
self.assertNotEqual(normalize_url('https://example.org/Feed'), normalize_url('https://example.org/feed'))
self.assertEqual(normalize_url('https://example.org:8080/f?a=1'), 'example.org:8080/f?a=1')
for bad in ('antennapod_local:content://x', '', None, 'ftp://example.org/f', 'https://'):
self.assertIsNone(normalize_url(bad))
def test_filter_terms(self):
self.assertEqual(parse_filter_terms('news "long talk" Teaser'), ['news', 'long talk', 'Teaser'])
self.assertEqual(parse_filter_terms('"news headlines" '), ['news headlines'])
self.assertEqual(parse_filter_terms(' a b '), ['a', 'b'])
self.assertEqual(parse_filter_terms('"open quote'), ['open', 'quote'])
self.assertEqual(parse_filter_terms('a,b A'), ['a b', 'A'])
self.assertEqual(parse_filter_terms('x X'), ['x'])
self.assertEqual(parse_filter_terms(''), [])
self.assertEqual(filter_words('news "long talk"'), 'news, long talk')
self.assertEqual(len(filter_words(' '.join(['word%03d' % i for i in range(100)]))), 493)
class FeedImportTests(ImportTestCase):
def test_feeds_match_by_normalised_url_and_missing_ones_are_created(self):
mine = self.diora_feed('https://www.Example.org/feed/')
only_diora = self.diora_feed('https://example.org/only-in-diora')
self.ap.feed('http://example.org/feed', title='Same')
self.ap.feed('https://new.example.org/rss', title='Original', custom_title='Renamed', author='Ann',
link='https://new.example.org/', image_url='https://new.example.org/art.jpg')
self.ap.feed('antennapod_local:content://folder', title='Local')
self.ap.feed('https://gone.example.org/rss', title='Not subscribed', state=1)
out = self.run_import()
self.assertEqual(PodcastFeed.objects.filter(user=self.user).count(), 3)
mine.refresh_from_db()
self.assertEqual(mine.title, 'Diora') # matched, not renamed
self.assertTrue(PodcastFeed.objects.filter(pk=only_diora.pk).exists())
new = PodcastFeed.objects.get(user=self.user, rss_url='https://new.example.org/rss')
self.assertEqual((new.title, new.author, new.link, new.artwork_url, new.last_refreshed_at),
('Renamed', 'Ann', 'https://new.example.org/', 'https://new.example.org/art.jpg', None))
self.assertIn('Feeds: 4 in backup, 3 subscribed (1 not subscribed ignored); 1 matched, 1 created, '
'1 skipped (not http/https), 0 duplicates', out)
self.assertIn('Created feeds (1):\n - Renamed\n', out)
self.assertIn('Skipped feeds (1):\n - Local (antennapod_local:content://folder)\n', out)
def test_two_backup_feeds_for_one_url(self):
self.ap.feed('https://example.org/feed')
self.ap.feed('http://www.example.org/feed/')
out = self.run_import()
self.assertEqual(PodcastFeed.objects.filter(user=self.user).count(), 1)
self.assertIn('0 matched, 1 created, 0 skipped (not http/https), 1 duplicates', out)
def test_settings(self):
keep = self.diora_feed('https://example.org/keep', playback_speed=1.5, skip_intro=10, volume=2.0,
new_episodes='queue', include_words='x', min_duration=5, auto_download=True)
changed = self.diora_feed('https://example.org/changed', playback_speed=1.5)
self.ap.feed('https://example.org/keep', auto_download=1) # all AntennaPod defaults
self.ap.feed('https://example.org/changed', feed_playback_speed=1.2000000476837158, feed_skip_intro=30,
feed_skip_ending=700, feed_volume_adaption=2, include_filter='news "long talk" ',
exclude_filter='"Trailer"', minimal_duration_filter=1500, new_episodes_action=2,
auto_download=2)
self.ap.feed('https://example.org/fast', feed_playback_speed=3.5, feed_volume_adaption=5,
minimal_duration_filter=59, new_episodes_action=3)
self.ap.feed('https://example.org/slow', feed_playback_speed=0.25, feed_volume_adaption=3,
new_episodes_action=1)
self.ap.feed('https://example.org/loud', feed_volume_adaption=4)
self.ap.feed('https://example.org/quiet', feed_volume_adaption=1)
out = self.run_import()
keep.refresh_from_db()
self.assertEqual(
(keep.playback_speed, keep.skip_intro, keep.volume, keep.new_episodes, keep.auto_queue,
keep.include_words, keep.min_duration, keep.auto_download),
(1.5, 10, 2.0, 'queue', True, 'x', 5, False), # auto_download "global", --global-auto-download off
)
changed.refresh_from_db()
self.assertEqual(
(changed.playback_speed, changed.skip_intro, changed.skip_outro, changed.volume, changed.include_words,
changed.exclude_words, changed.min_duration, changed.new_episodes, changed.auto_download),
(1.2, 30, 600, 0.2, 'news, long talk', 'Trailer', 25, 'ignore', True),
)
feeds = {f.rss_url.rsplit('/', 1)[1]: f for f in PodcastFeed.objects.filter(user=self.user)}
fast, slow = feeds['fast'], feeds['slow']
self.assertEqual((fast.playback_speed, fast.volume, fast.min_duration, fast.new_episodes, fast.auto_queue),
(3.5, 2.5, 0, 'queue', True))
self.assertEqual((slow.playback_speed, slow.volume, slow.new_episodes, slow.auto_queue),
(0.5, 1.5, 'inbox', False))
self.assertEqual((feeds['loud'].volume, feeds['quiet'].volume), (2.0, 0.5))
self.assertIn('Settings: changed: speed 3, skip intro 1, skip outro 1, volume 5, include words 1, '
'exclude words 1, min duration 1, new episodes 2, auto-download 2', out)
def test_not_kept_updated_feeds_bring_nothing_new(self):
self.ap.feed('https://example.org/a', keep_updated=0)
self.ap.feed('https://example.org/b', keep_updated=0, new_episodes_action=1)
self.ap.feed('https://example.org/c', keep_updated=0, new_episodes_action=3)
self.ap.feed('https://example.org/d', keep_updated=1)
out = self.run_import()
new = dict(PodcastFeed.objects.filter(user=self.user).values_list('rss_url', 'new_episodes'))
self.assertEqual(new, {'https://example.org/a': 'ignore', 'https://example.org/b': 'ignore',
'https://example.org/c': 'queue', 'https://example.org/d': 'inbox'})
self.assertIn('2 feeds not kept updated in AntennaPod -> new episodes "ignore"', out)
def test_auto_download(self):
self.diora_feed('https://example.org/off', auto_download=True)
for name, code in (('off', 0), ('global', 1), ('on', 2), ('global2', 1)):
self.ap.feed(f'https://example.org/{name}', auto_download=code)
def values():
return dict(PodcastFeed.objects.filter(user=self.user).values_list('rss_url', 'auto_download'))
out = self.run_import()
self.assertEqual(values(), {'https://example.org/off': False, 'https://example.org/global': False,
'https://example.org/on': True, 'https://example.org/global2': False})
self.assertIn("Auto-download: on for 1 feeds (1 on in AntennaPod, 2 use AntennaPod's global setting -> off "
'by --global-auto-download); default for new subscriptions: off\n', out)
self.assertFalse(PodcastSettings.for_user(self.user).auto_download_default)
out = self.run_import('--global-auto-download', 'on')
self.assertEqual(values(), {'https://example.org/off': False, 'https://example.org/global': True,
'https://example.org/on': True, 'https://example.org/global2': True})
self.assertIn("Auto-download: on for 3 feeds (1 on in AntennaPod, 2 use AntennaPod's global setting -> on "
'by --global-auto-download); default for new subscriptions: on (set by this import)\n', out)
self.assertTrue(PodcastSettings.for_user(self.user).auto_download_default)
# Once more: nothing to set any more; "off" leaves the default alone.
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_episode_lists_and_skip_silence(self):
"""Per podcast: its episode order and filter (where diora has them) and skipping silence."""
self.diora_feed('https://example.org/mine', episode_sort='long', episode_filter=['favorites'],
skip_silence=True)
self.ap.feed('https://example.org/mine') # nothing set in AntennaPod: diora's own stay
self.ap.feed('https://example.org/series', sort_order='1', hide='unplayed', feed_skip_silence=0)
self.ap.feed('https://example.org/new', sort_order='2', hide='paused,is_favorite,downloaded,queued')
self.ap.feed('https://example.org/title', sort_order='3', hide='played', feed_skip_silence=2)
self.ap.feed('https://example.org/short', sort_order='5')
self.ap.feed('https://example.org/long', sort_order='6', feed_skip_silence=1)
self.ap.feed('https://example.org/global', sort_order='11', hide='')
self.ap.feed('https://example.org/z-a', sort_order='4', hide='unplayed,not_downloaded')
self.ap.feed('https://example.org/size', sort_order='10', hide='new')
out = self.run_import()
got = {f.rss_url.rsplit('/', 1)[1]: (f.episode_sort, f.episode_filter, f.skip_silence)
for f in PodcastFeed.objects.filter(user=self.user)}
self.assertEqual(got, {
'mine': ('long', ['favorites'], True),
'series': ('old', ['unplayed'], False),
'new': ('new', ['in_progress', 'favorites', 'downloaded', 'queued'], None),
'title': ('title', ['played'], True),
'short': ('short', [], None),
'long': ('long', [], None),
'global': ('', [], None),
'z-a': ('', [], None), # title Z-A, "not downloaded": nothing diora can show
'size': ('', [], None),
})
self.assertIn('episode order 5, episode filter 3, skip silence 2', out)
self.assertIn("episode lists diora can't show that way (left as they are): 2 orders, 2 filters", out)
# Through the API as the apps see it.
self.client.force_login(self.user)
feeds = {f['rss_url'].rsplit('/', 1)[1]: f for f in self.client.get('/podcasts/state/').json()['feeds']}
self.assertEqual((feeds['series']['episode_sort'], feeds['series']['episode_filter'],
feeds['series']['skip_silence']), ('old', ['unplayed'], False))
def test_backup_without_list_and_silence_columns(self):
for column in ('sort_order', 'hide', 'feed_skip_silence'):
self.ap.db.execute(f'ALTER TABLE Feeds DROP COLUMN {column}')
self.ap.feed('https://example.org/old')
self.run_import()
f = PodcastFeed.objects.get(user=self.user, rss_url='https://example.org/old')
self.assertEqual((f.episode_sort, f.episode_filter, f.skip_silence), ('', [], None))
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)
self.ap.feed('https://example.org/on', auto_download=2)
self.run_import()
values = dict(PodcastFeed.objects.filter(user=self.user).values_list('rss_url', 'auto_download'))
self.assertEqual(values, {'https://example.org/explicit-off': False, 'https://example.org/on': True})
class EpisodeImportTests(ImportTestCase):
def setUp(self):
super().setUp()
self.feed = self.diora_feed()
self.ap_feed = self.ap.feed('https://example.org/feed')
def test_matching_and_creating_episodes(self):
by_guid = self.episode(self.feed, 'g1')
by_url = self.episode(self.feed, 'other-guid', audio='http://www.cdn.example.org/g2.mp3')
stateless = self.episode(self.feed, 'g4')
untouched = self.episode(self.feed, 'g6')
self.ap.item(self.ap_feed, 'g1', read=1)
self.ap.item(self.ap_feed, 'g2', read=1, audio='https://cdn.example.org/g2.mp3')
self.ap.item(self.ap_feed, 'g3', read=1, title='Created', description='<p>Notes</p>', duration=3600,
pub=T0 + timedelta(days=3), image_url='https://example.org/g3.jpg')
self.ap.item(self.ap_feed, 'g4', read=0)
self.ap.item(self.ap_feed, 'g5', read=0) # no state, not in diora: not imported
self.ap.item(self.ap_feed, None, read=-1, audio='https://cdn.example.org/noguid.mp3')
self.ap.item(self.ap_feed, 'g7', read=1, media=False)
out = self.run_import()
self.assertTrue(self.progress(by_guid).played)
self.assertTrue(self.progress(by_url).played)
created = PodcastEpisode.objects.get(feed=self.feed, guid='g3')
self.assertEqual(
(created.title, created.description, created.audio_url, created.duration_seconds, created.pub_date,
created.artwork_url),
('Created', '<p>Notes</p>', 'https://cdn.example.org/g3.mp3', 3600, T0 + timedelta(days=3),
'https://example.org/g3.jpg'),
)
self.assertTrue(self.progress(created).played)
# Without a guid, the audio URL is the guid (as when diora reads the feed).
noguid = PodcastEpisode.objects.get(feed=self.feed, guid='https://cdn.example.org/noguid.mp3')
self.assertFalse(self.progress(noguid).dismissed)
self.assertFalse(PodcastEpisode.objects.filter(guid__in=['g5', 'g7']).exists())
# Known to AntennaPod and not new there: out of the inbox, not played.
p = self.progress(stateless)
self.assertEqual((p.dismissed, p.played), (True, False))
self.assertIsNone(self.progress(untouched))
self.assertIn('Episodes: 5 with a state: 1 matched by guid, 1 by URL, 2 created, '
'1 skipped (no audio URL), 0 in skipped feeds', out)
self.assertIn('1 without a state matched to diora episodes', out)
self.assertIn('Inbox: 4 before, 2 after', out) # g6 and noguid
def test_states(self):
played_at = T0 + timedelta(days=10)
self.ap.item(self.ap_feed, 'played', read=1, position=0, completed=played_at, last_played=played_at)
self.ap.item(self.ap_feed, 'marked', read=1, pub=T0 + timedelta(days=2))
self.ap.item(self.ap_feed, 'started', read=0, position=600, last_played=played_at - timedelta(hours=1))
self.ap.item(self.ap_feed, 'new', read=-1, pub=T0 + timedelta(days=5))
self.ap.item(self.ap_feed, 'nodate', read=1, pub=None)
self.ap.item(self.ap_feed, 'future', read=1, pub=BACKUP_DATE + timedelta(days=30))
self.run_import()
def state(guid):
p = self.progress(PodcastEpisode.objects.get(guid=guid))
return p.played, p.dismissed, p.position_seconds, p.updated_at
self.assertEqual(state('played'), (True, True, 0, played_at))
self.assertEqual(state('marked'), (True, True, 0, T0 + timedelta(days=2))) # no activity: pub date
self.assertEqual(state('started'), (False, True, 600, played_at - timedelta(hours=1)))
self.assertEqual(state('new'), (False, False, 0, T0 + timedelta(days=5)))
self.assertEqual(state('nodate'), (True, True, 0, BACKUP_DATE))
self.assertEqual(state('future'), (True, True, 0, BACKUP_DATE))
def test_favourites_are_a_union(self):
diora_star = self.episode(self.feed, 'a')
p = EpisodeProgress.objects.create(user=self.user, episode=diora_star, favorite=True)
EpisodeProgress.objects.filter(pk=p.pk).update(updated_at=BACKUP_DATE - timedelta(days=5))
both = self.episode(self.feed, 'b')
self.ap.item(self.ap_feed, 'a', read=1) # AntennaPod wins, but diora's star stays
b = self.ap.item(self.ap_feed, 'b', read=0)
c = self.ap.item(self.ap_feed, 'c', read=0)
self.ap.favorite(b, c)
out = self.run_import()
p = self.progress(diora_star)
self.assertEqual((p.favorite, p.played), (True, True))
self.assertTrue(self.progress(both).favorite)
self.assertTrue(self.progress(PodcastEpisode.objects.get(guid='c')).favorite)
self.assertIn('Favourites: 2 in backup; 1 before, 3 after', out)
def test_merge_rule(self):
newer = self.episode(self.feed, 'newer')
older = self.episode(self.feed, 'older')
dismissed = self.episode(self.feed, 'dismissed')
after_backup = BACKUP_DATE + timedelta(days=1)
for ep, values in ((newer, dict(position_seconds=100)), (older, dict(position_seconds=100)),
(dismissed, dict(dismissed=True))):
p = EpisodeProgress.objects.create(user=self.user, episode=ep, **values)
EpisodeProgress.objects.filter(pk=p.pk).update(
updated_at=after_backup if ep is not older else BACKUP_DATE - timedelta(days=1))
self.ap.item(self.ap_feed, 'newer', read=1, completed=BACKUP_DATE - timedelta(days=2))
self.ap.item(self.ap_feed, 'older', read=1, completed=BACKUP_DATE - timedelta(days=2))
n = self.ap.item(self.ap_feed, 'dismissed', read=-1)
self.ap.favorite(n)
out = self.run_import()
p = self.progress(newer)
self.assertEqual((p.played, p.position_seconds, p.updated_at), (False, 100, after_backup))
p = self.progress(older)
self.assertEqual((p.played, p.position_seconds, p.updated_at), (True, 0, BACKUP_DATE - timedelta(days=2)))
# diora's newer "not new" stays; the star comes over anyway, without
# touching updated_at.
p = self.progress(dismissed)
self.assertEqual((p.dismissed, p.favorite, p.updated_at), (True, True, after_backup))
self.assertIn('Progress: 0 new, 1 updated, 0 unchanged, 2 kept (diora newer)', out)
def test_activity_after_diora_change_wins(self):
ep = self.episode(self.feed, 'e')
p = EpisodeProgress.objects.create(user=self.user, episode=ep, position_seconds=50)
EpisodeProgress.objects.filter(pk=p.pk).update(updated_at=BACKUP_DATE + timedelta(hours=1))
self.ap.item(self.ap_feed, 'e', read=0, position=300, last_played=BACKUP_DATE + timedelta(hours=2))
self.run_import()
self.assertEqual(self.progress(ep).position_seconds, 300)
def test_queue(self):
x = self.episode(self.feed, 'x')
y = self.episode(self.feed, 'y')
PodcastQueue.objects.create(user=self.user, episode=x, position=0)
PodcastQueue.objects.create(user=self.user, episode=y, position=1)
a = self.ap.item(self.ap_feed, 'a', read=-1)
b = self.ap.item(self.ap_feed, 'b', read=0)
y_ap = self.ap.item(self.ap_feed, 'y', read=0)
local = self.ap.feed('antennapod_local:x')
lost = self.ap.item(local, 'l', read=0)
self.ap.queue(b, y_ap, lost, a)
out = self.run_import()
ids = {e.guid: e.id for e in PodcastEpisode.objects.filter(feed=self.feed)}
self.assertEqual(self.queue(), [ids['b'], y.id, ids['a'], x.id])
self.assertEqual(list(PodcastQueue.objects.filter(user=self.user).order_by('position')
.values_list('position', flat=True)), [0, 1, 2, 3])
# Queued is not new, even if AntennaPod still had it as new.
self.assertTrue(self.progress(PodcastEpisode.objects.get(guid='a')).dismissed)
self.assertIn('Queue: 4 in backup (1 not found); 2 before, 4 after (1 diora entries after', out)
def test_queue_kept_sorted(self):
PodcastSettings.objects.create(user=self.user, keep_sorted='old')
a = self.ap.item(self.ap_feed, 'a', read=0, pub=T0 + timedelta(days=2))
b = self.ap.item(self.ap_feed, 'b', read=0, pub=T0 + timedelta(days=1))
self.ap.queue(a, b)
out = self.run_import()
self.assertEqual([PodcastEpisode.objects.get(pk=i).guid for i in self.queue()], ['b', 'a'])
self.assertIn('kept sorted by "old"', out)
def test_dry_run_writes_nothing(self):
self.episode(self.feed, 'g1')
PodcastQueue.objects.create(user=self.user, episode=self.episode(self.feed, 'q'), position=0)
g = self.ap.item(self.ap_feed, 'g1', read=1)
self.ap.item(self.ap_feed, 'g2', read=-1)
self.ap.feed('https://new.example.org/rss', feed_playback_speed=2.0)
self.ap.queue(g)
before = self.snapshot()
out = self.run_import('--dry-run')
self.assertEqual(self.snapshot(), before)
self.assertIn('DRY RUN', out)
self.assertIn('1 created', out)
self.assertIn('Queue: 1 in backup (0 not found); 1 before, 2 after', out)
self.assertNotIn('DRY RUN', self.run_import())
self.assertNotEqual(self.snapshot(), before)
def test_running_twice_changes_nothing_more(self):
self.episode(self.feed, 'g1')
self.episode(self.feed, 'stateless')
PodcastQueue.objects.create(user=self.user, episode=self.episode(self.feed, 'q'), position=0)
g1 = self.ap.item(self.ap_feed, 'g1', read=0, position=60, last_played=T0)
self.ap.item(self.ap_feed, 'stateless', read=0)
g2 = self.ap.item(self.ap_feed, 'g2', read=-1)
self.ap.item(self.ap_feed, 'g3', read=1)
self.ap.item(self.ap_feed, 'g4', read=1, pub=None)
self.ap.feed('https://new.example.org/rss', feed_playback_speed=2.0, keep_updated=0)
self.ap.queue(g2, g1)
self.ap.favorite(g1)
self.run_import()
first = self.snapshot()
out = self.run_import()
self.assertEqual(self.snapshot(), first)
self.assertIn('2 matched, 0 created', out)
self.assertIn('Settings: changed: speed 0, skip intro 0, skip outro 0, volume 0, include words 0, '
'exclude words 0, min duration 0, new episodes 0, auto-download 0', out)
self.assertIn('0 created, 0 skipped', out)
self.assertIn('Progress: 0 new, 0 updated, 4 unchanged, 0 kept (diora newer); 0 taken out', out)
class DownloadImportTests(ImportTestCase):
"""Episodes on the phone in AntennaPod: offered to the app for download."""
def setUp(self):
super().setUp()
self.feed = self.diora_feed()
self.ap_feed = self.ap.feed('https://example.org/feed')
def offered(self):
return PodcastSettings.for_user(self.user).import_downloads
def test_downloads_are_offered(self):
played = self.episode(self.feed, 'played')
known = self.episode(self.feed, 'known')
self.ap.item(self.ap_feed, 'played', read=1, pub=T0 + timedelta(days=1), downloaded=1, size=30_000_000)
self.ap.item(self.ap_feed, 'known', read=0, pub=T0 + timedelta(days=3), downloaded=ms(T0), size=20_400_000)
self.ap.item(self.ap_feed, 'on-phone', read=0, pub=T0 + timedelta(days=2), downloaded=1,
size=27_634_898_567_168) # garbage: unknown
self.ap.item(self.ap_feed, 'not-downloaded', read=0)
self.ap.item(self.ap_feed, 'local', read=0, audio='antennapod_local:x', downloaded=1, size=5)
self.ap.item(self.ap.feed('antennapod_local:folder'), 'file', read=0, downloaded=1, size=7)
before = self.snapshot()
out = self.run_import('--dry-run')
self.assertEqual(self.snapshot(), before)
self.assertFalse(PodcastSettings.objects.filter(user=self.user).exists())
self.assertIn('Downloads: 3 on the phone in AntennaPod (50 MB) -> offered to the app for download '
'(1 episodes created for them); 2 not found (feeds not imported, no audio URL)\n', out)
out = self.run_import()
created = PodcastEpisode.objects.get(feed=self.feed, guid='on-phone')
self.assertEqual(self.offered(), [ # newest first
{'episode_id': known.id, 'size': 20_400_000}, {'episode_id': created.id, 'size': 0},
{'episode_id': played.id, 'size': 30_000_000}])
# Created only to be downloaded; not new in AntennaPod, so not in the inbox.
p = self.progress(created)
self.assertEqual((p.played, p.dismissed), (False, True))
self.assertFalse(PodcastEpisode.objects.filter(guid__in=['not-downloaded', 'local', 'file']).exists())
self.assertIn('Episodes: 1 with a state: 1 matched by guid, 0 by URL, 0 created, '
'0 skipped (no audio URL), 0 in skipped feeds', out)
# Another backup later: its downloads replace the list.
self.ap.db.execute('UPDATE FeedMedia SET downloaded = 0 WHERE filesize != 30000000')
self.ap.db.commit()
out = self.run_import()
self.assertEqual(self.offered(), [{'episode_id': played.id, 'size': 30_000_000}])
self.assertIn('Downloads: 1 on the phone in AntennaPod (30 MB) -> offered to the app for download\n', out)
self.ap.db.execute('UPDATE FeedMedia SET downloaded = 0')
self.ap.db.commit()
out = self.run_import()
self.assertEqual(self.offered(), [])
self.assertIn('Downloads: 0 on the phone in AntennaPod (0 MB)', out)
def test_no_downloads_offers_nothing(self):
self.episode(self.feed, 'known')
self.ap.item(self.ap_feed, 'known', read=0, downloaded=1, size=20_400_000)
self.run_import()
self.assertEqual(len(self.offered()), 1)
out = self.run_import('--no-downloads')
self.assertEqual(self.offered(), [])
self.assertIn('Downloads: 1 on the phone in AntennaPod (20 MB) -> not offered (--no-downloads)\n', out)
def test_one_entry_per_episode(self):
ep = self.episode(self.feed, 'g')
self.ap.item(self.ap_feed, 'g', read=1, downloaded=1, size=10)
self.ap.item(self.ap.feed('http://www.example.org/feed/'), 'g', read=1, downloaded=1, size=10)
self.run_import()
self.assertEqual(self.offered(), [{'episode_id': ep.id, 'size': 10}])
def test_backup_without_download_columns(self):
self.ap.db.executescript('ALTER TABLE FeedMedia DROP COLUMN downloaded; '
'ALTER TABLE FeedMedia DROP COLUMN filesize;')
self.ap.db.execute('INSERT INTO FeedItems (title, read, feed, item_identifier) VALUES (?,?,?,?)',
('G', 1, self.ap_feed, 'g'))
self.ap.db.commit()
PodcastSettings.objects.create(user=self.user, import_downloads=[{'episode_id': 1, 'size': 1}])
out = self.run_import()
self.assertEqual(self.offered(), [])
self.assertIn('Downloads: 0 on the phone', out)
class ImportDownloadsViewTests(TestCase):
"""GET import-downloads/, its clear/, and the count in state/."""
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='A')
self.ep = PodcastEpisode.objects.create(feed=feed, guid='e', title='E', audio_url='https://example.org/e.mp3',
duration_seconds=1800, pub_date=T0)
EpisodeProgress.objects.create(user=self.user, episode=self.ep, position_seconds=120)
no_audio = PodcastEpisode.objects.create(feed=feed, guid='n', title='N', audio_url='')
bob = User.objects.create_user(username='bob', password='pw12345678')
bobs_feed = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='A')
bobs = PodcastEpisode.objects.create(feed=bobs_feed, guid='b', title='B', audio_url='https://example.org/b.mp3')
gone_feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/gone', title='Gone')
gone = PodcastEpisode.objects.create(feed=gone_feed, guid='g', title='G', audio_url='https://example.org/g.mp3')
gone_feed.delete() # unsubscribed since the import
PodcastSettings.objects.create(user=self.user, import_downloads=[
{'episode_id': i, 'size': 1000 + n} for n, i in enumerate((bobs.id, self.ep.id, no_audio.id, gone.id))])
def test_offered_episodes(self):
data = self.client.get('/podcasts/import-downloads/').json()
self.assertEqual(data['bytes'], 1001)
[e] = data['episodes']
self.assertEqual((e['id'], e['title'], e['feed__title'], e['duration_seconds'], e['pub_date'],
e['position_seconds'], e['in_queue'], e['size']),
(self.ep.id, 'E', 'A', 1800, T0.isoformat(), 120, False, 1001))
# The same keys as an inbox entry of state/, plus the size.
state = self.client.get('/podcasts/state/').json()
self.assertEqual(set(e), set(state['inbox'][0]) | {'size'})
self.assertEqual(state['import_downloads'], {'count': 1, 'bytes': 1001})
def test_clear(self):
self.assertEqual(self.client.get('/podcasts/import-downloads/clear/').status_code, 405)
r = self.client.post('/podcasts/import-downloads/clear/', '{}', content_type='application/json')
self.assertEqual(r.json(), {'ok': True})
self.assertEqual(PodcastSettings.objects.get(user=self.user).import_downloads, [])
self.assertEqual(self.client.get('/podcasts/import-downloads/').json(), {'episodes': [], 'bytes': 0})
self.assertEqual(self.client.get('/podcasts/state/').json()['import_downloads'], {'count': 0, 'bytes': 0})
def test_other_user_and_login(self):
self.client.force_login(User.objects.get(username='bob'))
self.assertEqual(self.client.get('/podcasts/import-downloads/').json(), {'episodes': [], 'bytes': 0})
self.assertEqual(self.client.get('/podcasts/state/').json()['import_downloads'], {'count': 0, 'bytes': 0})
self.client.logout()
self.assertEqual(self.client.get('/podcasts/import-downloads/').status_code, 401)
self.assertEqual(self.client.post('/podcasts/import-downloads/clear/').status_code, 401)
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_replace_is_a_clean_slate(self):
archived = PodcastFeed.objects.get(rss_url='https://example.org/archived')
ap_new = PodcastEpisode.objects.get(guid='ap-new')
old = PodcastEpisode.objects.get(guid='unknown-old')
late = PodcastEpisode.objects.get(guid='unknown-late')
PodcastQueue.objects.create(user=self.user, episode=late, position=0)
other = PodcastEpisode.objects.get(guid='kept-unknown')
PodcastQueue.objects.create(user=self.user, episode=other, position=1)
EpisodeProgress.objects.create(user=self.user, episode=old, position_seconds=30) # changed in diora now
queued = self.ap.item(self.ap_feed, 'unknown-late', read=0, pub=None)
self.ap.queue(queued)
out = self.run_import('--replace')
self.assertFalse(PodcastFeed.objects.filter(rss_url='https://example.org/only-diora').exists())
self.assertFalse(PodcastEpisode.objects.filter(guid__startswith='only-').exists())
self.assertEqual(self.queue(), [late.id])
self.assertTrue(self.progress(old).dismissed) # AntennaPod wins
self.assertIn('ap-new', self.inbox())
self.assertTrue(archived.episodes.filter(id=ap_new.id).exists())
self.assertIn(', 1 only in diora unsubscribed (--replace)', out)
self.assertIn(', 1 removed (--replace))', out)
self.assertIn('Unsubscribed feeds (1):\n - Diora\n', 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."""
def test_second_import_after_the_first_fetch(self):
import feedparser
from .views import FeedFetch, _refresh_feed
ap_feed = self.ap.feed('https://new.example.org/rss')
days = {'old1': 1, 'old2': 2, 'new': 3, 'newest': 4}
for guid, read in (('old1', 0), ('old2', 1), ('new', -1), ('newest', 0)):
self.ap.item(ap_feed, guid, read=read, pub=T0 + timedelta(days=days[guid]))
self.run_import()
feed = PodcastFeed.objects.get(user=self.user)
self.assertEqual(sorted(feed.episodes.values_list('guid', flat=True)), ['new', 'old2'])
def date(d):
return (T0 + timedelta(days=d)).strftime('%a, %d %b %Y %H:%M:%S GMT')
items = ''.join(
f'<item><guid>{g}</guid><title>{g}</title><pubDate>{date(d)}</pubDate><enclosure url="https://cdn.example.org/{g}.mp3" type="audio/mpeg" length="1"/></item>'
for g, d in days.items())
rss = f'<?xml version="1.0"?><rss version="2.0"><channel><title>New</title>{items}</channel></rss>'
with patch('podcasts.views._fetch_feed', lambda url, **kw: FeedFetch(200, feedparser.parse(rss))):
_refresh_feed(feed)
self.assertEqual(feed.episodes.count(), 4) # nothing twice
def inbox():
hidden = EpisodeProgress.objects.filter(user=self.user, dismissed=True).values('episode_id')
return sorted(feed.episodes.exclude(id__in=hidden).values_list('guid', flat=True))
self.assertEqual(inbox(), ['new', 'newest']) # the first-fetch rule leaves the newest
out = self.run_import()
self.assertEqual(inbox(), ['new'])
self.assertIn('2 without a state matched to diora episodes', out)
self.assertIn('1 taken out of the inbox', out)
class CommandErrorTests(ImportTestCase):
def test_unknown_user_and_bad_files(self):
with self.assertRaisesMessage(CommandError, 'No such user'):
self.run_import(user='nobody')
with self.assertRaisesMessage(CommandError, 'No such file'):
call_command('import_antennapod', os.path.join(self.tmp.name, 'missing.db'), '--user', 'alice')
other = os.path.join(self.tmp.name, 'other.db')
sqlite3.connect(other).close()
with self.assertRaisesMessage(CommandError, 'not an AntennaPod backup'):
call_command('import_antennapod', other, '--user', 'alice')
text = os.path.join(self.tmp.name, 'text.db')
with open(text, 'w') as f:
f.write('not a database at all, just text' * 10)
with self.assertRaisesMessage(CommandError, 'not'):
call_command('import_antennapod', text, '--user', 'alice')
with self.assertRaisesMessage(CommandError, 'Invalid --backup-date'):
call_command('import_antennapod', self.ap.path, '--user', 'alice', '--backup-date', 'gestern')