Add selectable animdl download fallback

This commit is contained in:
Dymas
2026-07-19 14:17:13 +02:00
parent 6bec0e068a
commit 6b3745c7e9
10 changed files with 282 additions and 65 deletions
+121 -2
View File
@@ -779,6 +779,36 @@ class QueueApiTests(unittest.TestCase):
)
self.assertTrue(Path(moved[0]).exists())
def test_tv_finalizer_uses_requested_episode_numbers_for_animdl_fallback(self):
with tempfile.TemporaryDirectory() as temp_root:
job = APP.app_support.build_job(
{
"query": "Fallback Show",
"title": "Fallback Show",
"anime_name": "Fallback Show",
"media_type": "tv",
"mode": "sub",
"quality": "best",
"episodes": "12",
"download_dir": temp_root,
"season": "2",
},
{"mode": "sub", "quality": "best", "download_dir": temp_root},
)
job["download_backend"] = "animdl"
staging_dir = APP.app_support.job_staging_dir(job)
staging_dir.mkdir(parents=True, exist_ok=True)
source = staging_dir / "Fallback Show - S02E01.mp4"
source.write_bytes(b"fallback")
moved = APP.app_support.finalize_library_files(job)
self.assertEqual(
moved,
[f"{temp_root}/tv/Fallback Show/Season 02/Fallback Show - S02E12.mp4"],
)
self.assertTrue(Path(moved[0]).exists())
def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self):
queue = object.__new__(APP.DownloadQueue)
queue.lock = threading.RLock()
@@ -1006,6 +1036,51 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
self.assertEqual(commands[1][0], APP.app_support.ANIPY_CLI)
self.assertIn("Fallback download completed with anipy-cli.", stored["log"])
def test_selected_download_methods_retry_through_animdl(self):
queue = APP.DownloadQueue(
lambda: {
"mode": "sub",
"quality": "best",
"download_dir": "/tmp/example",
"download_methods": ["ani-cli", "anipy-cli", "animdl"],
},
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"], 1),
FakeProc(6543, ["animdl 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([command[0] for command in commands], [APP.app_support.ANI_CLI, APP.app_support.ANIPY_CLI, APP.app_support.ANIMDL])
self.assertIn("Fallback download completed with animdl.", stored["log"])
class WatchlistRefreshAllTests(unittest.TestCase):
def test_refresh_all_iterates_only_watching_and_planned_show_ids(self):
@@ -1849,6 +1924,25 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertIn("-l", command)
self.assertEqual(command[-1], "/tmp/anipy-staging")
def test_command_for_job_builds_animdl_download_command(self):
command = APP.app_support.command_for_job(
{
"query": "Re:Zero",
"quality": "720",
"episodes": "1-3",
"mode": "dub",
},
backend="animdl",
download_path="/tmp/animdl-staging",
)
self.assertEqual(command[0], APP.app_support.ANIMDL)
self.assertEqual(command[1:4], ["download", "Re:Zero", "-r"])
self.assertIn("-q", command)
self.assertIn("--index", command)
self.assertIn("-d", command)
self.assertEqual(command[-1], "/tmp/animdl-staging")
def test_download_watchlist_item_queues_full_episode_range_for_selected_mode(self):
item = {
"show_id": "show-42",
@@ -2330,6 +2424,7 @@ class ConfigSnapshotTests(unittest.TestCase):
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 5)
self.assertFalse(config["anipy_cli_fallback_enabled"])
self.assertEqual(config["download_methods"], ["ani-cli"])
self.assertFalse(config["auto_download_enabled"])
self.assertEqual(config["auto_download_mode"], "dub")
self.assertEqual(config["auto_download_quality"], "best")
@@ -2355,6 +2450,7 @@ class ConfigSnapshotTests(unittest.TestCase):
}
)
self.assertTrue(config["anipy_cli_fallback_enabled"])
self.assertEqual(config["download_methods"], ["ani-cli", "anipy-cli"])
self.assertTrue(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 7)
@@ -2365,6 +2461,16 @@ class ConfigSnapshotTests(unittest.TestCase):
self.assertIn("jellyfin", config["jellyfin_tv_dir"])
self.assertIn("jellyfin", config["jellyfin_movie_dir"])
def test_normalize_config_filters_download_methods(self):
config = APP.normalize_config(
{
"download_methods": ["animdl", "bad-method", "anipy-cli", "animdl"],
}
)
self.assertEqual(config["download_methods"], ["animdl", "anipy-cli"])
self.assertTrue(config["anipy_cli_fallback_enabled"])
def test_normalize_config_filters_discord_webhook_events(self):
config = APP.normalize_config(
{
@@ -2952,6 +3058,7 @@ class HandlerRouteTests(unittest.TestCase):
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertTrue(handler.json_payload["anipy_cli_fallback_enabled"])
self.assertEqual(handler.json_payload["download_methods"], ["ani-cli", "anipy-cli"])
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)
@@ -2962,6 +3069,14 @@ 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_config_post_accepts_download_methods(self):
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","download_methods":["ani-cli","animdl"]}'
handler = DummyHandler("/api/config", body=body, content_length=len(body))
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertEqual(handler.json_payload["download_methods"], ["ani-cli", "animdl"])
self.assertTrue(handler.json_payload["anipy_cli_fallback_enabled"])
def test_version_route_includes_cli_versions(self):
handler = DummyHandler("/api/version")
APP.Handler.do_GET(handler)
@@ -2970,12 +3085,14 @@ class HandlerRouteTests(unittest.TestCase):
self.assertEqual(handler.json_payload["version"], APP.VERSION)
self.assertIn("ani_cli_version", handler.json_payload)
self.assertIn("anipy_cli_version", handler.json_payload)
self.assertIn("animdl_version", handler.json_payload)
def test_dependencies_route_reports_anipy_cli_status(self):
def test_dependencies_route_reports_fallback_tool_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)
self.assertIn("animdl", handler.json_payload)
def test_clear_failed_route_removes_failed_jobs(self):
with APP.DOWNLOAD_QUEUE._connect() as conn:
@@ -3542,9 +3659,11 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn("justify-items: stretch;", APP.CONFIG_HTML)
self.assertIn('id="aniCliVersionLine"', APP.CONFIG_HTML)
self.assertIn('id="anipyCliVersionLine"', APP.CONFIG_HTML)
self.assertIn('id="animdlVersionLine"', 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('animdl ${data.animdl_version || "Unavailable"}', APP.CONFIG_HTML)
self.assertIn('class="download-method" type="checkbox" value="animdl"', 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)