Add anipy-cli download fallback
Add an optional config toggle that retries failed ani-cli downloads with anipy-cli, expose fallback runtime status in the config page, and update release docs and tests for the new behavior.
This commit is contained in:
+78
-2
@@ -771,6 +771,51 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
||||
self.assertIn("Watchlist sync skipped for detached job.", stored["log"])
|
||||
self.assertNotIn("Watchlist download state synced.", stored["log"])
|
||||
|
||||
def test_failed_ani_cli_retries_with_anipy_when_enabled(self):
|
||||
queue = APP.DownloadQueue(
|
||||
lambda: {
|
||||
"mode": "sub",
|
||||
"quality": "best",
|
||||
"download_dir": "/tmp/example",
|
||||
"anipy_cli_fallback_enabled": True,
|
||||
},
|
||||
start_worker=False,
|
||||
)
|
||||
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
||||
commands = []
|
||||
|
||||
class FakeProc:
|
||||
def __init__(self, pid, stdout, exit_code):
|
||||
self.pid = pid
|
||||
self.stdout = stdout
|
||||
self._exit_code = exit_code
|
||||
|
||||
def wait(self):
|
||||
return self._exit_code
|
||||
|
||||
processes = [
|
||||
FakeProc(4321, ["ani-cli attempt\n"], 1),
|
||||
FakeProc(5432, ["anipy-cli attempt\n"], 0),
|
||||
]
|
||||
|
||||
def fake_popen(command, **kwargs):
|
||||
commands.append(command)
|
||||
return processes.pop(0)
|
||||
|
||||
with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=True), mock.patch.object(
|
||||
queue_jobs.subprocess, "Popen", side_effect=fake_popen
|
||||
), mock.patch.object(
|
||||
queue_jobs, "finalize_library_files", return_value=["/tmp/example/Queue Show - S01E01.mp4"]
|
||||
):
|
||||
queue._run_job(job)
|
||||
|
||||
stored = queue._find(job["id"])
|
||||
self.assertEqual(stored["status"], "done")
|
||||
self.assertTrue(stored["fallback_used"])
|
||||
self.assertEqual(commands[0][0], APP.app_support.ANI_CLI)
|
||||
self.assertEqual(commands[1][0], APP.app_support.ANIPY_CLI)
|
||||
self.assertIn("Fallback download completed with anipy-cli.", stored["log"])
|
||||
|
||||
|
||||
class WatchlistRefreshAllTests(unittest.TestCase):
|
||||
def test_refresh_all_iterates_only_watching_and_planned_show_ids(self):
|
||||
@@ -1597,6 +1642,23 @@ class WatchlistCompletionTests(unittest.TestCase):
|
||||
|
||||
self.assertNotIn("-S", command)
|
||||
|
||||
def test_command_for_job_builds_anipy_fallback_search_spec(self):
|
||||
command = APP.app_support.command_for_job(
|
||||
{
|
||||
"query": "Re:Zero",
|
||||
"quality": "720",
|
||||
"episodes": "1-3",
|
||||
"mode": "dub",
|
||||
},
|
||||
backend="anipy-cli",
|
||||
download_path="/tmp/anipy-staging",
|
||||
)
|
||||
|
||||
self.assertEqual(command[0], APP.app_support.ANIPY_CLI)
|
||||
self.assertEqual(command[1:5], ["-D", "-s", "Re Zero:1-3:dub", "-q"])
|
||||
self.assertIn("-l", command)
|
||||
self.assertEqual(command[-1], "/tmp/anipy-staging")
|
||||
|
||||
def test_download_watchlist_item_queues_full_episode_range_for_selected_mode(self):
|
||||
item = {
|
||||
"show_id": "show-42",
|
||||
@@ -2077,6 +2139,7 @@ class ConfigSnapshotTests(unittest.TestCase):
|
||||
self.assertFalse(config["watchlist_auto_refresh_enabled"])
|
||||
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
|
||||
self.assertEqual(config["watchlist_refresh_delay_seconds"], 5)
|
||||
self.assertFalse(config["anipy_cli_fallback_enabled"])
|
||||
self.assertFalse(config["auto_download_enabled"])
|
||||
self.assertEqual(config["auto_download_mode"], "dub")
|
||||
self.assertEqual(config["auto_download_quality"], "best")
|
||||
@@ -2089,6 +2152,7 @@ class ConfigSnapshotTests(unittest.TestCase):
|
||||
def test_normalize_config_parses_watchlist_schedule_fields(self):
|
||||
config = APP.normalize_config(
|
||||
{
|
||||
"anipy_cli_fallback_enabled": "true",
|
||||
"watchlist_auto_refresh_enabled": "true",
|
||||
"watchlist_auto_refresh_minutes": "15",
|
||||
"watchlist_refresh_delay_seconds": "7",
|
||||
@@ -2100,6 +2164,7 @@ class ConfigSnapshotTests(unittest.TestCase):
|
||||
"jellyfin_movie_dir": "~/jellyfin/movies",
|
||||
}
|
||||
)
|
||||
self.assertTrue(config["anipy_cli_fallback_enabled"])
|
||||
self.assertTrue(config["watchlist_auto_refresh_enabled"])
|
||||
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
|
||||
self.assertEqual(config["watchlist_refresh_delay_seconds"], 7)
|
||||
@@ -2692,10 +2757,11 @@ class HandlerRouteTests(unittest.TestCase):
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
|
||||
def test_config_post_accepts_watchlist_schedule_fields(self):
|
||||
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9","auto_download_enabled":true,"auto_download_mode":"dub","auto_download_quality":"720","jellyfin_sync_enabled":true,"jellyfin_tv_dir":"/tmp/jellyfin-tv","jellyfin_movie_dir":"/tmp/jellyfin-movies"}'
|
||||
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","anipy_cli_fallback_enabled":true,"watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9","auto_download_enabled":true,"auto_download_mode":"dub","auto_download_quality":"720","jellyfin_sync_enabled":true,"jellyfin_tv_dir":"/tmp/jellyfin-tv","jellyfin_movie_dir":"/tmp/jellyfin-movies"}'
|
||||
handler = DummyHandler("/api/config", body=body, content_length=len(body))
|
||||
APP.Handler.do_POST(handler)
|
||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||
self.assertTrue(handler.json_payload["anipy_cli_fallback_enabled"])
|
||||
self.assertTrue(handler.json_payload["watchlist_auto_refresh_enabled"])
|
||||
self.assertEqual(handler.json_payload["watchlist_auto_refresh_minutes"], 25)
|
||||
self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9)
|
||||
@@ -2706,13 +2772,20 @@ class HandlerRouteTests(unittest.TestCase):
|
||||
self.assertEqual(handler.json_payload["jellyfin_tv_dir"], "/tmp/jellyfin-tv")
|
||||
self.assertEqual(handler.json_payload["jellyfin_movie_dir"], "/tmp/jellyfin-movies")
|
||||
|
||||
def test_version_route_includes_ani_cli_version(self):
|
||||
def test_version_route_includes_cli_versions(self):
|
||||
handler = DummyHandler("/api/version")
|
||||
APP.Handler.do_GET(handler)
|
||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||
self.assertEqual(handler.json_payload["name"], APP.APP_NAME)
|
||||
self.assertEqual(handler.json_payload["version"], APP.VERSION)
|
||||
self.assertIn("ani_cli_version", handler.json_payload)
|
||||
self.assertIn("anipy_cli_version", handler.json_payload)
|
||||
|
||||
def test_dependencies_route_reports_anipy_cli_status(self):
|
||||
handler = DummyHandler("/api/dependencies")
|
||||
APP.Handler.do_GET(handler)
|
||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||
self.assertIn("anipy-cli", handler.json_payload)
|
||||
|
||||
def test_changelog_route_returns_project_changelog_content(self):
|
||||
handler = DummyHandler("/api/changelog")
|
||||
@@ -3244,7 +3317,10 @@ class TemplateHelperTests(unittest.TestCase):
|
||||
self.assertIn("@media (max-width: 1080px)", APP.CONFIG_HTML)
|
||||
self.assertIn("justify-items: stretch;", APP.CONFIG_HTML)
|
||||
self.assertIn('id="aniCliVersionLine"', APP.CONFIG_HTML)
|
||||
self.assertIn('id="anipyCliVersionLine"', APP.CONFIG_HTML)
|
||||
self.assertIn('ani-cli ${data.ani_cli_version || "Unavailable"}', APP.CONFIG_HTML)
|
||||
self.assertIn('anipy-cli ${data.anipy_cli_version || "Unavailable"}', APP.CONFIG_HTML)
|
||||
self.assertIn('id="anipyCliFallbackEnabled"', APP.CONFIG_HTML)
|
||||
self.assertIn('id="openChangelogBtn"', APP.CONFIG_HTML)
|
||||
self.assertIn('id="changelogOverlay"', APP.CONFIG_HTML)
|
||||
self.assertIn('const data = await api("/api/changelog");', APP.CONFIG_HTML)
|
||||
|
||||
Reference in New Issue
Block a user