5167 lines
226 KiB
Python
5167 lines
226 KiB
Python
#!/usr/bin/env python3
|
|
import base64
|
|
from email.message import Message
|
|
import http.cookiejar
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
import urllib.error
|
|
from http import HTTPStatus
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
MODULE_PATH = ROOT / "app.py"
|
|
TEMP_STATE = tempfile.TemporaryDirectory()
|
|
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
import queue_jobs
|
|
import http_handler
|
|
import provider_bridge
|
|
import provider_downloader
|
|
import provider_updates
|
|
|
|
os.environ.setdefault("KAIZOKU_DISABLE_WORKER", "1")
|
|
os.environ["KAIZOKU_STATE_ROOT"] = TEMP_STATE.name
|
|
|
|
SPEC = importlib.util.spec_from_file_location("kaizoku_app", MODULE_PATH)
|
|
APP = importlib.util.module_from_spec(SPEC)
|
|
assert SPEC.loader is not None
|
|
SPEC.loader.exec_module(APP)
|
|
IMPORT_RUNTIME_WAS_DEFERRED = (
|
|
APP.CONFIG is None
|
|
and APP.WATCHLIST is None
|
|
and APP.DOWNLOAD_QUEUE is None
|
|
and APP.WATCHLIST_REFRESH is None
|
|
and APP.WATCHLIST_JELLYFIN_SYNC is None
|
|
)
|
|
APP.initialize_runtime(start_workers=False)
|
|
APP.reset_runtime(wait=True)
|
|
APP.initialize_runtime(start_workers=False)
|
|
unittest.addModuleCleanup(lambda: APP.reset_runtime(wait=True))
|
|
|
|
|
|
class DummyHandler:
|
|
def __init__(self, path, body=b"{}", content_length=None):
|
|
self.path = path
|
|
self.client_address = ("127.0.0.1", 8421)
|
|
self.handler_context = APP.Handler.handler_context
|
|
self.headers = {}
|
|
if content_length is not None:
|
|
self.headers["Content-Length"] = str(content_length)
|
|
self.headers.setdefault("Content-Type", "application/json")
|
|
self.rfile = io.BytesIO(body)
|
|
self.json_payload = None
|
|
self.json_status = None
|
|
self.error_status = None
|
|
self.error_message = None
|
|
self.file_path = None
|
|
self.response_status = None
|
|
self.response_headers = []
|
|
self.wfile = io.BytesIO()
|
|
|
|
def ensure_client_access(self):
|
|
return APP.Handler.ensure_client_access(self)
|
|
|
|
def body_json(self):
|
|
return APP.Handler.body_json(self)
|
|
|
|
def json(self, payload, status=HTTPStatus.OK):
|
|
self.json_payload = payload
|
|
self.json_status = status
|
|
|
|
def json_attachment(self, payload, filename, status=HTTPStatus.OK):
|
|
return APP.Handler.json_attachment(self, payload, filename, status=status)
|
|
|
|
def write_response_bytes(self, payload, status, headers):
|
|
return APP.Handler.write_response_bytes(self, payload, status, headers)
|
|
|
|
def error(self, status, message):
|
|
self.error_status = status
|
|
self.error_message = message
|
|
|
|
def file(self, path):
|
|
self.file_path = path
|
|
|
|
def html(self, _content):
|
|
raise AssertionError("HTML should not be served in this test")
|
|
|
|
def exception(self, exc):
|
|
return APP.Handler.exception(self, exc)
|
|
|
|
def send_response(self, status):
|
|
self.response_status = status
|
|
|
|
def send_header(self, key, value):
|
|
self.response_headers.append((key, value))
|
|
|
|
def end_headers(self):
|
|
return None
|
|
|
|
|
|
class NetworkGuardTests(unittest.TestCase):
|
|
def test_runtime_bootstrap_is_deferred_until_initialize(self):
|
|
self.assertTrue(IMPORT_RUNTIME_WAS_DEFERRED)
|
|
|
|
def test_client_address_is_local_accepts_loopback(self):
|
|
self.assertTrue(APP.client_address_is_local("127.0.0.1"))
|
|
self.assertTrue(APP.client_address_is_local("::1"))
|
|
self.assertTrue(APP.client_address_is_local("localhost"))
|
|
|
|
def test_client_address_is_local_rejects_non_loopback(self):
|
|
self.assertFalse(APP.client_address_is_local("192.168.1.25"))
|
|
self.assertFalse(APP.client_address_is_local("10.0.0.8"))
|
|
|
|
def test_remote_access_requires_credentials_for_non_local_clients(self):
|
|
handler = DummyHandler("/api/config")
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
|
|
with mock.patch.dict(os.environ, {"KAIZOKU_ALLOW_REMOTE": "1"}, clear=False):
|
|
with self.assertRaises(PermissionError) as ctx:
|
|
APP.Handler.ensure_client_access(handler)
|
|
|
|
self.assertIn("AUTH_USERNAME", str(ctx.exception))
|
|
|
|
def test_remote_access_accepts_valid_basic_auth_for_non_local_clients(self):
|
|
handler = DummyHandler("/api/config")
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
encoded = base64.b64encode(b"demo:secret-pass").decode("ascii")
|
|
handler.headers["Authorization"] = f"Basic {encoded}"
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.ensure_client_access(handler)
|
|
|
|
def test_remote_access_accepts_valid_session_cookie_for_non_local_clients(self):
|
|
handler = DummyHandler("/api/config")
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
session_value = http_handler.remote_access_session_value(
|
|
{"auth_username": "demo", "auth_password": "secret-pass"}
|
|
)
|
|
handler.headers["Cookie"] = f"kaizoku_session={session_value}"
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.ensure_client_access(handler)
|
|
|
|
def test_remote_browser_get_without_session_renders_login_page(self):
|
|
handler = DummyHandler("/")
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
handler.command = "GET"
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.do_GET(handler)
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
self.assertEqual(handler.response_status, HTTPStatus.UNAUTHORIZED)
|
|
self.assertIn('<form method="post" action="/auth/login">', body)
|
|
self.assertIn("Username", body)
|
|
self.assertIn("Password", body)
|
|
|
|
def test_favicon_route_serves_checked_in_png(self):
|
|
handler = DummyHandler("/favicon.png")
|
|
handler.command = "GET"
|
|
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.file_path, http_handler.FAVICON_PATH)
|
|
|
|
def test_remote_login_page_escapes_hidden_next_value(self):
|
|
handler = DummyHandler('/?next="><script>alert(1)</script>')
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
handler.command = "GET"
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.do_GET(handler)
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
self.assertNotIn('<script>alert(1)</script>', body)
|
|
self.assertIn('value="/?next=%22%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E"', body)
|
|
|
|
def test_remote_login_post_sets_cookie_and_redirects(self):
|
|
body = b"username=demo&password=secret-pass&next=%2Fwatchlist"
|
|
handler = DummyHandler("/auth/login", body=body, content_length=len(body))
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
handler.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.do_POST(handler)
|
|
|
|
headers = dict(handler.response_headers)
|
|
self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER)
|
|
self.assertEqual(headers.get("Location"), "/watchlist")
|
|
self.assertIn("kaizoku_session=", headers.get("Set-Cookie", ""))
|
|
|
|
def test_remote_session_cookie_rejects_missing_origin_on_post(self):
|
|
body = b'{"show_id":"show-1"}'
|
|
handler = DummyHandler("/api/watchlist/remove", body=body, content_length=len(body))
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
handler.headers["Host"] = "anime.example:8421"
|
|
handler.headers["Cookie"] = (
|
|
"kaizoku_session="
|
|
+ http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"})
|
|
)
|
|
handler.handler_context = mock.Mock(remove_from_watchlist=mock.Mock(return_value={"ok": True}), http_error=APP.HttpError)
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.do_POST(handler)
|
|
|
|
self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN)
|
|
self.assertIn("origin", handler.error_message.lower())
|
|
|
|
def test_remote_session_cookie_rejects_cross_origin_post(self):
|
|
body = b'{"show_id":"show-1"}'
|
|
handler = DummyHandler("/api/watchlist/remove", body=body, content_length=len(body))
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
handler.headers["Host"] = "anime.example:8421"
|
|
handler.headers["Origin"] = "https://elsewhere.example"
|
|
handler.headers["Cookie"] = (
|
|
"kaizoku_session="
|
|
+ http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"})
|
|
)
|
|
handler.handler_context = mock.Mock(remove_from_watchlist=mock.Mock(return_value={"ok": True}), http_error=APP.HttpError)
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.do_POST(handler)
|
|
|
|
self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN)
|
|
self.assertIn("cross-origin", handler.error_message.lower())
|
|
|
|
def test_remote_session_cookie_accepts_same_origin_post(self):
|
|
body = b'{"show_id":"show-1"}'
|
|
handler = DummyHandler("/api/watchlist/remove", body=body, content_length=len(body))
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
handler.headers["Host"] = "anime.example:8421"
|
|
handler.headers["Origin"] = "https://anime.example:8421"
|
|
handler.headers["Cookie"] = (
|
|
"kaizoku_session="
|
|
+ http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"})
|
|
)
|
|
remove_from_watchlist = mock.Mock(return_value={"ok": True})
|
|
handler.handler_context = mock.Mock(remove_from_watchlist=remove_from_watchlist, http_error=APP.HttpError)
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.do_POST(handler)
|
|
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
remove_from_watchlist.assert_called_once_with("show-1")
|
|
|
|
def test_search_thumbnail_route_serves_cached_file(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
image_path = Path(temp_dir) / "search-thumb.jpg"
|
|
image_path.write_bytes(b"fake-image-data")
|
|
handler = DummyHandler("/api/search/thumb?title=Demo")
|
|
handler.handler_context = mock.Mock(
|
|
ensure_runtime=mock.Mock(),
|
|
runtime_state=mock.Mock(return_value={"config": {}}),
|
|
resolve_search_thumbnail=mock.Mock(return_value=image_path),
|
|
)
|
|
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.file_path, image_path)
|
|
|
|
def test_loopback_proxy_with_forwarded_remote_ip_requires_credentials(self):
|
|
handler = DummyHandler("/api/config")
|
|
handler.client_address = ("127.0.0.1", 8421)
|
|
handler.headers["X-Forwarded-For"] = "203.0.113.10"
|
|
|
|
with mock.patch.dict(os.environ, {"KAIZOKU_ALLOW_REMOTE": "1"}, clear=False):
|
|
with self.assertRaises(PermissionError) as ctx:
|
|
APP.Handler.ensure_client_access(handler)
|
|
|
|
self.assertIn("AUTH_USERNAME", str(ctx.exception))
|
|
|
|
|
|
class DownloadQueueCancelTests(unittest.TestCase):
|
|
def setUp(self):
|
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
|
with queue._connect() as conn:
|
|
conn.execute("DELETE FROM jobs")
|
|
|
|
def test_cancel_updates_inflight_job_state(self):
|
|
queue = object.__new__(APP.DownloadQueue)
|
|
queue.lock = threading.RLock()
|
|
queue.current_job_id = "job-1"
|
|
queue.current_process = mock.Mock(pid=4321)
|
|
queue.current_job = {"id": "job-1", "status": "running", "cancel_requested": False}
|
|
|
|
saved = []
|
|
queue._save_job_locked = lambda job: saved.append(dict(job))
|
|
queue._find = lambda job_id: {"id": job_id, "status": "running", "cancel_requested": False}
|
|
|
|
with mock.patch.object(APP.os, "getpgid", return_value=4321), mock.patch.object(APP.os, "killpg") as killpg:
|
|
result = APP.DownloadQueue.cancel(queue, "job-1")
|
|
|
|
killpg.assert_called_once()
|
|
self.assertTrue(result["cancel_requested"])
|
|
self.assertTrue(queue.current_job["cancel_requested"])
|
|
self.assertEqual(saved[-1]["id"], "job-1")
|
|
self.assertTrue(saved[-1]["cancel_requested"])
|
|
|
|
def test_append_log_batches_persistence(self):
|
|
queue = object.__new__(APP.DownloadQueue)
|
|
queue.lock = threading.RLock()
|
|
saved = []
|
|
|
|
def fake_save(job):
|
|
saved.append(list(job.get("log", [])))
|
|
job["_dirty_log_lines"] = 0
|
|
job["_last_persist_monotonic"] = 0.0
|
|
|
|
queue._save_job_locked = fake_save
|
|
job = {"log": [], "updated_at": APP.now_iso()}
|
|
|
|
with mock.patch.object(queue_jobs.time, "monotonic", return_value=0.0):
|
|
for index in range(queue_jobs.LOG_PERSIST_LINE_BATCH - 1):
|
|
APP.DownloadQueue._append_log(queue, job, f"line {index}")
|
|
|
|
self.assertEqual(saved, [])
|
|
|
|
APP.DownloadQueue._append_log(queue, job, "line flush")
|
|
|
|
self.assertEqual(len(saved), 1)
|
|
self.assertEqual(saved[0][-1], "line flush")
|
|
|
|
def test_append_log_can_mirror_download_output_to_stdout(self):
|
|
queue = object.__new__(APP.DownloadQueue)
|
|
queue.lock = threading.RLock()
|
|
queue._save_job_locked = lambda _job: None
|
|
job = {"id": "job-1", "download_backend": "kaizoku", "log": [], "updated_at": APP.now_iso()}
|
|
|
|
with mock.patch.dict(os.environ, {"KAIZOKU_JOB_STDOUT": "true"}, clear=False), mock.patch("builtins.print") as print_mock:
|
|
APP.DownloadQueue._append_log(queue, job, "\x1b[32mDownloading episode 1\x1b[0m\n")
|
|
|
|
print_mock.assert_called_once_with(
|
|
"[download:job-1:kaizoku] Downloading episode 1",
|
|
flush=True,
|
|
)
|
|
self.assertEqual(job["log"], ["Downloading episode 1"])
|
|
|
|
def test_append_log_parses_kaizoku_progress_lines(self):
|
|
queue = object.__new__(APP.DownloadQueue)
|
|
queue.lock = threading.RLock()
|
|
queue._save_job_locked = lambda _job: None
|
|
job = {"id": "job-1", "download_backend": "kaizoku", "log": [], "updated_at": APP.now_iso()}
|
|
|
|
APP.DownloadQueue._append_log(
|
|
queue,
|
|
job,
|
|
'KAIZOKU_PROGRESS {"episode":1,"episode_index":1,"episode_total":12,"message":"Episode 1: downloaded segment 25/100 (25%).","percent":25,"segment":25,"segment_total":100}',
|
|
)
|
|
|
|
self.assertEqual(job["progress"]["percent"], 25)
|
|
self.assertEqual(job["progress"]["episode_total"], 12)
|
|
self.assertEqual(job["progress"]["segment_total"], 100)
|
|
self.assertEqual(job["log"], ["Episode 1: downloaded segment 25/100 (25%)."])
|
|
|
|
|
|
class QueueApiTests(unittest.TestCase):
|
|
def setUp(self):
|
|
APP.reset_runtime(wait=True)
|
|
APP.initialize_runtime(start_workers=False)
|
|
self.queue = APP.DOWNLOAD_QUEUE
|
|
with self.queue._connect() as conn:
|
|
conn.execute("DELETE FROM jobs")
|
|
conn.execute("DELETE FROM jellyfin_sync_jobs")
|
|
|
|
def test_download_queue_get_returns_single_job(self):
|
|
created = self.queue.add(
|
|
{
|
|
"query": "Example Show",
|
|
"title": "Example Show",
|
|
"anime_name": "Example Show",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
|
|
fetched = self.queue.get(created["id"])
|
|
|
|
self.assertEqual(fetched["id"], created["id"])
|
|
self.assertEqual(fetched["title"], "Example Show")
|
|
|
|
def test_queue_item_get_route_returns_single_job_payload(self):
|
|
created = self.queue.add(
|
|
{
|
|
"query": "Route Show",
|
|
"title": "Route Show",
|
|
"anime_name": "Route Show",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
handler = DummyHandler(f"/api/queue/{created['id']}")
|
|
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertEqual(handler.json_payload["id"], created["id"])
|
|
|
|
def test_queue_route_includes_jellyfin_handoff_jobs(self):
|
|
APP.WATCHLIST_JELLYFIN_SYNC.start(
|
|
config={
|
|
"download_dir": "/tmp/downloads",
|
|
"jellyfin_sync_enabled": True,
|
|
"jellyfin_tv_dir": "/tmp/jellyfin-tv",
|
|
"jellyfin_movie_dir": "/tmp/jellyfin-movies",
|
|
}
|
|
)
|
|
handler = DummyHandler("/api/queue?page=1&per_page=10")
|
|
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
jellyfin_jobs = [job for job in handler.json_payload["jobs"] if job.get("job_type") == "jellyfin_handoff"]
|
|
self.assertEqual(len(jellyfin_jobs), 1)
|
|
self.assertEqual(jellyfin_jobs[0]["title"], "Jellyfin handoff")
|
|
self.assertEqual(jellyfin_jobs[0]["mode"], "copy")
|
|
|
|
def test_queue_item_get_route_returns_jellyfin_handoff_job(self):
|
|
created = APP.WATCHLIST_JELLYFIN_SYNC.start(
|
|
config={
|
|
"download_dir": "/tmp/downloads",
|
|
"jellyfin_sync_enabled": True,
|
|
"jellyfin_tv_dir": "/tmp/jellyfin-tv",
|
|
"jellyfin_movie_dir": "",
|
|
}
|
|
)["job"]
|
|
handler = DummyHandler(f"/api/queue/{created['id']}")
|
|
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertEqual(handler.json_payload["id"], created["id"])
|
|
self.assertEqual(handler.json_payload["job_type"], "jellyfin_handoff")
|
|
|
|
def test_retry_rejects_done_jobs(self):
|
|
created = self.queue.add(
|
|
{
|
|
"query": "Done Show",
|
|
"title": "Done Show",
|
|
"anime_name": "Done Show",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
with self.queue.lock:
|
|
job = self.queue._find(created["id"])
|
|
job["status"] = "done"
|
|
self.queue._save_job_locked(job)
|
|
|
|
with self.assertRaisesRegex(ValueError, "Only failed or canceled jobs can be retried"):
|
|
self.queue.retry(created["id"])
|
|
|
|
def test_retry_allows_canceled_jobs(self):
|
|
created = self.queue.add(
|
|
{
|
|
"query": "Canceled Show",
|
|
"title": "Canceled Show",
|
|
"anime_name": "Canceled Show",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
with self.queue.lock:
|
|
job = self.queue._find(created["id"])
|
|
job["status"] = "canceled"
|
|
job["cancel_requested"] = True
|
|
self.queue._save_job_locked(job)
|
|
|
|
retried = self.queue.retry(created["id"])
|
|
|
|
self.assertEqual(retried["status"], "pending")
|
|
self.assertFalse(retried["cancel_requested"])
|
|
|
|
def test_retry_prepares_failed_job_before_requeue(self):
|
|
queue = APP.DownloadQueue(
|
|
lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"},
|
|
job_prepare_fn=lambda job: {**job, "result_index": 3},
|
|
start_worker=False,
|
|
)
|
|
created = queue.add(
|
|
{
|
|
"query": "Retry Show",
|
|
"title": "Retry Show",
|
|
"anime_name": "Retry Show",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
with queue.lock:
|
|
job = queue._find(created["id"])
|
|
job["status"] = "failed"
|
|
job["result_index"] = None
|
|
queue._save_job_locked(job)
|
|
|
|
retried = queue.retry(created["id"])
|
|
|
|
self.assertEqual(retried["status"], "pending")
|
|
self.assertEqual(retried["result_index"], 3)
|
|
|
|
def test_retry_all_failed_prepares_jobs_before_requeue(self):
|
|
queue = APP.DownloadQueue(
|
|
lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"},
|
|
job_prepare_fn=lambda job: {**job, "result_index": 4},
|
|
start_worker=False,
|
|
)
|
|
created = queue.add(
|
|
{
|
|
"query": "Retry All Show",
|
|
"title": "Retry All Show",
|
|
"anime_name": "Retry All Show",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
with queue.lock:
|
|
job = queue._find(created["id"])
|
|
job["status"] = "failed"
|
|
job["result_index"] = None
|
|
queue._save_job_locked(job)
|
|
|
|
result = queue.retry_all_failed()
|
|
retried = queue.get(created["id"])
|
|
|
|
self.assertEqual(result["count"], 1)
|
|
self.assertEqual(retried["status"], "pending")
|
|
self.assertEqual(retried["result_index"], 4)
|
|
|
|
def test_covered_episodes_includes_completed_and_remaining_retry_parts(self):
|
|
created = self.queue.add(
|
|
{
|
|
"show_id": "show-42",
|
|
"query": "Retry Coverage",
|
|
"title": "Retry Coverage",
|
|
"anime_name": "Retry Coverage",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1-3",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
with self.queue.lock:
|
|
job = self.queue._find(created["id"])
|
|
job["completed_episodes"] = ["1"]
|
|
job["retry_episodes"] = "2-3"
|
|
self.queue._save_job_locked(job)
|
|
|
|
covered = self.queue.covered_episodes("show-42", "sub", ["1", "2", "3", "4"])
|
|
|
|
self.assertEqual(covered, {"1", "2", "3"})
|
|
|
|
def test_add_reuses_matching_failed_job_instead_of_creating_duplicate(self):
|
|
payload = {
|
|
"show_id": "show-42",
|
|
"query": "Failed Show",
|
|
"title": "Failed Show",
|
|
"anime_name": "Failed Show",
|
|
"media_type": "tv",
|
|
"mode": "dub",
|
|
"quality": "best",
|
|
"episodes": "11",
|
|
"download_dir": "/tmp/example",
|
|
"season": "2",
|
|
"episode_offset": "13",
|
|
}
|
|
created = self.queue.add(payload)
|
|
with self.queue.lock:
|
|
job = self.queue._find(created["id"])
|
|
job["status"] = "failed"
|
|
job["exit_code"] = 1
|
|
job["cancel_requested"] = True
|
|
job["started_at"] = APP.now_iso()
|
|
job["finished_at"] = APP.now_iso()
|
|
job["log"] = ["Download failed"]
|
|
self.queue._save_job_locked(job)
|
|
|
|
retried = self.queue.add(payload)
|
|
listing = self.queue.list(per_page=10)
|
|
|
|
self.assertEqual(retried["id"], created["id"])
|
|
self.assertEqual(retried["status"], "pending")
|
|
self.assertEqual(retried["log"], [])
|
|
self.assertIsNone(retried["exit_code"])
|
|
self.assertFalse(retried["cancel_requested"])
|
|
self.assertEqual(listing["total"], 1)
|
|
|
|
def test_add_keeps_new_job_when_failed_job_differs(self):
|
|
created = self.queue.add(
|
|
{
|
|
"show_id": "show-42",
|
|
"query": "Failed Show",
|
|
"title": "Failed Show",
|
|
"anime_name": "Failed Show",
|
|
"media_type": "tv",
|
|
"mode": "dub",
|
|
"quality": "best",
|
|
"episodes": "11",
|
|
"download_dir": "/tmp/example",
|
|
"season": "2",
|
|
"episode_offset": "13",
|
|
}
|
|
)
|
|
with self.queue.lock:
|
|
job = self.queue._find(created["id"])
|
|
job["status"] = "failed"
|
|
self.queue._save_job_locked(job)
|
|
|
|
new_job = self.queue.add(
|
|
{
|
|
"show_id": "show-42",
|
|
"query": "Failed Show",
|
|
"title": "Failed Show",
|
|
"anime_name": "Failed Show",
|
|
"media_type": "tv",
|
|
"mode": "dub",
|
|
"quality": "720",
|
|
"episodes": "11",
|
|
"download_dir": "/tmp/example",
|
|
"season": "2",
|
|
"episode_offset": "13",
|
|
}
|
|
)
|
|
listing = self.queue.list(per_page=10)
|
|
|
|
self.assertNotEqual(new_job["id"], created["id"])
|
|
self.assertEqual(listing["total"], 2)
|
|
|
|
def test_clear_failed_removes_only_failed_jobs(self):
|
|
failed = self.queue.add(
|
|
{
|
|
"query": "Failed Show",
|
|
"title": "Failed Show",
|
|
"anime_name": "Failed Show",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
done = self.queue.add(
|
|
{
|
|
"query": "Done Show",
|
|
"title": "Done Show",
|
|
"anime_name": "Done Show",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
with self.queue.lock:
|
|
failed_job = self.queue._find(failed["id"])
|
|
failed_job["status"] = "failed"
|
|
self.queue._save_job_locked(failed_job)
|
|
done_job = self.queue._find(done["id"])
|
|
done_job["status"] = "done"
|
|
self.queue._save_job_locked(done_job)
|
|
|
|
result = self.queue.clear_failed()
|
|
listing = self.queue.list()
|
|
|
|
self.assertEqual(result["count"], 1)
|
|
self.assertEqual([job["id"] for job in listing["jobs"]], [done["id"]])
|
|
|
|
def test_detach_show_clears_queue_coverage_and_sync_binding(self):
|
|
created = self.queue.add(
|
|
{
|
|
"show_id": "show-42",
|
|
"query": "Queued Show",
|
|
"title": "Queued Show",
|
|
"anime_name": "Queued Show",
|
|
"mode": "dub",
|
|
"quality": "best",
|
|
"episodes": "1-3",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
|
|
result = self.queue.detach_show("show-42")
|
|
detached = self.queue.get(created["id"])
|
|
|
|
self.assertEqual(result["count"], 1)
|
|
self.assertEqual(detached["show_id"], "")
|
|
self.assertTrue(detached["skip_watchlist_sync"])
|
|
self.assertEqual(self.queue.covered_episodes("show-42", "dub", ["1", "2", "3"]), set())
|
|
|
|
def test_save_job_splits_log_and_extra_payload_columns(self):
|
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
|
job = {
|
|
"id": "job-structured",
|
|
"show_id": "show-1",
|
|
"title": "Structured Show",
|
|
"anime_name": "Structured Show",
|
|
"media_type": "movie",
|
|
"season": "1",
|
|
"query": "Structured Show",
|
|
"result_index": 1,
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1-2",
|
|
"download_dir": "/tmp/example",
|
|
"status": "pending",
|
|
"created_at": APP.now_iso(),
|
|
"updated_at": APP.now_iso(),
|
|
"log": ["line 1", "line 2"],
|
|
"custom_flag": True,
|
|
}
|
|
|
|
with queue.lock:
|
|
queue._save_job_locked(job)
|
|
|
|
with queue._connect() as conn:
|
|
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job["id"],)).fetchone()
|
|
|
|
self.assertEqual(row["title"], "Structured Show")
|
|
self.assertEqual(row["media_type"], "movie")
|
|
self.assertEqual(row["log_payload"], '["line 1", "line 2"]')
|
|
self.assertEqual(APP.json.loads(row["payload"]), {"custom_flag": True})
|
|
restored = queue._job_from_row(row)
|
|
self.assertEqual(restored["log"], ["line 1", "line 2"])
|
|
self.assertTrue(restored["custom_flag"])
|
|
self.assertEqual(restored["media_type"], "movie")
|
|
|
|
def test_movie_finalizer_uses_movie_folder_and_single_file_name(self):
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
job = APP.app_support.build_job(
|
|
{
|
|
"query": "Movie Show",
|
|
"title": "Movie Show",
|
|
"anime_name": "Movie Show",
|
|
"media_type": "movie",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1",
|
|
"download_dir": temp_root,
|
|
"season": "1",
|
|
},
|
|
{"mode": "sub", "quality": "best", "download_dir": temp_root},
|
|
)
|
|
staging_dir = APP.app_support.job_staging_dir(job)
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
source = staging_dir / "Movie Show Episode 1.mp4"
|
|
source.write_bytes(b"movie-bytes")
|
|
|
|
moved = APP.app_support.finalize_library_files(job)
|
|
|
|
self.assertEqual(moved, [f"{temp_root}/movie/Movie Show/Movie Show.mp4"])
|
|
self.assertFalse(source.exists())
|
|
self.assertTrue(Path(moved[0]).exists())
|
|
|
|
def test_tv_finalizer_applies_episode_offset_to_named_files(self):
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
job = APP.app_support.build_job(
|
|
{
|
|
"query": "Split Season Show",
|
|
"title": "Split Season Show",
|
|
"anime_name": "Split Season Show",
|
|
"media_type": "tv",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1-2",
|
|
"download_dir": temp_root,
|
|
"season": "2",
|
|
"episode_offset": "13",
|
|
},
|
|
{"mode": "sub", "quality": "best", "download_dir": temp_root},
|
|
)
|
|
staging_dir = APP.app_support.job_staging_dir(job)
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
first = staging_dir / "Split Season Show Episode 1.mp4"
|
|
second = staging_dir / "Split Season Show Episode 2.mp4"
|
|
first.write_bytes(b"ep1")
|
|
second.write_bytes(b"ep2")
|
|
|
|
moved = APP.app_support.finalize_library_files(job)
|
|
|
|
self.assertEqual(
|
|
moved,
|
|
[
|
|
f"{temp_root}/tv/Split Season Show/Season 02/Split Season Show - S02E13.mp4",
|
|
f"{temp_root}/tv/Split Season Show/Season 02/Split Season Show - S02E14.mp4",
|
|
],
|
|
)
|
|
self.assertTrue(Path(moved[0]).exists())
|
|
self.assertTrue(Path(moved[1]).exists())
|
|
|
|
def test_tv_finalizer_preserves_episode_number_from_season_episode_filename(self):
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
job = APP.app_support.build_job(
|
|
{
|
|
"query": "Jobless Reincarnation",
|
|
"title": "Jobless Reincarnation",
|
|
"anime_name": "Jobless Reincarnation",
|
|
"media_type": "tv",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "6",
|
|
"download_dir": temp_root,
|
|
"season": "3",
|
|
},
|
|
{"mode": "sub", "quality": "best", "download_dir": temp_root},
|
|
)
|
|
staging_dir = APP.app_support.job_staging_dir(job)
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
source = staging_dir / "Jobless Reincarnation - S01E06.mp4"
|
|
source.write_bytes(b"ep6")
|
|
|
|
moved = APP.app_support.finalize_library_files(job)
|
|
|
|
self.assertEqual(
|
|
moved,
|
|
[f"{temp_root}/tv/Jobless Reincarnation/Season 03/Jobless Reincarnation - S03E06.mp4"],
|
|
)
|
|
self.assertTrue(Path(moved[0]).exists())
|
|
|
|
def test_tv_finalizer_accepts_season_zero_for_specials(self):
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
job = APP.app_support.build_job(
|
|
{
|
|
"query": "Special Show",
|
|
"title": "Special Show",
|
|
"anime_name": "Special Show",
|
|
"media_type": "tv",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1",
|
|
"download_dir": temp_root,
|
|
"season": "0",
|
|
},
|
|
{"mode": "sub", "quality": "best", "download_dir": temp_root},
|
|
)
|
|
staging_dir = APP.app_support.job_staging_dir(job)
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
source = staging_dir / "Special Show Episode 1.mp4"
|
|
source.write_bytes(b"ep1")
|
|
|
|
moved = APP.app_support.finalize_library_files(job)
|
|
|
|
self.assertEqual(moved, [f"{temp_root}/tv/Special Show/Season 00/Special Show - S00E01.mp4"])
|
|
self.assertTrue(Path(moved[0]).exists())
|
|
|
|
def test_tv_finalizer_can_move_one_finished_episode_without_removing_staging(self):
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
job = APP.app_support.build_job(
|
|
{
|
|
"query": "Batch Show",
|
|
"title": "Batch Show",
|
|
"anime_name": "Batch Show",
|
|
"media_type": "tv",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1-2",
|
|
"download_dir": temp_root,
|
|
"season": "1",
|
|
},
|
|
{"mode": "sub", "quality": "best", "download_dir": temp_root},
|
|
)
|
|
staging_dir = APP.app_support.job_staging_dir(job)
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
first = staging_dir / "Batch Show - S01E01.mp4"
|
|
second = staging_dir / "Batch Show - S01E02.mp4"
|
|
first.write_bytes(b"ep1")
|
|
second.write_bytes(b"ep2")
|
|
|
|
moved = APP.app_support.finalize_library_files(job, episode=1, cleanup_staging=False)
|
|
|
|
self.assertEqual(moved, [f"{temp_root}/tv/Batch Show/Season 01/Batch Show - S01E01.mp4"])
|
|
self.assertTrue(staging_dir.exists())
|
|
self.assertFalse(first.exists())
|
|
self.assertTrue(second.exists())
|
|
|
|
def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self):
|
|
queue = object.__new__(APP.DownloadQueue)
|
|
queue.lock = threading.RLock()
|
|
queue.stop_event = threading.Event()
|
|
queue.wakeup = threading.Event()
|
|
queue.current_process = mock.Mock(pid=4321)
|
|
queue.current_job = {"id": "job-1", "cancel_requested": False}
|
|
queue.worker = mock.Mock()
|
|
queue.worker.is_alive.side_effect = [True, False, False]
|
|
|
|
with mock.patch.object(APP.os, "getpgid", return_value=4321), mock.patch.object(APP.os, "killpg") as killpg:
|
|
stopped = APP.DownloadQueue.shutdown(queue, wait=True, cancel_active=False)
|
|
|
|
self.assertTrue(stopped)
|
|
self.assertTrue(queue.current_job["cancel_requested"])
|
|
killpg.assert_called_once_with(4321, queue_jobs.signal.SIGTERM)
|
|
|
|
|
|
class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
|
def setUp(self):
|
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
|
with queue._connect() as conn:
|
|
conn.execute("DELETE FROM jobs")
|
|
|
|
def test_run_job_marks_startup_failure_and_cleans_staging(self):
|
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
|
staging_dir = queue_jobs.job_staging_dir(job)
|
|
|
|
with mock.patch.object(queue_jobs.subprocess, "Popen", side_effect=FileNotFoundError("kaizoku downloader missing")):
|
|
queue._run_job(job)
|
|
|
|
stored = queue._find(job["id"])
|
|
self.assertEqual(stored["status"], "failed")
|
|
self.assertEqual(stored["exit_code"], 1)
|
|
self.assertIn("Could not start download", stored["log"][-1])
|
|
self.assertFalse(staging_dir.exists())
|
|
|
|
def test_failed_download_cleans_staging_dir(self):
|
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
|
staging_dir = queue_jobs.job_staging_dir(job)
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
(staging_dir / "partial.mp4").write_text("partial", encoding="utf-8")
|
|
|
|
class FakeProc:
|
|
pid = 4321
|
|
stdout = []
|
|
|
|
def wait(self):
|
|
return 1
|
|
|
|
with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()):
|
|
queue._run_job(job)
|
|
|
|
stored = queue._find(job["id"])
|
|
self.assertEqual(stored["status"], "failed")
|
|
self.assertFalse(staging_dir.exists())
|
|
|
|
def test_zero_exit_without_downloaded_files_marks_job_failed_with_clear_message(self):
|
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1-10"})
|
|
staging_dir = queue_jobs.job_staging_dir(job)
|
|
|
|
class FakeProc:
|
|
pid = 4321
|
|
stdout = ["Checking dependencies...\n"]
|
|
|
|
def wait(self):
|
|
return 0
|
|
|
|
with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()):
|
|
queue._run_job(job)
|
|
|
|
stored = queue._find(job["id"])
|
|
self.assertEqual(stored["status"], "failed")
|
|
self.assertEqual(stored["exit_code"], 1)
|
|
self.assertIn("without downloading any files", "\n".join(stored["log"]))
|
|
self.assertFalse(staging_dir.exists())
|
|
|
|
def test_run_job_marks_post_start_exception_failed_without_killing_worker_state(self):
|
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
|
staging_dir = queue_jobs.job_staging_dir(job)
|
|
|
|
class BrokenStdout:
|
|
def __iter__(self):
|
|
return self
|
|
|
|
def __next__(self):
|
|
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "bad output")
|
|
|
|
class FakeProc:
|
|
pid = 4321
|
|
stdout = BrokenStdout()
|
|
|
|
def terminate(self):
|
|
return None
|
|
|
|
with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()), mock.patch.object(
|
|
queue_jobs.os, "getpgid", side_effect=OSError("gone")
|
|
):
|
|
queue._run_job(job)
|
|
|
|
stored = queue._find(job["id"])
|
|
self.assertEqual(stored["status"], "failed")
|
|
self.assertEqual(stored["exit_code"], 1)
|
|
self.assertIn("unexpectedly", stored["log"][-1].lower())
|
|
self.assertIsNone(queue.current_process)
|
|
self.assertIsNone(queue.current_job_id)
|
|
self.assertIsNone(queue.current_job)
|
|
self.assertFalse(staging_dir.exists())
|
|
|
|
def test_successful_download_sends_completion_notification(self):
|
|
queue = APP.DownloadQueue(
|
|
lambda: {
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"download_dir": "/tmp/example",
|
|
"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token",
|
|
"discord_webhook_events": ["download_completed"],
|
|
},
|
|
watchlist_sync_fn=lambda _job: {
|
|
"show_id": "show-55",
|
|
"title": "Queue Show",
|
|
"category": "finished",
|
|
"thumbnail_path": None,
|
|
},
|
|
start_worker=False,
|
|
)
|
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1-2"})
|
|
|
|
class FakeProc:
|
|
pid = 4321
|
|
stdout = []
|
|
|
|
def wait(self):
|
|
return 0
|
|
|
|
with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()), mock.patch.object(
|
|
queue_jobs, "finalize_library_files", return_value=["/tmp/example/Queue Show - S01E01.mp4"]
|
|
), mock.patch.object(queue_jobs, "send_discord_webhook_event") as send_webhook:
|
|
queue._run_job(job)
|
|
|
|
stored = queue._find(job["id"])
|
|
self.assertEqual(stored["status"], "done")
|
|
self.assertIn("Download completed.", stored["log"])
|
|
send_webhook.assert_called_once()
|
|
self.assertEqual(send_webhook.call_args.args[1], "download_completed")
|
|
self.assertEqual(send_webhook.call_args.args[2]["title"], "Queue Show")
|
|
self.assertEqual(send_webhook.call_args.args[2]["category"], "finished")
|
|
self.assertEqual(send_webhook.call_args.args[2]["moved_files"], ["/tmp/example/Queue Show - S01E01.mp4"])
|
|
|
|
def test_failed_batch_preserves_episode_moved_after_progress_done(self):
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
queue = APP.DownloadQueue(
|
|
lambda: {
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"download_dir": temp_root,
|
|
},
|
|
start_worker=False,
|
|
)
|
|
job = queue.add({"query": "Batch Show", "title": "Batch Show", "season": "1", "episodes": "1-2"})
|
|
staging_dir = queue_jobs.job_staging_dir(job)
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
staged_episode = staging_dir / "Batch Show - S01E01.mp4"
|
|
staged_episode.write_bytes(b"ep1")
|
|
|
|
class FakeProc:
|
|
pid = 4321
|
|
stdout = [
|
|
'KAIZOKU_PROGRESS {"episode":1,"episode_index":1,"episode_total":2,"message":"Episode 1 saved.","percent":100,"phase":"done"}\n',
|
|
"Episode 2 failed on all providers: boom\n",
|
|
]
|
|
|
|
def wait(self):
|
|
return 1
|
|
|
|
with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()):
|
|
queue._run_job(job)
|
|
|
|
stored = queue._find(job["id"])
|
|
final_episode = Path(temp_root) / "tv" / "Batch Show" / "Season 01" / "Batch Show - S01E01.mp4"
|
|
self.assertEqual(stored["status"], "failed")
|
|
self.assertTrue(final_episode.exists())
|
|
self.assertFalse(staged_episode.exists())
|
|
self.assertFalse(staging_dir.exists())
|
|
self.assertIn(f"Saved: {final_episode}", stored["log"])
|
|
self.assertEqual(stored["completed_episodes"], ["1"])
|
|
self.assertEqual(stored["retry_episodes"], "2")
|
|
|
|
retried = queue.retry(job["id"])
|
|
command = APP.app_support.command_for_job(retried)
|
|
|
|
self.assertEqual(retried["status"], "pending")
|
|
self.assertEqual(retried["episodes"], "1-2")
|
|
self.assertEqual(retried["retry_episodes"], "2")
|
|
self.assertEqual(command[command.index("--episodes") + 1], "2")
|
|
|
|
def test_successful_detached_download_logs_sync_skipped(self):
|
|
queue = APP.DownloadQueue(
|
|
lambda: {
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"download_dir": "/tmp/example",
|
|
},
|
|
watchlist_sync_fn=lambda _job: None,
|
|
start_worker=False,
|
|
)
|
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
|
job["skip_watchlist_sync"] = True
|
|
|
|
class FakeProc:
|
|
pid = 4321
|
|
stdout = []
|
|
|
|
def wait(self):
|
|
return 0
|
|
|
|
with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()), 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.assertIn("Watchlist sync skipped for detached job.", stored["log"])
|
|
self.assertNotIn("Watchlist download state synced.", stored["log"])
|
|
|
|
def test_selected_unavailable_kaizoku_fails_without_start_attempt(self):
|
|
queue = APP.DownloadQueue(
|
|
lambda: {
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"download_dir": "/tmp/example",
|
|
"download_methods": ["kaizoku"],
|
|
},
|
|
start_worker=False,
|
|
)
|
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
|
staging_dir = queue_jobs.job_staging_dir(job)
|
|
|
|
with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=False), mock.patch.object(
|
|
queue_jobs.subprocess, "Popen"
|
|
) as popen:
|
|
queue._run_job(job)
|
|
|
|
stored = queue._find(job["id"])
|
|
self.assertEqual(stored["status"], "failed")
|
|
self.assertEqual(stored["download_backend"], "kaizoku")
|
|
self.assertIn("No selected download methods are installed or executable: kaizoku.", stored["log"])
|
|
self.assertFalse(staging_dir.exists())
|
|
popen.assert_not_called()
|
|
|
|
def test_selected_download_methods_runs_kaizoku(self):
|
|
queue = APP.DownloadQueue(
|
|
lambda: {
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"download_dir": "/tmp/example",
|
|
"download_methods": ["kaizoku"],
|
|
},
|
|
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, ["kaizoku 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.assertFalse(stored["fallback_used"])
|
|
self.assertEqual([command[0] for command in commands], ["python3"])
|
|
self.assertIn("Download completed.", stored["log"])
|
|
|
|
def test_failed_job_reuse_keeps_providers_separate(self):
|
|
queue = APP.DownloadQueue(
|
|
lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"},
|
|
start_worker=False,
|
|
)
|
|
with queue._connect() as conn:
|
|
conn.execute("DELETE FROM jobs")
|
|
first = queue.add(
|
|
{
|
|
"show_id": "queue-show",
|
|
"provider": "anikoto",
|
|
"query": "Queue Show",
|
|
"title": "Queue Show",
|
|
"season": "1",
|
|
"episodes": "1",
|
|
}
|
|
)
|
|
first["status"] = "failed"
|
|
queue._save_job_locked(first)
|
|
|
|
second = queue.add(
|
|
{
|
|
"show_id": "queue-show",
|
|
"provider": "anineko",
|
|
"query": "Queue Show",
|
|
"title": "Queue Show",
|
|
"season": "1",
|
|
"episodes": "1",
|
|
}
|
|
)
|
|
|
|
self.assertNotEqual(first["id"], second["id"])
|
|
self.assertEqual(second["provider"], "anineko")
|
|
|
|
|
|
class WatchlistRefreshAllTests(unittest.TestCase):
|
|
def test_refresh_all_iterates_only_watching_and_planned_show_ids(self):
|
|
store = object.__new__(APP.WatchlistStore)
|
|
calls = []
|
|
store.all_show_ids = lambda categories=None: ["show-a", "show-b"]
|
|
store.refresh = lambda show_id, include_thumbnail=True: calls.append((show_id, include_thumbnail)) or {"show_id": show_id}
|
|
|
|
result = APP.WatchlistStore.refresh_all(store)
|
|
|
|
self.assertEqual(calls, [("show-a", False), ("show-b", False)])
|
|
self.assertEqual(result["count"], 2)
|
|
self.assertEqual([item["show_id"] for item in result["items"]], ["show-a", "show-b"])
|
|
|
|
def test_all_show_ids_can_filter_by_category(self):
|
|
with APP.WATCHLIST._connect() as conn:
|
|
conn.execute("DELETE FROM watchlist")
|
|
now = APP.now_iso()
|
|
items = [
|
|
{"show_id": "show-w", "title": "Watching", "category": "watching"},
|
|
{"show_id": "show-p", "title": "Planned", "category": "planned"},
|
|
{"show_id": "show-f", "title": "Finished", "category": "finished"},
|
|
{"show_id": "show-d", "title": "Dropped", "category": "dropped"},
|
|
]
|
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
|
for item in items:
|
|
APP.WATCHLIST._upsert_conn(
|
|
conn,
|
|
{
|
|
**APP.WATCHLIST._auto_download_defaults(item["title"]),
|
|
"show_id": item["show_id"],
|
|
"title": item["title"],
|
|
"category": item["category"],
|
|
"downloaded": False,
|
|
"status": "tracked",
|
|
"status_message": "seeded",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"last_checked": None,
|
|
"sub_count": 0,
|
|
"dub_count": 0,
|
|
"expected_count": 0,
|
|
"airing_status": None,
|
|
"sub_latest_episode": None,
|
|
"dub_latest_episode": None,
|
|
"animeschedule_route": None,
|
|
"animeschedule_title": None,
|
|
"anidb_aid": None,
|
|
"anidb_title": None,
|
|
"thumbnail_path": None,
|
|
"thumbnail_checked_at": None,
|
|
},
|
|
)
|
|
|
|
filtered = APP.WATCHLIST.all_show_ids(categories=("watching", "planned"))
|
|
|
|
self.assertEqual(filtered, ["show-p", "show-w"])
|
|
|
|
|
|
class WatchlistRefreshJobTests(unittest.TestCase):
|
|
def setUp(self):
|
|
with APP.WatchlistRefreshJobs(APP.WATCHLIST, start_worker=False)._connect() as conn:
|
|
conn.execute("DELETE FROM watchlist_refresh_jobs")
|
|
|
|
def test_refresh_job_tracks_completion_and_errors(self):
|
|
class FakeStore:
|
|
def all_show_ids(self, categories=None):
|
|
return ["show-a", "show-b"]
|
|
|
|
def refresh(self, show_id, include_thumbnail=True):
|
|
self.last_include_thumbnail = include_thumbnail
|
|
if show_id == "show-a":
|
|
return {"title": "Show A", "status": "updated"}
|
|
return {"title": "Show B", "status": "error"}
|
|
|
|
service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False)
|
|
started = service.start()
|
|
self.assertTrue(started["created"])
|
|
|
|
job = service._next_pending()
|
|
self.assertIsNotNone(job)
|
|
service._run_job(job)
|
|
|
|
status = service.status()["job"]
|
|
self.assertIsNotNone(status)
|
|
self.assertEqual(status["status"], "done")
|
|
self.assertEqual(status["completed"], 2)
|
|
self.assertEqual(status["errors"], 1)
|
|
self.assertFalse(service.store.last_include_thumbnail)
|
|
|
|
def test_refresh_job_skips_removed_items_without_thumbnail_warmup(self):
|
|
calls = []
|
|
|
|
class FakeStore:
|
|
def all_show_ids(self, categories=None):
|
|
return ["show-a", "show-b"]
|
|
|
|
def refresh(self, show_id, include_thumbnail=True):
|
|
calls.append((show_id, include_thumbnail))
|
|
if show_id == "show-a":
|
|
return None
|
|
return {"title": "Show B", "status": "updated"}
|
|
|
|
service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False)
|
|
started = service.start()
|
|
self.assertTrue(started["created"])
|
|
|
|
job = service._next_pending()
|
|
self.assertIsNotNone(job)
|
|
service._run_job(job)
|
|
|
|
status = service.status()["job"]
|
|
self.assertEqual(status["status"], "done")
|
|
self.assertEqual(status["completed"], 2)
|
|
self.assertEqual(status["errors"], 0)
|
|
self.assertCountEqual(calls, [("show-a", False), ("show-b", False)])
|
|
|
|
def test_refresh_job_marks_interrupted_when_shutdown_requested(self):
|
|
service = APP.WatchlistRefreshJobs(mock.Mock(), start_worker=False)
|
|
job = {
|
|
"id": "refresh-1",
|
|
"status": "running",
|
|
"created_at": APP.now_iso(),
|
|
"updated_at": APP.now_iso(),
|
|
"completed": 0,
|
|
"errors": 0,
|
|
"current_title": None,
|
|
"message": "",
|
|
}
|
|
service.store.all_show_ids = lambda categories=None: ["show-a"]
|
|
service.store.refresh = mock.Mock(return_value={"title": "Show A", "status": "updated"})
|
|
service.stop_event.set()
|
|
|
|
service._run_job(job)
|
|
|
|
self.assertEqual(job["status"], "interrupted")
|
|
self.assertIn("interrupted", job["message"].lower())
|
|
|
|
def test_refresh_start_reuses_pending_job(self):
|
|
class FakeStore:
|
|
def all_show_ids(self, categories=None):
|
|
return []
|
|
|
|
def refresh(self, _show_id, include_thumbnail=True):
|
|
return {"title": "Unused", "status": "updated"}
|
|
|
|
service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False)
|
|
first = service.start()
|
|
second = service.start()
|
|
|
|
self.assertTrue(first["created"])
|
|
self.assertFalse(second["created"])
|
|
self.assertEqual(second["job"]["id"], first["job"]["id"])
|
|
self.assertEqual(second["job"]["status"], "pending")
|
|
|
|
def test_scheduled_refresh_logs_queue_and_completion(self):
|
|
class FakeStore:
|
|
def all_show_ids(self, categories=None):
|
|
return ["show-a"]
|
|
|
|
def refresh(self, _show_id, include_thumbnail=True):
|
|
return {"title": "Show A", "status": "updated"}
|
|
|
|
service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False)
|
|
with mock.patch("builtins.print") as print_mock:
|
|
started = service.start(source="scheduled")
|
|
job = service._next_pending()
|
|
service._run_job(job)
|
|
|
|
self.assertTrue(started["created"])
|
|
logged = "\n".join(call.args[0] for call in print_mock.call_args_list if call.args)
|
|
self.assertIn("Scheduled refresh queued", logged)
|
|
self.assertIn("Scheduled refresh started", logged)
|
|
self.assertIn("Scheduled refresh finished successfully", logged)
|
|
|
|
def test_refresh_history_marks_pending_jobs_interrupted_after_restart(self):
|
|
class FakeStore:
|
|
def all_show_ids(self, categories=None):
|
|
return []
|
|
|
|
def refresh(self, _show_id, include_thumbnail=True):
|
|
return {"title": "Unused", "status": "updated"}
|
|
|
|
service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False)
|
|
started = service.start()
|
|
restarted = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False)
|
|
|
|
status = restarted.status()["job"]
|
|
self.assertEqual(status["id"], started["job"]["id"])
|
|
self.assertEqual(status["status"], "interrupted")
|
|
self.assertIn("restart", status["message"].lower())
|
|
|
|
|
|
class WatchlistAlternativeTitleTests(unittest.TestCase):
|
|
def setUp(self):
|
|
APP.reset_runtime(wait=True)
|
|
APP.initialize_runtime(start_workers=False)
|
|
self.watchlist = APP.WATCHLIST
|
|
with self.watchlist._connect() as conn:
|
|
conn.execute("DELETE FROM watchlist")
|
|
|
|
def test_watchlist_schema_includes_alternative_title_column(self):
|
|
with self.watchlist._connect() as conn:
|
|
columns = {row["name"] for row in conn.execute("PRAGMA table_info(watchlist)").fetchall()}
|
|
|
|
self.assertIn("alternative_titles_json", columns)
|
|
|
|
def test_refresh_caches_english_anidb_alternative_titles(self):
|
|
self.watchlist.add({"show_id": "show-1", "title": "Attack on Titan", "category": "watching"})
|
|
|
|
with mock.patch.object(
|
|
APP,
|
|
"fetch_show_episode_snapshot",
|
|
return_value={"show_id": "show-1", "title": "Attack on Titan", "episodes": {"sub": ["1", "2"], "dub": ["1"]}},
|
|
), mock.patch.object(
|
|
APP,
|
|
"resolve_animeschedule_anime",
|
|
return_value={"route": "attack-on-titan", "title": "Attack on Titan", "episodes": 2, "status": "Finished"},
|
|
), mock.patch.object(
|
|
APP,
|
|
"resolve_anidb_aid",
|
|
return_value={"aid": "16498", "title": "Attack on Titan"},
|
|
), mock.patch.object(
|
|
APP,
|
|
"get_cached_anidb_titles",
|
|
return_value=[
|
|
{"aid": "16498", "title": "Attack on Titan", "type": "official", "lang": "en", "normalized_title": "attack on titan"},
|
|
{"aid": "16498", "title": "AoT", "type": "syn", "lang": "en", "normalized_title": "aot"},
|
|
{"aid": "16498", "title": "Shingeki no Kyojin", "type": "official", "lang": "x-jat", "normalized_title": "shingeki no kyojin"},
|
|
{"aid": "16498", "title": "Attack Titan", "type": "short", "lang": "en", "normalized_title": "attack titan"},
|
|
],
|
|
):
|
|
item = self.watchlist.refresh("show-1", include_thumbnail=False)
|
|
|
|
self.assertEqual(item["anidb_aid"], "16498")
|
|
self.assertEqual(
|
|
item["alternative_titles"],
|
|
[
|
|
{"label": "English", "value": "AoT"},
|
|
{"label": "English", "value": "Attack Titan"},
|
|
],
|
|
)
|
|
self.assertTrue(item["has_alternative_titles"])
|
|
|
|
|
|
class ThumbnailEventRecoveryTests(unittest.TestCase):
|
|
def test_ensure_thumbnails_skips_show_removed_during_batch(self):
|
|
store = object.__new__(APP.WatchlistStore)
|
|
item = {
|
|
"show_id": "show-1",
|
|
"thumbnail_ready": False,
|
|
}
|
|
store.get = mock.Mock(side_effect=[item, KeyError("Anime not found in watchlist.")])
|
|
store.ensure_thumbnail = mock.Mock(return_value=None)
|
|
|
|
result = APP.WatchlistStore.ensure_thumbnails(store, ["show-1"])
|
|
|
|
self.assertEqual(result, {"items": []})
|
|
store.ensure_thumbnail.assert_called_once_with("show-1", force=False)
|
|
|
|
def test_ensure_thumbnail_waiter_returns_none_after_show_is_removed(self):
|
|
store = object.__new__(APP.WatchlistStore)
|
|
store.lock = threading.RLock()
|
|
inflight = threading.Event()
|
|
inflight.set()
|
|
store.thumbnail_events = {"show-1": inflight}
|
|
store.has_show = lambda _show_id: False
|
|
store.get = mock.Mock(return_value={
|
|
"show_id": "show-1",
|
|
"title": "Show One",
|
|
"thumbnail_path": None,
|
|
"thumbnail_checked_at": None,
|
|
"thumbnail_ready": False,
|
|
"animeschedule_route": None,
|
|
"animeschedule_title": None,
|
|
"anidb_aid": None,
|
|
"anidb_title": None,
|
|
})
|
|
|
|
result = APP.WatchlistStore.ensure_thumbnail(store, "show-1")
|
|
|
|
self.assertIsNone(result)
|
|
self.assertEqual(store.get.call_count, 1)
|
|
|
|
def test_ensure_thumbnail_clears_inflight_event_after_update_failure(self):
|
|
store = object.__new__(APP.WatchlistStore)
|
|
store.lock = threading.RLock()
|
|
store.thumbnail_events = {}
|
|
store.get = lambda _show_id: {
|
|
"show_id": "show-1",
|
|
"title": "Show One",
|
|
"thumbnail_path": None,
|
|
"thumbnail_checked_at": None,
|
|
"thumbnail_ready": False,
|
|
"animeschedule_route": None,
|
|
"animeschedule_title": None,
|
|
"anidb_aid": None,
|
|
"anidb_title": None,
|
|
}
|
|
|
|
class BrokenConn:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, _sql, _params):
|
|
raise RuntimeError("db write failed")
|
|
|
|
store._connect = lambda: BrokenConn()
|
|
|
|
with mock.patch.object(APP, "fetch_animeschedule_cover", return_value={"route": "route-1", "title": "Show One", "url": "https://example.com/cover.jpg"}), mock.patch.object(
|
|
APP, "cache_thumbnail_image", return_value="show-1.jpg"
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
APP.WatchlistStore.ensure_thumbnail(store, "show-1", force=False)
|
|
|
|
self.assertEqual(store.thumbnail_events, {})
|
|
|
|
def test_ensure_thumbnail_records_failed_attempt_time_for_retry_backoff(self):
|
|
now = APP.now_iso()
|
|
item = {
|
|
"show_id": "show-thumb-1",
|
|
"title": "Broken Cover Show",
|
|
"category": "watching",
|
|
"downloaded": False,
|
|
"status": "tracked",
|
|
"status_message": "Waiting for thumbnail.",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"last_checked": None,
|
|
"sub_count": 0,
|
|
"dub_count": 0,
|
|
"expected_count": 0,
|
|
"airing_status": None,
|
|
"sub_latest_episode": None,
|
|
"dub_latest_episode": None,
|
|
"animeschedule_route": None,
|
|
"animeschedule_title": None,
|
|
"anidb_aid": None,
|
|
"anidb_title": None,
|
|
"media_type": "tv",
|
|
"thumbnail_path": None,
|
|
"thumbnail_checked_at": None,
|
|
}
|
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
|
APP.WATCHLIST._upsert_conn(conn, item)
|
|
|
|
with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=RuntimeError("lookup failed")) as fetch_cover, mock.patch.object(
|
|
APP, "fetch_anidb_cover", side_effect=RuntimeError("fallback failed")
|
|
) as fetch_fallback:
|
|
result = APP.WATCHLIST.ensure_thumbnail("show-thumb-1")
|
|
|
|
self.assertIsNone(result)
|
|
self.assertEqual(fetch_cover.call_count, 1)
|
|
self.assertEqual(fetch_fallback.call_count, 1)
|
|
failed_item = APP.WATCHLIST.get("show-thumb-1")
|
|
self.assertTrue(failed_item["thumbnail_checked_at"])
|
|
self.assertFalse(APP.thumbnail_retry_due(failed_item["thumbnail_checked_at"]))
|
|
|
|
with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=AssertionError("retry window should suppress another lookup")), mock.patch.object(
|
|
APP, "fetch_anidb_cover", side_effect=AssertionError("retry window should suppress fallback lookup")
|
|
):
|
|
second = APP.WATCHLIST.ensure_thumbnail("show-thumb-1")
|
|
|
|
self.assertIsNone(second)
|
|
|
|
def test_ensure_thumbnail_falls_back_from_stale_animeschedule_route(self):
|
|
now = APP.now_iso()
|
|
item = {
|
|
"show_id": "show-thumb-2",
|
|
"title": "Route Fallback Show",
|
|
"category": "watching",
|
|
"downloaded": False,
|
|
"status": "tracked",
|
|
"status_message": "Waiting for thumbnail.",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"last_checked": None,
|
|
"sub_count": 0,
|
|
"dub_count": 0,
|
|
"expected_count": 0,
|
|
"airing_status": None,
|
|
"sub_latest_episode": None,
|
|
"dub_latest_episode": None,
|
|
"animeschedule_route": "stale-route",
|
|
"animeschedule_title": "Route Fallback Show",
|
|
"anidb_aid": None,
|
|
"anidb_title": None,
|
|
"thumbnail_path": None,
|
|
"thumbnail_checked_at": None,
|
|
}
|
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
|
APP.WATCHLIST._upsert_conn(conn, item)
|
|
|
|
with mock.patch.object(APP, "fetch_animeschedule_cover_by_route", side_effect=RuntimeError("stale route")), mock.patch.object(
|
|
APP, "fetch_animeschedule_cover", return_value={"route": "fresh-route", "title": "Route Fallback Show", "url": "https://example.com/cover.jpg"}
|
|
) as title_lookup, mock.patch.object(APP, "cache_thumbnail_image", return_value="show-thumb-2.jpg"):
|
|
result = APP.WATCHLIST.ensure_thumbnail("show-thumb-2")
|
|
|
|
title_lookup.assert_called_once_with("Route Fallback Show")
|
|
self.assertEqual(result, APP.THUMBNAIL_ROOT / "show-thumb-2.jpg")
|
|
refreshed = APP.WATCHLIST.get("show-thumb-2")
|
|
self.assertEqual(refreshed["animeschedule_route"], "fresh-route")
|
|
|
|
def test_ensure_thumbnail_falls_back_from_stale_anidb_aid(self):
|
|
now = APP.now_iso()
|
|
item = {
|
|
"show_id": "show-thumb-3",
|
|
"title": "AniDB Fallback Show",
|
|
"category": "watching",
|
|
"downloaded": False,
|
|
"status": "tracked",
|
|
"status_message": "Waiting for thumbnail.",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"last_checked": None,
|
|
"sub_count": 0,
|
|
"dub_count": 0,
|
|
"expected_count": 0,
|
|
"airing_status": None,
|
|
"sub_latest_episode": None,
|
|
"dub_latest_episode": None,
|
|
"animeschedule_route": None,
|
|
"animeschedule_title": None,
|
|
"anidb_aid": "12345",
|
|
"anidb_title": "AniDB Fallback Show",
|
|
"thumbnail_path": None,
|
|
"thumbnail_checked_at": None,
|
|
}
|
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
|
APP.WATCHLIST._upsert_conn(conn, item)
|
|
|
|
with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=RuntimeError("animeschedule failed")), mock.patch.object(
|
|
APP, "fetch_anidb_cover_by_aid", side_effect=RuntimeError("stale aid")
|
|
), mock.patch.object(
|
|
APP, "fetch_anidb_cover", return_value={"aid": "67890", "title": "AniDB Fallback Show", "url": "https://example.com/cover.jpg"}
|
|
) as title_lookup, mock.patch.object(APP, "cache_thumbnail_image", return_value="show-thumb-3.jpg"):
|
|
result = APP.WATCHLIST.ensure_thumbnail("show-thumb-3")
|
|
|
|
title_lookup.assert_called_once_with("AniDB Fallback Show")
|
|
self.assertEqual(result, APP.THUMBNAIL_ROOT / "show-thumb-3.jpg")
|
|
refreshed = APP.WATCHLIST.get("show-thumb-3")
|
|
self.assertEqual(refreshed["anidb_aid"], "67890")
|
|
|
|
|
|
class WatchlistCompletionTests(unittest.TestCase):
|
|
def setUp(self):
|
|
with APP.WATCHLIST._connect() as conn:
|
|
conn.execute("DELETE FROM watchlist")
|
|
|
|
def seed_watchlist_item(self, **overrides):
|
|
now = APP.now_iso()
|
|
item = {
|
|
"show_id": "show-1",
|
|
"title": "Example Show",
|
|
"category": "watching",
|
|
"downloaded": False,
|
|
"status": "tracked",
|
|
"status_message": "Waiting for the first manual refresh.",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"last_checked": None,
|
|
"sub_count": 0,
|
|
"dub_count": 0,
|
|
"expected_count": 0,
|
|
"airing_status": None,
|
|
"sub_latest_episode": None,
|
|
"dub_latest_episode": None,
|
|
"animeschedule_route": None,
|
|
"animeschedule_title": None,
|
|
"anidb_aid": None,
|
|
"anidb_title": None,
|
|
"thumbnail_path": None,
|
|
"thumbnail_checked_at": None,
|
|
}
|
|
item.update(overrides)
|
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
|
APP.WATCHLIST._upsert_conn(conn, item)
|
|
|
|
def test_watchlist_status_message_marks_ready_modes(self):
|
|
message = APP.watchlist_status_message(12, 10, expected_count=12, airing_status="finished")
|
|
self.assertIn("Sub: 12/12 available.", message)
|
|
self.assertIn("Dub: 10/12 available.", message)
|
|
self.assertIn("AnimeSchedule: Finished.", message)
|
|
self.assertIn("Sub ready.", message)
|
|
self.assertNotIn("Dub ready.", message)
|
|
|
|
def test_watchlist_item_exposes_provider_label_from_added_provider(self):
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
|
|
result = APP.WATCHLIST.add(
|
|
{
|
|
"show_id": "providerless-show",
|
|
"provider": "anineko",
|
|
"title": "Provider Show",
|
|
"category": "watching",
|
|
}
|
|
)
|
|
|
|
self.assertEqual(result["item"]["provider"], "anineko")
|
|
self.assertEqual(result["item"]["provider_label"], "AniNeko")
|
|
|
|
def test_watchlist_item_derives_provider_label_from_prefixed_show_id(self):
|
|
self.seed_watchlist_item(show_id="pahe:provider-show", title="Provider Show")
|
|
|
|
item = APP.WATCHLIST.get("pahe:provider-show")
|
|
|
|
self.assertEqual(item["provider"], "pahe")
|
|
self.assertEqual(item["provider_label"], "AnimePahe")
|
|
|
|
def test_watchlist_add_uses_selected_auto_download_mode(self):
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
|
|
result = APP.WATCHLIST.add(
|
|
{
|
|
"show_id": "anikoto:mode-show",
|
|
"provider": "anikoto",
|
|
"title": "Mode Show",
|
|
"auto_download_mode": "sub",
|
|
}
|
|
)
|
|
|
|
self.assertEqual(result["item"]["auto_download_mode"], "sub")
|
|
|
|
def test_watchlist_source_url_uses_anikoto_show_page(self):
|
|
self.seed_watchlist_item(show_id="anikoto:provider-show", title="Provider Show", provider="anikoto")
|
|
|
|
item = APP.WATCHLIST.get("anikoto:provider-show")
|
|
|
|
self.assertEqual(item["source_url"], "https://anikototv.to/watch/provider-show")
|
|
|
|
def test_watchlist_source_url_uses_anineko_show_page(self):
|
|
self.seed_watchlist_item(show_id="anineko:provider-show", title="Provider Show", provider="anineko")
|
|
|
|
item = APP.WATCHLIST.get("anineko:provider-show")
|
|
|
|
self.assertEqual(item["source_url"], "https://anineko.to/watch/provider-show")
|
|
|
|
def test_watchlist_source_url_uses_animepahe_show_page(self):
|
|
self.seed_watchlist_item(show_id="pahe:provider-show", title="Provider Show", provider="pahe")
|
|
|
|
item = APP.WATCHLIST.get("pahe:provider-show")
|
|
|
|
self.assertEqual(item["source_url"], "https://animepahe.pw/anime/provider-show")
|
|
|
|
def test_refresh_persists_expected_episode_total_and_ready_flags(self):
|
|
self.seed_watchlist_item()
|
|
|
|
snapshot = {
|
|
"show_id": "show-1",
|
|
"title": "Example Show",
|
|
"episodes": {
|
|
"sub": [str(index) for index in range(1, 13)],
|
|
"dub": [str(index) for index in range(1, 13)],
|
|
},
|
|
}
|
|
schedule = {
|
|
"route": "example-show",
|
|
"title": "Example Show",
|
|
"episodes": 12,
|
|
"status": "Finished",
|
|
}
|
|
|
|
with mock.patch.object(APP, "fetch_show_episode_snapshot", return_value=snapshot), mock.patch.object(
|
|
APP, "resolve_animeschedule_anime", return_value=schedule
|
|
), mock.patch.object(APP.WatchlistStore, "ensure_thumbnail", return_value=None):
|
|
item = APP.WATCHLIST.refresh("show-1")
|
|
|
|
self.assertEqual(item["expected_count"], 12)
|
|
self.assertEqual(item["airing_status"], "Finished")
|
|
self.assertTrue(item["sub_complete"])
|
|
self.assertTrue(item["dub_complete"])
|
|
self.assertEqual(item["sub_progress"], "12/12")
|
|
self.assertEqual(item["dub_progress"], "12/12")
|
|
self.assertEqual(item["animeschedule_route"], "example-show")
|
|
self.assertIn("Sub ready.", item["status_message"])
|
|
self.assertIn("Dub ready.", item["status_message"])
|
|
|
|
def test_list_filters_by_category_and_reports_category_counts(self):
|
|
self.seed_watchlist_item(show_id="show-1", title="Watching Show", category="watching", status="queued")
|
|
self.seed_watchlist_item(show_id="show-2", title="Finished Show", category="finished", downloaded=True)
|
|
|
|
listing = APP.WATCHLIST.list(category="watching")
|
|
|
|
self.assertEqual(listing["active_category"], "watching")
|
|
self.assertEqual(listing["summary"]["shows"], 2)
|
|
self.assertEqual(listing["categories"]["watching"], 1)
|
|
self.assertEqual(listing["categories"]["finished"], 1)
|
|
self.assertEqual(listing["queued_count"], 1)
|
|
self.assertEqual([item["show_id"] for item in listing["items"]], ["show-1"])
|
|
|
|
def test_list_clamps_out_of_range_page_before_querying_rows(self):
|
|
self.seed_watchlist_item(show_id="show-1", title="Alpha Show", category="watching")
|
|
self.seed_watchlist_item(show_id="show-2", title="Beta Show", category="watching")
|
|
|
|
listing = APP.WATCHLIST.list(page=3, per_page=1, category="watching")
|
|
|
|
self.assertEqual(listing["page"], 2)
|
|
self.assertEqual(listing["pages"], 2)
|
|
self.assertEqual([item["show_id"] for item in listing["items"]], ["show-2"])
|
|
|
|
def test_queue_refresh_marks_item_queued_without_sync_refresh(self):
|
|
self.seed_watchlist_item(show_id="show-11", title="Refresh Show", category="watching", status="updated")
|
|
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object(
|
|
APP.WATCHLIST, "refresh", side_effect=AssertionError("queue_refresh should not refresh synchronously")
|
|
):
|
|
item = APP.WATCHLIST.queue_refresh("show-11", status_message="Queued from test.")
|
|
|
|
schedule_refresh.assert_called_once_with("show-11", source="manual")
|
|
self.assertEqual(item["status"], "queued")
|
|
self.assertEqual(item["status_message"], "Queued from test.")
|
|
|
|
def test_mark_downloaded_keeps_existing_category_when_selected_mode_is_incomplete(self):
|
|
self.seed_watchlist_item(
|
|
show_id="show-9",
|
|
title="Queue Show",
|
|
category="planned",
|
|
downloaded=False,
|
|
expected_count=12,
|
|
airing_status="Finished",
|
|
dub_count=8,
|
|
)
|
|
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object(
|
|
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
|
|
), mock.patch.object(APP, "episode_list", return_value=["1", "2", "3", "4", "5", "6", "7", "8"]):
|
|
item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show", mode="dub", episodes="2-3")
|
|
|
|
self.assertEqual(item["category"], "planned")
|
|
self.assertTrue(item["downloaded"])
|
|
self.assertEqual(item["finished_badge"], "")
|
|
self.assertEqual(item["status"], "queued")
|
|
self.assertEqual(item["downloaded_dub_episodes"], ["2", "3"])
|
|
|
|
def test_mark_downloaded_moves_existing_category_to_finished_when_selected_mode_is_complete(self):
|
|
self.seed_watchlist_item(
|
|
show_id="show-9c",
|
|
title="Queue Show Complete",
|
|
category="watching",
|
|
downloaded=False,
|
|
expected_count=3,
|
|
airing_status="Finished",
|
|
dub_count=3,
|
|
)
|
|
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object(
|
|
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
|
|
), mock.patch.object(APP, "episode_list", return_value=["1", "2", "3"]):
|
|
item = APP.WATCHLIST.mark_downloaded("show-9c", title="Queue Show Complete", mode="dub", episodes="1-3")
|
|
|
|
self.assertEqual(item["category"], "finished")
|
|
self.assertTrue(item["downloaded"])
|
|
self.assertEqual(item["finished_badge"], "downloaded")
|
|
self.assertEqual(item["downloaded_dub_episodes"], ["1", "2", "3"])
|
|
|
|
def test_mark_downloaded_moves_existing_movie_to_finished(self):
|
|
self.seed_watchlist_item(
|
|
show_id="movie-1",
|
|
title="Movie Show",
|
|
category="watching",
|
|
downloaded=False,
|
|
media_type="movie",
|
|
)
|
|
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object(
|
|
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
|
|
):
|
|
item = APP.WATCHLIST.mark_downloaded("movie-1", title="Movie Show", media_type="movie")
|
|
|
|
self.assertEqual(item["category"], "finished")
|
|
self.assertEqual(item["media_type"], "movie")
|
|
self.assertTrue(item["downloaded"])
|
|
|
|
def test_mark_downloaded_creates_missing_show_in_finished_category(self):
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object(
|
|
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
|
|
):
|
|
item = APP.WATCHLIST.mark_downloaded("show-10", title="Downloaded Show")
|
|
|
|
self.assertEqual(item["category"], "finished")
|
|
self.assertTrue(item["downloaded"])
|
|
self.assertEqual(item["finished_badge"], "downloaded")
|
|
self.assertEqual(item["status"], "queued")
|
|
|
|
def test_mark_downloaded_creates_missing_movie_in_finished_category(self):
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object(
|
|
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
|
|
):
|
|
item = APP.WATCHLIST.mark_downloaded("movie-2", title="Downloaded Movie", media_type="movie")
|
|
|
|
self.assertEqual(item["category"], "finished")
|
|
self.assertEqual(item["media_type"], "movie")
|
|
self.assertTrue(item["downloaded"])
|
|
|
|
def test_mark_downloaded_does_not_recreate_removed_show(self):
|
|
self.seed_watchlist_item(show_id="show-removed", title="Removed Show", category="watching")
|
|
|
|
APP.WATCHLIST.remove("show-removed")
|
|
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=AssertionError("removed show should not be requeued")), mock.patch.object(
|
|
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
|
|
):
|
|
item = APP.WATCHLIST.mark_downloaded("show-removed", title="Removed Show", mode="sub", episodes="1")
|
|
|
|
self.assertIsNone(item)
|
|
self.assertFalse(APP.WATCHLIST.has_show("show-removed"))
|
|
|
|
def test_mark_downloaded_reconciles_unique_title_match_to_new_show_id(self):
|
|
self.seed_watchlist_item(
|
|
show_id="legacy-show-9",
|
|
title="Queue Show",
|
|
category="watching",
|
|
downloaded=False,
|
|
expected_count=3,
|
|
airing_status="Finished",
|
|
sub_count=3,
|
|
)
|
|
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object(
|
|
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
|
|
), mock.patch.object(APP, "episode_list", return_value=["1", "2", "3"]):
|
|
item = APP.WATCHLIST.mark_downloaded("resolved-show-9", title="Queue Show", mode="sub", episodes="1-3")
|
|
|
|
self.assertEqual(item["show_id"], "resolved-show-9")
|
|
self.assertEqual(item["category"], "finished")
|
|
self.assertTrue(item["downloaded"])
|
|
self.assertEqual(item["finished_badge"], "downloaded")
|
|
self.assertEqual(item["status"], "queued")
|
|
self.assertFalse(APP.WATCHLIST.has_show("legacy-show-9"))
|
|
self.assertEqual(APP.WATCHLIST.all_show_ids(), ["resolved-show-9"])
|
|
|
|
def test_add_queues_refresh_without_sync_refresh(self):
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object(
|
|
APP.WATCHLIST, "refresh", side_effect=AssertionError("add should not refresh synchronously")
|
|
):
|
|
result = APP.WATCHLIST.add({"show_id": "show-22", "title": "Queued Show", "category": "watching"})
|
|
|
|
schedule_refresh.assert_called_once_with("show-22", source="initial")
|
|
self.assertTrue(result["created"])
|
|
self.assertEqual(result["item"]["status"], "queued")
|
|
self.assertIn("Refresh queued", result["message"])
|
|
|
|
def test_add_uses_requested_auto_download_series(self):
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
|
|
result = APP.WATCHLIST.add(
|
|
{"show_id": "show-23", "title": "Queued Show", "category": "watching", "auto_download_series": "4"}
|
|
)
|
|
|
|
self.assertEqual(result["item"]["auto_download_series"], "4")
|
|
|
|
def test_add_uses_requested_auto_download_series_zero(self):
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
|
|
result = APP.WATCHLIST.add(
|
|
{"show_id": "show-23z", "title": "Queued Specials", "category": "watching", "auto_download_series": "0"}
|
|
)
|
|
|
|
self.assertEqual(result["item"]["auto_download_series"], "0")
|
|
|
|
def test_update_auto_download_settings_uses_series_zero(self):
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
|
|
APP.WATCHLIST.add({"show_id": "show-23s", "title": "Queued Specials", "category": "watching"})
|
|
|
|
item = APP.WATCHLIST.update_auto_download_settings("show-23s", series="0")
|
|
|
|
self.assertEqual(item["auto_download_series"], "0")
|
|
|
|
def test_add_uses_requested_auto_download_offset(self):
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
|
|
result = APP.WATCHLIST.add(
|
|
{"show_id": "show-23b", "title": "Queued Show", "category": "watching", "auto_download_offset": "13"}
|
|
)
|
|
|
|
self.assertEqual(result["item"]["auto_download_offset"], "13")
|
|
|
|
def test_add_uses_requested_media_type(self):
|
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
|
|
result = APP.WATCHLIST.add(
|
|
{"show_id": "show-24", "title": "Queued Movie", "category": "watching", "media_type": "movie"}
|
|
)
|
|
|
|
self.assertEqual(result["item"]["media_type"], "movie")
|
|
self.assertEqual(result["item"]["media_type_label"], "Movie")
|
|
|
|
def test_refresh_uses_bounded_request_timeout(self):
|
|
self.seed_watchlist_item(show_id="show-1", title="Example Show")
|
|
snapshot = {
|
|
"show_id": "show-1",
|
|
"title": "Example Show",
|
|
"episodes": {"sub": ["1"], "dub": []},
|
|
}
|
|
schedule = {
|
|
"route": "example-show",
|
|
"title": "Example Show",
|
|
"episodes": 12,
|
|
"status": "Finished",
|
|
}
|
|
|
|
with mock.patch.object(APP, "fetch_show_episode_snapshot", return_value=snapshot) as fetch_snapshot, mock.patch.object(
|
|
APP, "resolve_animeschedule_anime", return_value=schedule
|
|
) as resolve_schedule, mock.patch.object(APP.WatchlistStore, "ensure_thumbnail", return_value=None):
|
|
APP.WATCHLIST.refresh("show-1")
|
|
|
|
fetch_snapshot.assert_called_once_with("show-1", timeout=APP.WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS)
|
|
resolve_schedule.assert_called_once_with(
|
|
"Example Show",
|
|
route=None,
|
|
timeout=APP.WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS,
|
|
)
|
|
|
|
def test_sync_downloaded_job_to_watchlist_resolves_missing_show_id_from_search(self):
|
|
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"}
|
|
|
|
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show"}]), mock.patch.object(
|
|
APP.WATCHLIST, "mark_downloaded", return_value={"show_id": "show-42"}
|
|
) as mark_downloaded:
|
|
APP.sync_downloaded_job_to_watchlist(job)
|
|
|
|
mark_downloaded.assert_called_once_with("show-42", title="Queue Show", mode="sub", episodes=None, media_type=None)
|
|
|
|
def test_prepare_download_job_persists_resolved_show_id(self):
|
|
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show", "show_id": ""}
|
|
|
|
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show"}]):
|
|
prepared = APP.prepare_download_job(dict(job))
|
|
|
|
self.assertEqual(prepared["show_id"], "show-42")
|
|
self.assertEqual(prepared["title"], "Queue Show")
|
|
|
|
def test_prepare_download_job_infers_result_index_from_show_id(self):
|
|
job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": "show-99"}
|
|
|
|
with mock.patch.object(
|
|
APP,
|
|
"search_anime",
|
|
return_value=[
|
|
{"id": "show-42", "title": "Queue Show"},
|
|
{"id": "show-99", "title": "Queue Show Season 4"},
|
|
],
|
|
):
|
|
prepared = APP.prepare_download_job(dict(job))
|
|
|
|
self.assertEqual(prepared["result_index"], 2)
|
|
|
|
def test_prepare_download_job_infers_result_index_from_unique_title_match(self):
|
|
job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": ""}
|
|
|
|
with mock.patch.object(
|
|
APP,
|
|
"search_anime",
|
|
return_value=[
|
|
{"id": "show-42", "title": "Queue Show"},
|
|
{"id": "show-99", "title": "Different Show"},
|
|
],
|
|
):
|
|
prepared = APP.prepare_download_job(dict(job))
|
|
|
|
self.assertEqual(prepared["result_index"], 1)
|
|
|
|
def test_prepare_download_job_accepts_equivalent_season_notation(self):
|
|
job = {"query": "Queue Show 2nd Season", "mode": "sub", "result_index": 1, "title": "Queue Show 2nd Season", "show_id": ""}
|
|
|
|
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show Season 2"}]):
|
|
prepared = APP.prepare_download_job(dict(job))
|
|
|
|
self.assertEqual(prepared["show_id"], "show-42")
|
|
self.assertEqual(prepared["title"], "Queue Show Season 2")
|
|
|
|
def test_prepare_download_job_resolves_unique_match_without_result_index(self):
|
|
job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": ""}
|
|
|
|
with mock.patch.object(
|
|
APP,
|
|
"search_anime",
|
|
return_value=[
|
|
{"id": "show-42", "title": "Queue Show"},
|
|
{"id": "show-99", "title": "Different Show"},
|
|
],
|
|
):
|
|
prepared = APP.prepare_download_job(dict(job))
|
|
|
|
self.assertEqual(prepared["show_id"], "show-42")
|
|
self.assertEqual(prepared["title"], "Queue Show")
|
|
|
|
def test_prepare_download_job_skips_ambiguous_match_without_result_index(self):
|
|
job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": ""}
|
|
|
|
with mock.patch.object(
|
|
APP,
|
|
"search_anime",
|
|
return_value=[
|
|
{"id": "show-42", "title": "Queue Show"},
|
|
{"id": "show-99", "title": "Queue Show (2024)"},
|
|
],
|
|
):
|
|
prepared = APP.prepare_download_job(dict(job))
|
|
|
|
self.assertEqual(prepared["show_id"], "")
|
|
|
|
def test_command_for_job_omits_result_switch_when_result_index_missing(self):
|
|
job = APP.app_support.build_job(
|
|
{
|
|
"query": "Queue Show",
|
|
"title": "Queue Show",
|
|
"mode": "dub",
|
|
"quality": "best",
|
|
"episodes": "1-10",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
},
|
|
{"mode": "sub", "quality": "best", "download_dir": "/tmp/example"},
|
|
)
|
|
|
|
self.assertNotIn("-S", APP.app_support.command_for_job(job))
|
|
|
|
def test_command_for_job_ignores_result_index_for_provider_downloader(self):
|
|
command = APP.app_support.command_for_job(
|
|
{
|
|
"query": "Queue Show",
|
|
"quality": "best",
|
|
"episodes": "1-10",
|
|
"mode": "dub",
|
|
"result_index": 2,
|
|
},
|
|
download_path="/tmp/kaizoku-staging",
|
|
)
|
|
|
|
self.assertNotIn("-S", command)
|
|
self.assertIn("--provider", command)
|
|
self.assertEqual(command[command.index("--provider") + 1], "anikoto")
|
|
|
|
def test_command_for_job_ignores_invalid_result_index(self):
|
|
command = APP.app_support.command_for_job(
|
|
{
|
|
"query": "Queue Show",
|
|
"quality": "best",
|
|
"episodes": "1-10",
|
|
"mode": "dub",
|
|
"result_index": "nope",
|
|
},
|
|
download_path="/tmp/kaizoku-staging",
|
|
)
|
|
|
|
self.assertNotIn("-S", command)
|
|
|
|
def test_command_for_job_uses_retry_episode_spec_when_present(self):
|
|
command = APP.app_support.command_for_job(
|
|
{
|
|
"query": "Queue Show",
|
|
"quality": "best",
|
|
"episodes": "1-12",
|
|
"retry_episodes": "3-12",
|
|
"mode": "dub",
|
|
},
|
|
download_path="/tmp/kaizoku-staging",
|
|
)
|
|
|
|
self.assertEqual(command[command.index("--episodes") + 1], "3-12")
|
|
|
|
def test_download_watchlist_item_queues_full_episode_range_for_selected_mode(self):
|
|
item = {
|
|
"show_id": "show-42",
|
|
"title": "Queue Show",
|
|
"auto_download_name": "Queue Show Library",
|
|
"auto_download_source_name": "Queue Show Source",
|
|
"auto_download_series": "4",
|
|
"auto_download_quality": "720",
|
|
"media_type": "movie",
|
|
}
|
|
job = {"id": "job-1", "show_id": "show-42", "episodes": "1-12", "mode": "dub"}
|
|
with mock.patch.object(APP, "ensure_runtime", return_value={"watchlist": APP.WATCHLIST, "download_queue": mock.Mock(), "config": {"quality": "best", "download_dir": "/tmp/downloads"}}), mock.patch.object(
|
|
APP.WATCHLIST, "get", return_value=item
|
|
), mock.patch.object(APP, "episode_list", return_value=["1", "2", "12"]):
|
|
runtime = APP.ensure_runtime()
|
|
runtime["download_queue"].add.return_value = job
|
|
result = APP.download_watchlist_item("show-42", "dub")
|
|
|
|
runtime["download_queue"].add.assert_called_once_with(
|
|
{
|
|
"show_id": "show-42",
|
|
"provider": "anikoto",
|
|
"query": "Queue Show Source",
|
|
"title": "Queue Show",
|
|
"anime_name": "Queue Show Library",
|
|
"media_type": "movie",
|
|
"season": "4",
|
|
"episode_offset": None,
|
|
"mode": "dub",
|
|
"quality": "720",
|
|
"episodes": "1-12",
|
|
"download_dir": "/tmp/downloads",
|
|
}
|
|
)
|
|
self.assertEqual(result["job"], job)
|
|
self.assertIn("Queued Queue Show for dub download.", result["message"])
|
|
|
|
def test_download_watchlist_item_queues_season_zero(self):
|
|
item = {
|
|
"show_id": "show-42",
|
|
"title": "Queue Specials",
|
|
"auto_download_name": "Queue Specials Library",
|
|
"auto_download_source_name": "Queue Specials Source",
|
|
"auto_download_series": "0",
|
|
"auto_download_quality": "720",
|
|
"media_type": "tv",
|
|
}
|
|
job = {"id": "job-1", "show_id": "show-42", "episodes": "1", "mode": "dub"}
|
|
with mock.patch.object(APP, "ensure_runtime", return_value={"watchlist": APP.WATCHLIST, "download_queue": mock.Mock(), "config": {"quality": "best", "download_dir": "/tmp/downloads"}}), mock.patch.object(
|
|
APP.WATCHLIST, "get", return_value=item
|
|
), mock.patch.object(APP, "episode_list", return_value=["1"]):
|
|
runtime = APP.ensure_runtime()
|
|
runtime["download_queue"].add.return_value = job
|
|
APP.download_watchlist_item("show-42", "dub")
|
|
|
|
self.assertEqual(runtime["download_queue"].add.call_args.args[0]["season"], "0")
|
|
|
|
def test_remove_from_watchlist_detaches_existing_queue_jobs(self):
|
|
runtime = APP.ensure_runtime()
|
|
APP.WATCHLIST.add({"show_id": "show-queue-remove", "title": "Queue Remove Show"})
|
|
created = runtime["download_queue"].add(
|
|
{
|
|
"show_id": "show-queue-remove",
|
|
"query": "Queue Remove Show",
|
|
"title": "Queue Remove Show",
|
|
"anime_name": "Queue Remove Show",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1-2",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
|
|
APP.remove_from_watchlist("show-queue-remove")
|
|
|
|
detached = runtime["download_queue"].get(created["id"])
|
|
self.assertEqual(detached["show_id"], "")
|
|
self.assertTrue(detached["skip_watchlist_sync"])
|
|
|
|
def test_download_watchlist_item_does_not_require_search_match(self):
|
|
item = {
|
|
"show_id": "show-42",
|
|
"title": "Queue Show",
|
|
"auto_download_name": "Queue Show",
|
|
"auto_download_source_name": "Queue Show Alt Search",
|
|
"auto_download_series": "1",
|
|
"media_type": "tv",
|
|
}
|
|
runtime = {
|
|
"watchlist": APP.WATCHLIST,
|
|
"download_queue": mock.Mock(),
|
|
"config": {"quality": "best", "download_dir": "/tmp/downloads"},
|
|
}
|
|
runtime["download_queue"].add.return_value = {"id": "job-1"}
|
|
|
|
with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object(
|
|
APP.WATCHLIST, "get", return_value=item
|
|
), mock.patch.object(APP, "search_anime", side_effect=AssertionError("search should not be required")), mock.patch.object(
|
|
APP, "episode_list", return_value=["1", "2"]
|
|
):
|
|
result = APP.download_watchlist_item("show-42", "dub")
|
|
|
|
self.assertEqual(result["job"]["id"], "job-1")
|
|
|
|
|
|
class JellyfinSyncTests(unittest.TestCase):
|
|
def setUp(self):
|
|
with APP.WATCHLIST._connect() as conn:
|
|
conn.execute("DELETE FROM watchlist")
|
|
|
|
def seed_watchlist_item(self, **overrides):
|
|
now = APP.now_iso()
|
|
item = {
|
|
"show_id": "show-jelly-1",
|
|
"title": "Jelly Show",
|
|
"category": "finished",
|
|
"downloaded": True,
|
|
"status": "updated",
|
|
"status_message": "Finished.",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"last_checked": now,
|
|
"sub_count": 12,
|
|
"dub_count": 12,
|
|
"expected_count": 12,
|
|
"airing_status": "Finished",
|
|
"sub_latest_episode": "12",
|
|
"dub_latest_episode": "12",
|
|
"animeschedule_route": None,
|
|
"animeschedule_title": None,
|
|
"anidb_aid": None,
|
|
"anidb_title": None,
|
|
"media_type": "tv",
|
|
"auto_download_mode": "dub",
|
|
"auto_download_name": "Jelly Show",
|
|
"auto_download_series": "1",
|
|
"downloaded_sub_episodes_json": None,
|
|
"downloaded_dub_episodes_json": APP.encode_episode_values([str(index) for index in range(1, 13)]),
|
|
"thumbnail_path": None,
|
|
"thumbnail_checked_at": None,
|
|
}
|
|
item.update(overrides)
|
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
|
APP.WATCHLIST._upsert_conn(conn, item)
|
|
|
|
def test_run_jellyfin_sync_moves_eligible_tv_library(self):
|
|
self.seed_watchlist_item()
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
download_root = Path(temp_root) / "downloads"
|
|
source_file = download_root / "tv" / "Jelly Show" / "Season 01" / "Jelly Show - S01E01.mp4"
|
|
source_file.parent.mkdir(parents=True, exist_ok=True)
|
|
source_file.write_bytes(b"episode-data")
|
|
jellyfin_tv = Path(temp_root) / "jellyfin-tv"
|
|
|
|
result = APP.run_jellyfin_sync(
|
|
{
|
|
"download_dir": str(download_root),
|
|
"jellyfin_sync_enabled": True,
|
|
"jellyfin_tv_dir": str(jellyfin_tv),
|
|
"jellyfin_movie_dir": "",
|
|
}
|
|
)
|
|
|
|
self.assertEqual(result["moved"], 1)
|
|
target_file = jellyfin_tv / "Jelly Show" / "Season 01" / "Jelly Show - S01E01.mp4"
|
|
self.assertTrue(target_file.exists())
|
|
self.assertFalse((download_root / "tv" / "Jelly Show").exists())
|
|
|
|
def test_trigger_jellyfin_sync_for_item_sends_success_notification(self):
|
|
self.seed_watchlist_item()
|
|
item = APP.WATCHLIST.get("show-jelly-1")
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
download_root = Path(temp_root) / "downloads"
|
|
source_file = download_root / "tv" / "Jelly Show" / "Season 01" / "Jelly Show - S01E01.mp4"
|
|
source_file.parent.mkdir(parents=True, exist_ok=True)
|
|
source_file.write_bytes(b"episode-data")
|
|
jellyfin_tv = Path(temp_root) / "jellyfin-tv"
|
|
with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook:
|
|
result = APP.trigger_jellyfin_sync_for_item(
|
|
item,
|
|
automatic=True,
|
|
config={
|
|
"download_dir": str(download_root),
|
|
"jellyfin_sync_enabled": True,
|
|
"jellyfin_tv_dir": str(jellyfin_tv),
|
|
"jellyfin_movie_dir": "",
|
|
"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token",
|
|
"discord_webhook_events": ["jellyfin_sync_success"],
|
|
},
|
|
)
|
|
|
|
self.assertTrue(result["moved"])
|
|
send_webhook.assert_called_once()
|
|
self.assertEqual(send_webhook.call_args.args[1], "jellyfin_sync_success")
|
|
self.assertEqual(send_webhook.call_args.args[2]["title"], "Jelly Show")
|
|
self.assertEqual(send_webhook.call_args.args[2]["source"], "automatic")
|
|
|
|
def test_trigger_jellyfin_sync_for_item_sends_failure_notification(self):
|
|
self.seed_watchlist_item()
|
|
item = APP.WATCHLIST.get("show-jelly-1")
|
|
with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook:
|
|
result = APP.trigger_jellyfin_sync_for_item(
|
|
item,
|
|
automatic=True,
|
|
config={
|
|
"download_dir": "/tmp/example",
|
|
"jellyfin_sync_enabled": True,
|
|
"jellyfin_tv_dir": "",
|
|
"jellyfin_movie_dir": "",
|
|
"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token",
|
|
"discord_webhook_events": ["jellyfin_sync_failed"],
|
|
},
|
|
)
|
|
|
|
self.assertFalse(result["moved"])
|
|
self.assertEqual(result["reason"], "target_missing")
|
|
send_webhook.assert_called_once()
|
|
self.assertEqual(send_webhook.call_args.args[1], "jellyfin_sync_failed")
|
|
self.assertEqual(send_webhook.call_args.args[2]["reason"], "target_missing")
|
|
|
|
def test_trigger_jellyfin_sync_for_item_skips_already_moved_notification(self):
|
|
self.seed_watchlist_item(expected_count=1, dub_count=1, downloaded_dub_episodes_json=APP.encode_episode_values(["1"]))
|
|
item = APP.WATCHLIST.get("show-jelly-1")
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
jellyfin_tv = Path(temp_root) / "jellyfin-tv" / "Jelly Show"
|
|
jellyfin_tv.mkdir(parents=True, exist_ok=True)
|
|
(jellyfin_tv / "Season 01").mkdir(parents=True, exist_ok=True)
|
|
(jellyfin_tv / "Season 01" / "Jelly Show - S01E01.mp4").write_bytes(b"episode-data")
|
|
with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook:
|
|
result = APP.trigger_jellyfin_sync_for_item(
|
|
item,
|
|
automatic=False,
|
|
config={
|
|
"download_dir": str(Path(temp_root) / "downloads"),
|
|
"jellyfin_sync_enabled": True,
|
|
"jellyfin_tv_dir": str(Path(temp_root) / "jellyfin-tv"),
|
|
"jellyfin_movie_dir": "",
|
|
"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token",
|
|
"discord_webhook_events": ["jellyfin_sync_failed", "jellyfin_sync_success"],
|
|
},
|
|
)
|
|
|
|
self.assertFalse(result["moved"])
|
|
self.assertEqual(result["reason"], "already_moved")
|
|
send_webhook.assert_not_called()
|
|
|
|
def test_trigger_jellyfin_sync_for_item_flags_incomplete_target_when_source_missing(self):
|
|
self.seed_watchlist_item(expected_count=12, dub_count=12)
|
|
item = APP.WATCHLIST.get("show-jelly-1")
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
jellyfin_tv = Path(temp_root) / "jellyfin-tv" / "Jelly Show"
|
|
jellyfin_tv.mkdir(parents=True, exist_ok=True)
|
|
with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook:
|
|
result = APP.trigger_jellyfin_sync_for_item(
|
|
item,
|
|
automatic=False,
|
|
config={
|
|
"download_dir": str(Path(temp_root) / "downloads"),
|
|
"jellyfin_sync_enabled": True,
|
|
"jellyfin_tv_dir": str(Path(temp_root) / "jellyfin-tv"),
|
|
"jellyfin_movie_dir": "",
|
|
"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token",
|
|
"discord_webhook_events": ["jellyfin_sync_failed"],
|
|
},
|
|
)
|
|
|
|
self.assertFalse(result["moved"])
|
|
self.assertEqual(result["reason"], "target_incomplete")
|
|
send_webhook.assert_called_once()
|
|
self.assertEqual(send_webhook.call_args.args[1], "jellyfin_sync_failed")
|
|
|
|
def test_trigger_jellyfin_sync_for_item_skips_incomplete_series(self):
|
|
self.seed_watchlist_item(
|
|
expected_count=12,
|
|
dub_count=8,
|
|
dub_latest_episode="8",
|
|
downloaded_dub_episodes_json=APP.encode_episode_values([str(index) for index in range(1, 9)]),
|
|
)
|
|
item = APP.WATCHLIST.get("show-jelly-1")
|
|
|
|
result = APP.trigger_jellyfin_sync_for_item(
|
|
item,
|
|
automatic=False,
|
|
config={"download_dir": "/tmp/example", "jellyfin_tv_dir": "/tmp/jellyfin-tv", "jellyfin_movie_dir": ""},
|
|
)
|
|
|
|
self.assertFalse(result["moved"])
|
|
self.assertEqual(result["reason"], "episodes_unavailable")
|
|
|
|
def test_trigger_jellyfin_sync_for_item_skips_when_dub_not_fully_downloaded(self):
|
|
self.seed_watchlist_item(
|
|
expected_count=12,
|
|
dub_count=12,
|
|
downloaded_dub_episodes_json=APP.encode_episode_values([str(index) for index in range(1, 9)]),
|
|
)
|
|
item = APP.WATCHLIST.get("show-jelly-1")
|
|
|
|
result = APP.trigger_jellyfin_sync_for_item(
|
|
item,
|
|
automatic=False,
|
|
config={"download_dir": "/tmp/example", "jellyfin_tv_dir": "/tmp/jellyfin-tv", "jellyfin_movie_dir": ""},
|
|
)
|
|
|
|
self.assertFalse(result["moved"])
|
|
self.assertEqual(result["reason"], "episodes_missing")
|
|
|
|
def test_sync_downloaded_job_to_watchlist_rejects_ambiguous_fallback_match(self):
|
|
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"}
|
|
|
|
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Different Anime"}]):
|
|
with self.assertRaises(ValueError) as ctx:
|
|
APP.sync_downloaded_job_to_watchlist(job)
|
|
|
|
self.assertIn("title mismatch", str(ctx.exception).lower())
|
|
|
|
def test_sync_downloaded_job_to_watchlist_rejects_cross_season_match(self):
|
|
job = {"query": "Queue Show Season 2", "mode": "sub", "result_index": 1, "title": "Queue Show Season 2"}
|
|
|
|
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show Season 1"}]):
|
|
with self.assertRaises(ValueError) as ctx:
|
|
APP.sync_downloaded_job_to_watchlist(job)
|
|
|
|
self.assertIn("title mismatch", str(ctx.exception).lower())
|
|
|
|
|
|
class WatchlistFilesystemReconcileTests(unittest.TestCase):
|
|
def setUp(self):
|
|
with APP.WATCHLIST._connect() as conn:
|
|
conn.execute("DELETE FROM watchlist")
|
|
|
|
def seed_watchlist_item(self, **overrides):
|
|
now = APP.now_iso()
|
|
item = {
|
|
"show_id": "show-reconcile-1",
|
|
"title": "Reconcile Show",
|
|
"category": "watching",
|
|
"downloaded": True,
|
|
"status": "updated",
|
|
"status_message": "Tracked.",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"last_checked": now,
|
|
"sub_count": 12,
|
|
"dub_count": 12,
|
|
"expected_count": 12,
|
|
"airing_status": "Finished",
|
|
"sub_latest_episode": "12",
|
|
"dub_latest_episode": "12",
|
|
"animeschedule_route": None,
|
|
"animeschedule_title": None,
|
|
"anidb_aid": None,
|
|
"anidb_title": None,
|
|
"media_type": "tv",
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"auto_download_name": "Reconcile Show",
|
|
"auto_download_source_name": "Reconcile Show",
|
|
"auto_download_series": "2",
|
|
"auto_download_offset": "13",
|
|
"downloaded_sub_episodes_json": None,
|
|
"downloaded_dub_episodes_json": APP.encode_episode_values(["1", "3"]),
|
|
"thumbnail_path": None,
|
|
"thumbnail_checked_at": None,
|
|
}
|
|
item.update(overrides)
|
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
|
APP.WATCHLIST._upsert_conn(conn, item)
|
|
|
|
def test_reconcile_watchlist_downloaded_files_updates_episode_sets_from_filesystem(self):
|
|
self.seed_watchlist_item()
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
season_dir = Path(temp_root) / "tv" / "Reconcile Show" / "Season 02"
|
|
season_dir.mkdir(parents=True, exist_ok=True)
|
|
(season_dir / "Reconcile Show - S02E13.mp4").write_bytes(b"one")
|
|
(season_dir / "Reconcile Show - S02E14.mkv").write_bytes(b"two")
|
|
|
|
result = APP.reconcile_watchlist_downloaded_files({"download_dir": temp_root, "mode": "sub", "quality": "best"})
|
|
|
|
self.assertEqual(result["changed"], 1)
|
|
item = APP.WATCHLIST.get("show-reconcile-1")
|
|
self.assertTrue(item["downloaded"])
|
|
self.assertEqual(item["downloaded_dub_episodes"], ["1", "2"])
|
|
self.assertEqual(item["downloaded_sub_episodes"], [])
|
|
|
|
def test_reconcile_watchlist_downloaded_files_clears_missing_files(self):
|
|
self.seed_watchlist_item(
|
|
downloaded_sub_episodes_json=APP.encode_episode_values(["1"]),
|
|
downloaded_dub_episodes_json=APP.encode_episode_values(["1", "2"]),
|
|
)
|
|
|
|
result = APP.reconcile_watchlist_downloaded_files({"download_dir": "/tmp/missing-download-root", "mode": "sub", "quality": "best"})
|
|
|
|
self.assertEqual(result["changed"], 1)
|
|
item = APP.WATCHLIST.get("show-reconcile-1")
|
|
self.assertFalse(item["downloaded"])
|
|
self.assertEqual(item["downloaded_sub_episodes"], [])
|
|
self.assertEqual(item["downloaded_dub_episodes"], [])
|
|
|
|
def test_reconcile_watchlist_downloaded_files_marks_movie_present_when_file_exists(self):
|
|
self.seed_watchlist_item(
|
|
show_id="show-movie-1",
|
|
title="Movie Reconcile",
|
|
media_type="movie",
|
|
downloaded=False,
|
|
auto_download_name="Movie Reconcile",
|
|
auto_download_series="1",
|
|
auto_download_offset=None,
|
|
downloaded_sub_episodes_json=None,
|
|
downloaded_dub_episodes_json=None,
|
|
)
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
movie_dir = Path(temp_root) / "movie" / "Movie Reconcile"
|
|
movie_dir.mkdir(parents=True, exist_ok=True)
|
|
(movie_dir / "Movie Reconcile.mp4").write_bytes(b"movie")
|
|
|
|
result = APP.reconcile_watchlist_downloaded_files({"download_dir": temp_root, "mode": "sub", "quality": "best"})
|
|
|
|
self.assertEqual(result["changed"], 1)
|
|
item = APP.WATCHLIST.get("show-movie-1")
|
|
self.assertTrue(item["downloaded"])
|
|
|
|
|
|
class WatchlistBackupTests(unittest.TestCase):
|
|
def setUp(self):
|
|
with APP.WATCHLIST._connect() as conn:
|
|
conn.execute("DELETE FROM watchlist")
|
|
conn.execute("DELETE FROM watchlist_removals")
|
|
|
|
def seed_watchlist_item(self, **overrides):
|
|
now = APP.now_iso()
|
|
item = {
|
|
"show_id": "show-backup-1",
|
|
"title": "Backup Show",
|
|
"category": "finished",
|
|
"downloaded": True,
|
|
"status": "updated",
|
|
"status_message": "Ready.",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"last_checked": now,
|
|
"sub_count": 2,
|
|
"dub_count": 2,
|
|
"expected_count": 2,
|
|
"airing_status": "Finished",
|
|
"sub_latest_episode": "2",
|
|
"dub_latest_episode": "2",
|
|
"animeschedule_route": "backup-route",
|
|
"animeschedule_title": "Backup Schedule",
|
|
"anidb_aid": "123",
|
|
"anidb_title": "Backup AniDB",
|
|
"media_type": "tv",
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"auto_download_name": "Backup Show",
|
|
"auto_download_source_name": "Backup Search",
|
|
"auto_download_series": "1",
|
|
"auto_download_offset": None,
|
|
"downloaded_sub_episodes_json": APP.encode_episode_values(["1"]),
|
|
"downloaded_dub_episodes_json": APP.encode_episode_values(["1", "2"]),
|
|
"thumbnail_path": "show-backup-1.jpg",
|
|
"thumbnail_checked_at": now,
|
|
}
|
|
item.update(overrides)
|
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
|
APP.WATCHLIST._upsert_conn(conn, item)
|
|
|
|
def test_export_watchlist_backup_includes_raw_watchlist_and_removal_rows(self):
|
|
self.seed_watchlist_item()
|
|
with APP.WATCHLIST._connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO watchlist_removals (show_id, title, removed_at) VALUES (?, ?, ?)",
|
|
("removed-show", "Removed Show", APP.now_iso()),
|
|
)
|
|
|
|
backup = APP.export_watchlist_backup()
|
|
|
|
self.assertEqual(backup["format"], "kaizoku-watchlist-backup")
|
|
self.assertEqual(backup["format_version"], 1)
|
|
self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["show_id"], "show-backup-1")
|
|
self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["downloaded_dub_episodes_json"], APP.encode_episode_values(["1", "2"]))
|
|
self.assertEqual(backup["tables"]["watchlist_removals"]["rows"][0]["show_id"], "removed-show")
|
|
|
|
def test_import_watchlist_backup_replaces_existing_watchlist_tables(self):
|
|
self.seed_watchlist_item(show_id="old-show", title="Old Show")
|
|
backup = APP.export_watchlist_backup()
|
|
backup["tables"]["watchlist"]["rows"][0]["show_id"] = "new-show"
|
|
backup["tables"]["watchlist"]["rows"][0]["title"] = "New Show"
|
|
backup["tables"]["watchlist_removals"]["rows"] = [
|
|
{"show_id": "removed-new", "title": "Removed New", "removed_at": APP.now_iso()}
|
|
]
|
|
|
|
result = APP.import_watchlist_backup(backup)
|
|
|
|
self.assertEqual(result["imported"], 1)
|
|
with self.assertRaises(KeyError):
|
|
APP.WATCHLIST.get("old-show")
|
|
restored = APP.WATCHLIST.get("new-show")
|
|
self.assertEqual(restored["title"], "New Show")
|
|
self.assertEqual(restored["downloaded_dub_episodes"], ["1", "2"])
|
|
with APP.WATCHLIST._connect() as conn:
|
|
removal = conn.execute("SELECT title FROM watchlist_removals WHERE show_id = ?", ("removed-new",)).fetchone()
|
|
self.assertEqual(removal["title"], "Removed New")
|
|
|
|
def test_import_watchlist_backup_rejects_unrecognized_format(self):
|
|
with self.assertRaises(ValueError):
|
|
APP.import_watchlist_backup({"format": "not-this-app", "format_version": 1, "tables": {}})
|
|
|
|
|
|
class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
|
|
def setUp(self):
|
|
with APP.WATCHLIST._connect() as conn:
|
|
conn.execute("DELETE FROM watchlist")
|
|
|
|
def seed_watchlist_item(self, **overrides):
|
|
now = APP.now_iso()
|
|
item = {
|
|
"show_id": "show-1",
|
|
"title": "Example Show",
|
|
"category": "watching",
|
|
"downloaded": False,
|
|
"status": "tracked",
|
|
"status_message": "Waiting for the first manual refresh.",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"last_checked": None,
|
|
"sub_count": 0,
|
|
"dub_count": 0,
|
|
"expected_count": 0,
|
|
"airing_status": None,
|
|
"sub_latest_episode": None,
|
|
"dub_latest_episode": None,
|
|
"animeschedule_route": None,
|
|
"animeschedule_title": None,
|
|
"anidb_aid": None,
|
|
"anidb_title": None,
|
|
"thumbnail_path": None,
|
|
"thumbnail_checked_at": None,
|
|
}
|
|
item.update(overrides)
|
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
|
APP.WATCHLIST._upsert_conn(conn, item)
|
|
|
|
def test_restart_restores_queued_watchlist_refreshes(self):
|
|
self.seed_watchlist_item(
|
|
show_id="show-b",
|
|
title="Beta Show",
|
|
status="queued",
|
|
status_message="Queued refresh",
|
|
updated_at="2026-05-15T10:00:00+00:00",
|
|
)
|
|
self.seed_watchlist_item(
|
|
show_id="show-a",
|
|
title="Alpha Show",
|
|
status="queued",
|
|
status_message="Queued refresh",
|
|
updated_at="2026-05-15T09:00:00+00:00",
|
|
)
|
|
self.seed_watchlist_item(
|
|
show_id="show-c",
|
|
title="Gamma Show",
|
|
status="updated",
|
|
status_message="Updated",
|
|
updated_at="2026-05-15T11:00:00+00:00",
|
|
)
|
|
|
|
restored = APP.WatchlistStore(start_worker=False)
|
|
|
|
self.assertEqual(restored.refresh_pending, [("show-a", "manual"), ("show-b", "manual")])
|
|
self.assertEqual(restored.refresh_pending_set, {"show-a", "show-b"})
|
|
|
|
|
|
class WatchlistRefreshQueueTests(unittest.TestCase):
|
|
def test_schedule_refresh_skips_show_already_in_flight(self):
|
|
store = object.__new__(APP.WatchlistStore)
|
|
store.lock = threading.RLock()
|
|
store.thumbnail_events = {}
|
|
store.refresh_pending = []
|
|
store.refresh_pending_set = set()
|
|
store.refresh_inflight_set = {"show-1"}
|
|
store.refresh_wakeup = threading.Event()
|
|
store.refresh_stop_event = threading.Event()
|
|
store.refresh_worker = None
|
|
store.worker_enabled = False
|
|
store.ensure_worker = lambda: None
|
|
store.get = lambda show_id: {"show_id": show_id, "status": "queued"}
|
|
|
|
item = APP.WatchlistStore.schedule_refresh(store, "show-1")
|
|
|
|
self.assertEqual(item["show_id"], "show-1")
|
|
self.assertEqual(store.refresh_pending, [])
|
|
self.assertEqual(store.refresh_pending_set, set())
|
|
self.assertEqual(store.refresh_inflight_set, {"show-1"})
|
|
|
|
def test_refresh_loop_clears_inflight_marker_after_refresh(self):
|
|
store = object.__new__(APP.WatchlistStore)
|
|
store.lock = threading.RLock()
|
|
store.thumbnail_events = {}
|
|
store.refresh_pending = [("show-2", "manual")]
|
|
store.refresh_pending_set = {"show-2"}
|
|
store.refresh_inflight_set = set()
|
|
store.refresh_wakeup = threading.Event()
|
|
store.refresh_stop_event = threading.Event()
|
|
store.refresh_worker = None
|
|
store.worker_enabled = False
|
|
calls = []
|
|
|
|
def fake_refresh(show_id, refresh_source="manual"):
|
|
calls.append((show_id, refresh_source))
|
|
store.refresh_stop_event.set()
|
|
|
|
store.refresh = fake_refresh
|
|
|
|
APP.WatchlistStore._refresh_loop(store)
|
|
|
|
self.assertEqual(calls, [("show-2", "manual")])
|
|
self.assertEqual(store.refresh_inflight_set, set())
|
|
self.assertEqual(store.refresh_pending, [])
|
|
self.assertEqual(store.refresh_pending_set, set())
|
|
|
|
def test_remove_drops_pending_and_inflight_refresh_state(self):
|
|
store = object.__new__(APP.WatchlistStore)
|
|
store.lock = threading.RLock()
|
|
thumbnail_event = threading.Event()
|
|
store.thumbnail_events = {"show-3": thumbnail_event}
|
|
store.refresh_pending = [("show-3", "manual"), ("show-4", "manual")]
|
|
store.refresh_pending_set = {"show-3", "show-4"}
|
|
store.refresh_inflight_set = {"show-3"}
|
|
store._connect = APP.WATCHLIST._connect
|
|
store.get = lambda show_id: {"show_id": show_id, "title": "Queued Show", "thumbnail_path": None}
|
|
|
|
class DummyCursor:
|
|
rowcount = 1
|
|
|
|
class DummyConn:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, _sql, _params):
|
|
return DummyCursor()
|
|
|
|
store._connect = lambda: DummyConn()
|
|
|
|
result = APP.WatchlistStore.remove(store, "show-3")
|
|
|
|
self.assertEqual(result["item"]["show_id"], "show-3")
|
|
self.assertEqual(store.refresh_pending, [("show-4", "manual")])
|
|
self.assertEqual(store.refresh_pending_set, {"show-4"})
|
|
self.assertEqual(store.refresh_inflight_set, set())
|
|
self.assertTrue(thumbnail_event.is_set())
|
|
self.assertNotIn("show-3", store.thumbnail_events)
|
|
|
|
|
|
class ConfigSnapshotTests(unittest.TestCase):
|
|
def test_get_config_snapshot_returns_copy(self):
|
|
original = APP.CONFIG
|
|
APP.CONFIG = {
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"download_dir": "/tmp/example",
|
|
"watchlist_auto_refresh_enabled": False,
|
|
"watchlist_auto_refresh_minutes": 60,
|
|
"watchlist_refresh_delay_seconds": 5,
|
|
"auto_download_enabled": False,
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"discord_webhook_url": "",
|
|
"discord_webhook_events": [],
|
|
}
|
|
try:
|
|
snapshot = APP.get_config_snapshot()
|
|
snapshot["mode"] = "dub"
|
|
self.assertEqual(APP.CONFIG["mode"], "sub")
|
|
finally:
|
|
APP.CONFIG = original
|
|
|
|
def test_normalize_config_includes_watchlist_schedule_defaults(self):
|
|
config = APP.normalize_config({})
|
|
self.assertFalse(config["watchlist_auto_refresh_enabled"])
|
|
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
|
|
self.assertEqual(config["watchlist_refresh_delay_seconds"], 5)
|
|
self.assertEqual(config["download_methods"], ["kaizoku"])
|
|
self.assertFalse(config["auto_download_enabled"])
|
|
self.assertEqual(config["auto_download_mode"], "dub")
|
|
self.assertEqual(config["auto_download_quality"], "best")
|
|
self.assertFalse(config["jellyfin_sync_enabled"])
|
|
self.assertEqual(config["jellyfin_tv_dir"], "")
|
|
self.assertEqual(config["jellyfin_movie_dir"], "")
|
|
self.assertEqual(config["discord_webhook_url"], "")
|
|
self.assertEqual(config["discord_webhook_events"], [])
|
|
|
|
def test_normalize_config_parses_watchlist_schedule_fields(self):
|
|
config = APP.normalize_config(
|
|
{
|
|
"watchlist_auto_refresh_enabled": "true",
|
|
"watchlist_auto_refresh_minutes": "15",
|
|
"watchlist_refresh_delay_seconds": "7",
|
|
"auto_download_enabled": "true",
|
|
"auto_download_mode": "sub",
|
|
"auto_download_quality": "720p",
|
|
"jellyfin_sync_enabled": "true",
|
|
"jellyfin_tv_dir": "~/jellyfin/tv",
|
|
"jellyfin_movie_dir": "~/jellyfin/movies",
|
|
}
|
|
)
|
|
self.assertEqual(config["download_methods"], ["kaizoku"])
|
|
self.assertTrue(config["watchlist_auto_refresh_enabled"])
|
|
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
|
|
self.assertEqual(config["watchlist_refresh_delay_seconds"], 7)
|
|
self.assertTrue(config["auto_download_enabled"])
|
|
self.assertEqual(config["auto_download_mode"], "sub")
|
|
self.assertEqual(config["auto_download_quality"], "720")
|
|
self.assertTrue(config["jellyfin_sync_enabled"])
|
|
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": ["kaizoku", "bad-method", "kaizoku"],
|
|
}
|
|
)
|
|
|
|
self.assertEqual(config["download_methods"], ["kaizoku"])
|
|
|
|
def test_remote_path_roots_accept_comma_and_platform_separators(self):
|
|
with tempfile.TemporaryDirectory() as first_root, tempfile.TemporaryDirectory() as second_root, tempfile.TemporaryDirectory() as third_root:
|
|
raw = f"{first_root},{second_root}{os.pathsep}{third_root}"
|
|
with mock.patch.dict(os.environ, {"KAIZOKU_REMOTE_PATH_ROOTS": raw}, clear=False):
|
|
roots = APP.app_support._env_remote_path_roots()
|
|
|
|
self.assertEqual(roots, [Path(first_root).resolve(), Path(second_root).resolve(), Path(third_root).resolve()])
|
|
|
|
def test_normalize_config_filters_discord_webhook_events(self):
|
|
config = APP.normalize_config(
|
|
{
|
|
"discord_webhook_url": " https://discord.com/api/webhooks/123456/test-token ",
|
|
"discord_webhook_events": [
|
|
"watchlist_refresh_new_episodes",
|
|
"runtime_error",
|
|
"runtime_error",
|
|
"nope",
|
|
],
|
|
}
|
|
)
|
|
self.assertEqual(config["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token")
|
|
self.assertEqual(
|
|
config["discord_webhook_events"],
|
|
["watchlist_refresh_new_episodes", "runtime_error"],
|
|
)
|
|
|
|
def test_test_discord_webhook_config_requires_url(self):
|
|
with self.assertRaises(ValueError):
|
|
APP.test_discord_webhook_config({"discord_webhook_url": ""})
|
|
|
|
def test_test_discord_webhook_config_sends_test_event(self):
|
|
with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook:
|
|
result = APP.test_discord_webhook_config(
|
|
{
|
|
"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token",
|
|
"discord_webhook_events": [],
|
|
}
|
|
)
|
|
|
|
self.assertEqual(result["message"], "Discord webhook test notification sent.")
|
|
send_webhook.assert_called_once()
|
|
config, event_name = send_webhook.call_args.args[:2]
|
|
self.assertEqual(config["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token")
|
|
self.assertEqual(event_name, "test")
|
|
self.assertTrue(send_webhook.call_args.kwargs["force"])
|
|
|
|
|
|
class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
|
|
def test_tick_waits_then_triggers_refresh(self):
|
|
config = {
|
|
"watchlist_auto_refresh_enabled": True,
|
|
"watchlist_auto_refresh_minutes": 10,
|
|
}
|
|
start = mock.Mock(return_value={"created": True})
|
|
scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False)
|
|
|
|
self.assertEqual(scheduler.tick(now_monotonic=100.0), 600)
|
|
self.assertEqual(scheduler.tick(now_monotonic=699.0), 1)
|
|
self.assertEqual(scheduler.tick(now_monotonic=700.0), 600)
|
|
start.assert_called_once_with(source="scheduled")
|
|
|
|
def test_refresh_job_waits_between_series_using_configured_delay(self):
|
|
class FakeStore:
|
|
def all_show_ids(self, categories=None):
|
|
return ["show-a", "show-b", "show-c"]
|
|
|
|
def refresh(self, show_id, include_thumbnail=True):
|
|
return {"title": show_id, "status": "updated"}
|
|
|
|
service = APP.WatchlistRefreshJobs(
|
|
FakeStore(),
|
|
config_getter=lambda: {"watchlist_refresh_delay_seconds": 5},
|
|
start_worker=False,
|
|
)
|
|
job = service.start()["job"]
|
|
job = service._next_pending()
|
|
|
|
with mock.patch.object(service.stop_event, "wait", return_value=False) as wait_mock:
|
|
service._run_job(job)
|
|
|
|
self.assertEqual(wait_mock.call_count, 2)
|
|
wait_mock.assert_called_with(5)
|
|
|
|
def test_refresh_job_sends_new_episode_and_failure_notifications(self):
|
|
class FakeStore:
|
|
def all_show_ids(self, categories=None):
|
|
return ["show-a", "show-b"]
|
|
|
|
def refresh(self, show_id, include_thumbnail=True):
|
|
if show_id == "show-a":
|
|
return {
|
|
"show_id": "show-a",
|
|
"title": "Show A",
|
|
"status": "updated",
|
|
"had_new_episodes": True,
|
|
"sub_count": 12,
|
|
"dub_count": 4,
|
|
"sub_delta": 1,
|
|
"dub_delta": 0,
|
|
"expected_count": 12,
|
|
"airing_status": "Finished",
|
|
"thumbnail_path": None,
|
|
}
|
|
return {
|
|
"show_id": "show-b",
|
|
"title": "Show B",
|
|
"status": "error",
|
|
"status_message": "Could not refresh episodes: boom",
|
|
"had_new_episodes": False,
|
|
}
|
|
|
|
service = APP.WatchlistRefreshJobs(
|
|
FakeStore(),
|
|
config_getter=lambda: {
|
|
"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token",
|
|
"discord_webhook_events": [
|
|
"watchlist_refresh_new_episodes",
|
|
"watchlist_refresh_failed",
|
|
],
|
|
},
|
|
start_worker=False,
|
|
)
|
|
job = service.start()["job"]
|
|
job = service._next_pending()
|
|
|
|
with mock.patch.object(queue_jobs, "send_discord_webhook_event") as send_webhook:
|
|
service._run_job(job)
|
|
|
|
self.assertEqual(send_webhook.call_count, 2)
|
|
self.assertEqual(send_webhook.call_args_list[0].args[1], "watchlist_refresh_new_episodes")
|
|
self.assertEqual(send_webhook.call_args_list[0].args[2]["items"][0]["title"], "Show A")
|
|
self.assertEqual(send_webhook.call_args_list[1].args[1], "watchlist_refresh_failed")
|
|
self.assertEqual(send_webhook.call_args_list[1].args[2]["items"][0]["title"], "Show B")
|
|
|
|
|
|
class DiscordWebhookFormattingTests(unittest.TestCase):
|
|
def test_download_completed_webhook_sends_full_file_list_across_embeds(self):
|
|
payloads = []
|
|
with mock.patch.object(APP.app_support, "_post_discord_webhook", side_effect=lambda url, payload, attachments=None, timeout=10: payloads.append(payload)):
|
|
result = APP.app_support.send_discord_webhook_event(
|
|
{
|
|
"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token",
|
|
"discord_webhook_events": ["download_completed"],
|
|
},
|
|
"download_completed",
|
|
{
|
|
"title": "Big Download",
|
|
"mode": "sub",
|
|
"episodes": "1-12",
|
|
"category": "finished",
|
|
"moved_files": [f"/tmp/downloads/Big Download/File {index:02d}.mp4" for index in range(1, 130)],
|
|
},
|
|
)
|
|
|
|
self.assertTrue(result["sent"])
|
|
self.assertGreaterEqual(len(payloads), 1)
|
|
all_descriptions = "\n".join(
|
|
embed.get("description", "")
|
|
for payload in payloads
|
|
for embed in payload.get("embeds", [])
|
|
)
|
|
self.assertIn("/tmp/downloads/Big Download/File 01.mp4", all_descriptions)
|
|
self.assertIn("/tmp/downloads/Big Download/File 129.mp4", all_descriptions)
|
|
|
|
def test_jellyfin_success_webhook_includes_moved_files(self):
|
|
payloads = []
|
|
with mock.patch.object(APP.app_support, "_post_discord_webhook", side_effect=lambda url, payload, attachments=None, timeout=10: payloads.append(payload)):
|
|
result = APP.app_support.send_discord_webhook_event(
|
|
{
|
|
"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token",
|
|
"discord_webhook_events": ["jellyfin_sync_success"],
|
|
},
|
|
"jellyfin_sync_success",
|
|
{
|
|
"title": "Jelly Show",
|
|
"media_type": "tv",
|
|
"source": "automatic",
|
|
"target": "/srv/jellyfin/Jelly Show",
|
|
"moved_files": ["/srv/jellyfin/Jelly Show/Season 01/Jelly Show - S01E01.mp4"],
|
|
},
|
|
)
|
|
|
|
self.assertTrue(result["sent"])
|
|
self.assertEqual(payloads[0]["content"], "Kaizoku moved a finished library into Jellyfin.")
|
|
self.assertIn("/srv/jellyfin/Jelly Show/Season 01/Jelly Show - S01E01.mp4", payloads[0]["embeds"][1]["description"])
|
|
|
|
def test_tick_resets_timer_when_schedule_changes(self):
|
|
config = {
|
|
"watchlist_auto_refresh_enabled": True,
|
|
"watchlist_auto_refresh_minutes": 10,
|
|
}
|
|
start = mock.Mock(return_value={"created": True})
|
|
scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False)
|
|
|
|
self.assertEqual(scheduler.tick(now_monotonic=100.0), 600)
|
|
config["watchlist_auto_refresh_minutes"] = 5
|
|
self.assertEqual(scheduler.tick(now_monotonic=150.0), 300)
|
|
start.assert_not_called()
|
|
|
|
def test_tick_returns_none_when_disabled(self):
|
|
config = {
|
|
"watchlist_auto_refresh_enabled": False,
|
|
"watchlist_auto_refresh_minutes": 10,
|
|
}
|
|
start = mock.Mock(return_value={"created": True})
|
|
scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False)
|
|
|
|
self.assertIsNone(scheduler.tick(now_monotonic=100.0))
|
|
start.assert_not_called()
|
|
|
|
|
|
class AutoDownloadQueueTests(unittest.TestCase):
|
|
def test_queue_watchlist_auto_download_skips_initial_refresh(self):
|
|
item = {
|
|
"show_id": "show-1",
|
|
"title": "Example Show",
|
|
"category": "watching",
|
|
"previous_last_checked": None,
|
|
"had_new_episodes": True,
|
|
}
|
|
with mock.patch.object(
|
|
APP,
|
|
"ensure_runtime",
|
|
return_value={"config": {"auto_download_enabled": True}, "download_queue": mock.Mock()},
|
|
):
|
|
result = APP.queue_watchlist_auto_download(item, refresh_source="initial")
|
|
self.assertFalse(result["queued"])
|
|
self.assertEqual(result["reason"], "source:initial")
|
|
|
|
def test_queue_watchlist_auto_download_queues_only_missing_new_episodes(self):
|
|
item = {
|
|
"show_id": "show-42",
|
|
"title": "Queue Show",
|
|
"category": "watching",
|
|
"previous_last_checked": "2026-05-25T10:00:00+00:00",
|
|
"had_new_episodes": False,
|
|
"previous_dub_count": 10,
|
|
"previous_sub_count": 10,
|
|
"dub_episode_values": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"],
|
|
"sub_episode_values": ["1", "2", "3"],
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"auto_download_name": 'Queue Show: Alt/Name',
|
|
"auto_download_source_name": "Queue Show Source",
|
|
"auto_download_series": "3",
|
|
"auto_download_offset": "13",
|
|
"downloaded_dub_episodes": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
|
|
"downloaded_sub_episodes": [],
|
|
}
|
|
download_queue = mock.Mock()
|
|
download_queue.covered_episodes.return_value = {"11"}
|
|
download_queue.add.side_effect = [
|
|
{"id": "job-12", "episodes": "12"},
|
|
]
|
|
runtime = {
|
|
"config": {
|
|
"auto_download_enabled": True,
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"download_dir": "/tmp/downloads",
|
|
},
|
|
"download_queue": download_queue,
|
|
}
|
|
with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object(
|
|
APP,
|
|
"search_anime",
|
|
side_effect=AssertionError("search should not be required"),
|
|
):
|
|
result = APP.queue_watchlist_auto_download(item, refresh_source="scheduled")
|
|
|
|
self.assertTrue(result["queued"])
|
|
self.assertEqual(result["episodes"], ["12"])
|
|
download_queue.add.assert_called_once_with(
|
|
{
|
|
"show_id": "show-42",
|
|
"provider": "anikoto",
|
|
"query": "Queue Show Source",
|
|
"title": "Queue Show",
|
|
"anime_name": "Queue Show- Alt-Name",
|
|
"media_type": "tv",
|
|
"season": "3",
|
|
"episode_offset": "13",
|
|
"mode": "dub",
|
|
"quality": "best",
|
|
"episodes": "12",
|
|
"download_dir": "/tmp/downloads",
|
|
}
|
|
)
|
|
|
|
def test_queue_watchlist_auto_download_queues_season_zero(self):
|
|
item = {
|
|
"show_id": "show-42",
|
|
"title": "Queue Specials",
|
|
"category": "watching",
|
|
"dub_episode_values": ["1"],
|
|
"sub_episode_values": [],
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"auto_download_name": "Queue Specials",
|
|
"auto_download_source_name": "Queue Specials Source",
|
|
"auto_download_series": "0",
|
|
"auto_download_offset": None,
|
|
"downloaded_dub_episodes": [],
|
|
"downloaded_sub_episodes": [],
|
|
}
|
|
download_queue = mock.Mock()
|
|
download_queue.covered_episodes.return_value = set()
|
|
runtime = {
|
|
"config": {
|
|
"auto_download_enabled": True,
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"download_dir": "/tmp/downloads",
|
|
},
|
|
"download_queue": download_queue,
|
|
}
|
|
with mock.patch.object(APP, "ensure_runtime", return_value=runtime):
|
|
result = APP.queue_watchlist_auto_download(item, refresh_source="manual")
|
|
|
|
self.assertTrue(result["queued"])
|
|
self.assertEqual(download_queue.add.call_args.args[0]["season"], "0")
|
|
|
|
def test_queue_watchlist_auto_download_can_queue_existing_backlog_after_initial_seed_refresh(self):
|
|
item = {
|
|
"show_id": "show-99",
|
|
"title": "Backlog Show",
|
|
"category": "watching",
|
|
"previous_last_checked": "2026-05-25T10:00:00+00:00",
|
|
"had_new_episodes": False,
|
|
"dub_episode_values": ["1", "2", "3"],
|
|
"sub_episode_values": [],
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"auto_download_name": "Backlog Show",
|
|
"auto_download_source_name": "Backlog Show Search",
|
|
"auto_download_series": "1",
|
|
"auto_download_offset": None,
|
|
"downloaded_dub_episodes": [],
|
|
"downloaded_sub_episodes": [],
|
|
}
|
|
download_queue = mock.Mock()
|
|
download_queue.covered_episodes.return_value = set()
|
|
download_queue.add.return_value = {"id": "job-backlog", "episodes": "1-3"}
|
|
runtime = {
|
|
"config": {
|
|
"auto_download_enabled": True,
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"download_dir": "/tmp/downloads",
|
|
},
|
|
"download_queue": download_queue,
|
|
}
|
|
with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object(
|
|
APP,
|
|
"search_anime",
|
|
side_effect=AssertionError("search should not be required"),
|
|
):
|
|
result = APP.queue_watchlist_auto_download(item, refresh_source="manual")
|
|
|
|
self.assertTrue(result["queued"])
|
|
self.assertEqual(result["episodes"], ["1-3"])
|
|
download_queue.add.assert_called_once_with(
|
|
{
|
|
"show_id": "show-99",
|
|
"provider": "anikoto",
|
|
"query": "Backlog Show Search",
|
|
"title": "Backlog Show",
|
|
"anime_name": "Backlog Show",
|
|
"media_type": "tv",
|
|
"season": "1",
|
|
"episode_offset": None,
|
|
"mode": "dub",
|
|
"quality": "best",
|
|
"episodes": "1-3",
|
|
"download_dir": "/tmp/downloads",
|
|
}
|
|
)
|
|
|
|
def test_queue_watchlist_auto_download_can_queue_backlog_on_first_manual_refresh(self):
|
|
item = {
|
|
"show_id": "show-first-refresh",
|
|
"title": "First Refresh Show",
|
|
"category": "watching",
|
|
"previous_last_checked": None,
|
|
"had_new_episodes": False,
|
|
"dub_episode_values": ["1", "2", "3"],
|
|
"sub_episode_values": [],
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"auto_download_name": "First Refresh Show",
|
|
"auto_download_source_name": "First Refresh Source",
|
|
"auto_download_series": "1",
|
|
"auto_download_offset": None,
|
|
"downloaded_dub_episodes": [],
|
|
"downloaded_sub_episodes": [],
|
|
}
|
|
download_queue = mock.Mock()
|
|
download_queue.covered_episodes.return_value = set()
|
|
download_queue.add.return_value = {"id": "job-first-refresh", "episodes": "1-3"}
|
|
runtime = {
|
|
"config": {
|
|
"auto_download_enabled": True,
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"download_dir": "/tmp/downloads",
|
|
},
|
|
"download_queue": download_queue,
|
|
}
|
|
with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object(
|
|
APP,
|
|
"search_anime",
|
|
side_effect=AssertionError("search should not be required"),
|
|
):
|
|
result = APP.queue_watchlist_auto_download(item, refresh_source="manual")
|
|
|
|
self.assertTrue(result["queued"])
|
|
self.assertEqual(result["episodes"], ["1-3"])
|
|
download_queue.add.assert_called_once_with(
|
|
{
|
|
"show_id": "show-first-refresh",
|
|
"provider": "anikoto",
|
|
"query": "First Refresh Source",
|
|
"title": "First Refresh Show",
|
|
"anime_name": "First Refresh Show",
|
|
"media_type": "tv",
|
|
"season": "1",
|
|
"episode_offset": None,
|
|
"mode": "dub",
|
|
"quality": "best",
|
|
"episodes": "1-3",
|
|
"download_dir": "/tmp/downloads",
|
|
}
|
|
)
|
|
|
|
def test_queue_watchlist_auto_download_passes_episode_offset_to_jobs(self):
|
|
item = {
|
|
"show_id": "show-77",
|
|
"title": "Split Season Show",
|
|
"category": "watching",
|
|
"previous_last_checked": "2026-05-25T10:00:00+00:00",
|
|
"had_new_episodes": False,
|
|
"dub_episode_values": ["1", "2"],
|
|
"sub_episode_values": [],
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"auto_download_name": "Split Season Show",
|
|
"auto_download_source_name": "Split Season Search",
|
|
"auto_download_series": "2",
|
|
"auto_download_offset": "13",
|
|
"downloaded_dub_episodes": [],
|
|
"downloaded_sub_episodes": [],
|
|
}
|
|
download_queue = mock.Mock()
|
|
download_queue.covered_episodes.return_value = set()
|
|
download_queue.add.return_value = {"id": "job-split", "episodes": "1-2"}
|
|
runtime = {
|
|
"config": {
|
|
"auto_download_enabled": True,
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"download_dir": "/tmp/downloads",
|
|
},
|
|
"download_queue": download_queue,
|
|
}
|
|
with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object(
|
|
APP,
|
|
"search_anime",
|
|
side_effect=AssertionError("search should not be required"),
|
|
):
|
|
result = APP.queue_watchlist_auto_download(item, refresh_source="manual")
|
|
|
|
self.assertTrue(result["queued"])
|
|
download_queue.add.assert_called_once_with(
|
|
{
|
|
"show_id": "show-77",
|
|
"provider": "anikoto",
|
|
"query": "Split Season Search",
|
|
"title": "Split Season Show",
|
|
"anime_name": "Split Season Show",
|
|
"media_type": "tv",
|
|
"season": "2",
|
|
"episode_offset": "13",
|
|
"mode": "dub",
|
|
"quality": "best",
|
|
"episodes": "1-2",
|
|
"download_dir": "/tmp/downloads",
|
|
}
|
|
)
|
|
|
|
class StartupBehaviorTests(unittest.TestCase):
|
|
def test_main_does_not_eagerly_initialize_runtime(self):
|
|
server = mock.Mock()
|
|
server.serve_forever.side_effect = KeyboardInterrupt()
|
|
|
|
with mock.patch.object(APP, "initialize_runtime") as initialize_runtime, mock.patch.object(
|
|
APP, "ThreadingHTTPServer", return_value=server
|
|
), mock.patch.object(APP, "shutdown_runtime") as shutdown_runtime, mock.patch.object(
|
|
APP, "start_provider_update_check"
|
|
) as start_provider_update_check:
|
|
shutdown_runtime.return_value = True
|
|
APP.main()
|
|
|
|
initialize_runtime.assert_not_called()
|
|
start_provider_update_check.assert_called_once()
|
|
shutdown_runtime.assert_called_once_with(wait=True, cancel_active_downloads=True)
|
|
server.serve_forever.assert_called_once()
|
|
server.server_close.assert_called_once()
|
|
|
|
def test_reset_runtime_wait_defaults_to_cancel_active_downloads(self):
|
|
APP.initialize_runtime(start_workers=False)
|
|
try:
|
|
with mock.patch.object(APP, "shutdown_runtime") as shutdown_runtime:
|
|
shutdown_runtime.return_value = True
|
|
APP.reset_runtime(wait=True)
|
|
|
|
shutdown_runtime.assert_called_once_with(wait=True, cancel_active_downloads=None)
|
|
self.assertIsNone(APP.CONFIG)
|
|
finally:
|
|
APP.initialize_runtime(start_workers=False)
|
|
|
|
def test_reset_runtime_refuses_to_clear_globals_when_shutdown_does_not_finish(self):
|
|
APP.initialize_runtime(start_workers=False)
|
|
try:
|
|
current_config = APP.CONFIG
|
|
current_watchlist = APP.WATCHLIST
|
|
current_queue = APP.DOWNLOAD_QUEUE
|
|
current_refresh = APP.WATCHLIST_REFRESH
|
|
current_auto_refresh = APP.WATCHLIST_AUTO_REFRESH
|
|
current_jellyfin_sync = APP.WATCHLIST_JELLYFIN_SYNC
|
|
with mock.patch.object(APP, "shutdown_runtime", return_value=False):
|
|
with self.assertRaises(RuntimeError):
|
|
APP.reset_runtime(wait=True)
|
|
|
|
self.assertIs(APP.CONFIG, current_config)
|
|
self.assertIs(APP.WATCHLIST, current_watchlist)
|
|
self.assertIs(APP.DOWNLOAD_QUEUE, current_queue)
|
|
self.assertIs(APP.WATCHLIST_REFRESH, current_refresh)
|
|
self.assertIs(APP.WATCHLIST_AUTO_REFRESH, current_auto_refresh)
|
|
self.assertIs(APP.WATCHLIST_JELLYFIN_SYNC, current_jellyfin_sync)
|
|
finally:
|
|
APP.reset_runtime(wait=True)
|
|
APP.initialize_runtime(start_workers=False)
|
|
|
|
def test_save_runtime_config_replaces_runtime_config(self):
|
|
original = APP.CONFIG
|
|
original_scheduler = APP.WATCHLIST_AUTO_REFRESH
|
|
try:
|
|
APP.CONFIG = {
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"download_dir": "/tmp/old",
|
|
"watchlist_auto_refresh_enabled": False,
|
|
"watchlist_auto_refresh_minutes": 60,
|
|
"watchlist_refresh_delay_seconds": 5,
|
|
"auto_download_enabled": False,
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"discord_webhook_url": "",
|
|
"discord_webhook_events": [],
|
|
}
|
|
APP.WATCHLIST_AUTO_REFRESH = mock.Mock()
|
|
saved = APP.save_runtime_config(
|
|
{
|
|
"mode": "dub",
|
|
"quality": "720",
|
|
"download_dir": "/tmp/new",
|
|
"watchlist_auto_refresh_enabled": True,
|
|
"watchlist_auto_refresh_minutes": 15,
|
|
"watchlist_refresh_delay_seconds": 8,
|
|
"auto_download_enabled": True,
|
|
"auto_download_mode": "sub",
|
|
"auto_download_quality": "720",
|
|
"jellyfin_sync_enabled": True,
|
|
"jellyfin_tv_dir": "/tmp/jellyfin-tv",
|
|
"jellyfin_movie_dir": "/tmp/jellyfin-movies",
|
|
"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token",
|
|
"discord_webhook_events": ["runtime_error"],
|
|
}
|
|
)
|
|
self.assertEqual(saved["mode"], "dub")
|
|
self.assertEqual(APP.CONFIG["mode"], "dub")
|
|
self.assertEqual(APP.CONFIG["quality"], "720")
|
|
self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"])
|
|
self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15)
|
|
self.assertEqual(APP.CONFIG["watchlist_refresh_delay_seconds"], 8)
|
|
self.assertTrue(APP.CONFIG["auto_download_enabled"])
|
|
self.assertEqual(APP.CONFIG["auto_download_mode"], "sub")
|
|
self.assertEqual(APP.CONFIG["auto_download_quality"], "720")
|
|
self.assertTrue(APP.CONFIG["jellyfin_sync_enabled"])
|
|
self.assertEqual(APP.CONFIG["jellyfin_tv_dir"], "/tmp/jellyfin-tv")
|
|
self.assertEqual(APP.CONFIG["jellyfin_movie_dir"], "/tmp/jellyfin-movies")
|
|
self.assertEqual(APP.CONFIG["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token")
|
|
self.assertEqual(APP.CONFIG["discord_webhook_events"], ["runtime_error"])
|
|
APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with()
|
|
finally:
|
|
APP.CONFIG = original
|
|
APP.WATCHLIST_AUTO_REFRESH = original_scheduler
|
|
|
|
def test_reset_runtime_clears_runtime_services(self):
|
|
APP.initialize_runtime(start_workers=False)
|
|
APP.reset_runtime(wait=True)
|
|
try:
|
|
self.assertIsNone(APP.CONFIG)
|
|
self.assertIsNone(APP.WATCHLIST)
|
|
self.assertIsNone(APP.DOWNLOAD_QUEUE)
|
|
self.assertIsNone(APP.WATCHLIST_REFRESH)
|
|
self.assertIsNone(APP.WATCHLIST_AUTO_REFRESH)
|
|
self.assertIsNone(APP.WATCHLIST_JELLYFIN_SYNC)
|
|
finally:
|
|
APP.initialize_runtime(start_workers=False)
|
|
|
|
|
|
class ProviderUpdateTests(unittest.TestCase):
|
|
def test_provider_update_check_reports_up_to_date_files(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
provider_file = root / "Anime" / "pahe.js"
|
|
provider_file.parent.mkdir(parents=True)
|
|
provider_file.write_text("module.exports = {};\n", encoding="utf-8")
|
|
|
|
with mock.patch.object(
|
|
provider_updates, "upstream_provider_js_paths", return_value=({"Anime/pahe.js"}, "tree-sha")
|
|
), mock.patch.object(provider_updates, "fetch_bytes", return_value=provider_file.read_bytes()):
|
|
status = provider_updates.check_provider_updates(local_root=root)
|
|
|
|
self.assertEqual(status["status"], "up_to_date")
|
|
self.assertEqual(status["checked_files"], 1)
|
|
self.assertEqual(status["updated_files"], [])
|
|
self.assertEqual(status["upstream_tree_sha"], "tree-sha")
|
|
|
|
def test_provider_update_check_reports_changed_files(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
provider_file = root / "Anime" / "pahe.js"
|
|
provider_file.parent.mkdir(parents=True)
|
|
provider_file.write_text("local version\n", encoding="utf-8")
|
|
|
|
with mock.patch.object(
|
|
provider_updates, "upstream_provider_js_paths", return_value=({"Anime/pahe.js"}, "tree-sha")
|
|
), mock.patch.object(provider_updates, "fetch_bytes", return_value=b"upstream version\n"):
|
|
status = provider_updates.check_provider_updates(local_root=root)
|
|
|
|
self.assertEqual(status["status"], "updates_available")
|
|
self.assertEqual(status["checked_files"], 1)
|
|
self.assertEqual([item["path"] for item in status["updated_files"]], ["Anime/pahe.js"])
|
|
|
|
def test_provider_update_check_reports_local_provider_patch(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
provider_file = root / "Anime" / "anikoto.js"
|
|
provider_file.parent.mkdir(parents=True)
|
|
provider_file.write_text('module.exports = { version: "5.0.3" };\n', encoding="utf-8")
|
|
|
|
with mock.patch.object(
|
|
provider_updates, "upstream_provider_js_paths", return_value=({"Anime/anikoto.js"}, "tree-sha")
|
|
), mock.patch.object(
|
|
provider_updates, "fetch_bytes", return_value=b'module.exports = { version: "5.0.2" };\n'
|
|
):
|
|
status = provider_updates.check_provider_updates(local_root=root)
|
|
|
|
self.assertEqual(status["status"], "local_changes")
|
|
self.assertEqual(status["updated_files"], [])
|
|
self.assertEqual([item["path"] for item in status["locally_modified_files"]], ["Anime/anikoto.js"])
|
|
self.assertEqual(status["locally_modified_files"][0]["local_version"], "5.0.3")
|
|
self.assertEqual(status["locally_modified_files"][0]["upstream_version"], "5.0.2")
|
|
|
|
def test_provider_update_api_returns_current_status(self):
|
|
status = provider_updates.default_status("updates_available", "Providers changed.")
|
|
status["checked_files"] = 3
|
|
status["updated_files"] = [{"path": "Anime/pahe.js"}]
|
|
APP.set_provider_update_status(status)
|
|
handler = DummyHandler("/api/provider-updates")
|
|
handler.command = "GET"
|
|
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertEqual(handler.json_payload["status"], "updates_available")
|
|
self.assertEqual(handler.json_payload["checked_files"], 3)
|
|
self.assertEqual(handler.json_payload["updated_files"][0]["path"], "Anime/pahe.js")
|
|
|
|
|
|
class HandlerRouteTests(unittest.TestCase):
|
|
def test_runtime_backed_route_respects_worker_disable_flag(self):
|
|
handler = DummyHandler("/api/watchlist/refresh-status")
|
|
APP.reset_runtime(wait=True)
|
|
try:
|
|
with mock.patch.dict(os.environ, {"KAIZOKU_DISABLE_WORKER": "1"}, clear=False):
|
|
APP.Handler.do_GET(handler)
|
|
self.assertFalse(APP.WATCHLIST.worker_enabled)
|
|
self.assertIsNone(APP.WATCHLIST.refresh_worker)
|
|
self.assertIsNone(APP.DOWNLOAD_QUEUE.worker)
|
|
self.assertIsNone(APP.WATCHLIST_REFRESH.worker)
|
|
self.assertIsNone(APP.WATCHLIST_AUTO_REFRESH.worker)
|
|
self.assertIsNone(APP.WATCHLIST_JELLYFIN_SYNC.worker)
|
|
finally:
|
|
APP.reset_runtime(wait=True)
|
|
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"}'
|
|
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"], ["kaizoku"])
|
|
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)
|
|
self.assertTrue(handler.json_payload["auto_download_enabled"])
|
|
self.assertEqual(handler.json_payload["auto_download_mode"], "dub")
|
|
self.assertEqual(handler.json_payload["auto_download_quality"], "720")
|
|
self.assertTrue(handler.json_payload["jellyfin_sync_enabled"])
|
|
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":["kaizoku"]}'
|
|
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"], ["kaizoku"])
|
|
|
|
def test_version_route_reports_kaizoku_runtime(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.assertEqual(handler.json_payload["providers"], list(provider_bridge.PROVIDERS))
|
|
|
|
def test_dependencies_route_reports_provider_runtime_status(self):
|
|
handler = DummyHandler("/api/dependencies")
|
|
APP.Handler.do_GET(handler)
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertIn("node", handler.json_payload)
|
|
self.assertIn("python3", handler.json_payload)
|
|
self.assertIn("ffmpeg", handler.json_payload)
|
|
self.assertIn("openssl", handler.json_payload)
|
|
self.assertIn("provider-bridge", handler.json_payload)
|
|
|
|
def test_dependency_status_checks_provider_runtime(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
bridge_path = Path(temp_dir) / "bridge.js"
|
|
bridge_path.write_text("", encoding="utf-8")
|
|
with mock.patch.object(http_handler.shutil, "which", return_value="/usr/bin/tool"), mock.patch.object(
|
|
http_handler.provider_bridge, "BRIDGE", bridge_path
|
|
):
|
|
status = http_handler.dependency_status()
|
|
|
|
self.assertTrue(status["node"])
|
|
self.assertTrue(status["python3"])
|
|
self.assertTrue(status["ffmpeg"])
|
|
self.assertTrue(status["openssl"])
|
|
self.assertTrue(status["provider-bridge"])
|
|
|
|
def test_clear_failed_route_removes_failed_jobs(self):
|
|
with APP.DOWNLOAD_QUEUE._connect() as conn:
|
|
conn.execute("DELETE FROM jobs")
|
|
job = APP.DOWNLOAD_QUEUE.add(
|
|
{
|
|
"query": "Failed Route Show",
|
|
"title": "Failed Route Show",
|
|
"anime_name": "Failed Route Show",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"episodes": "1",
|
|
"download_dir": "/tmp/example",
|
|
"season": "1",
|
|
}
|
|
)
|
|
with APP.DOWNLOAD_QUEUE.lock:
|
|
stored = APP.DOWNLOAD_QUEUE._find(job["id"])
|
|
stored["status"] = "failed"
|
|
APP.DOWNLOAD_QUEUE._save_job_locked(stored)
|
|
|
|
handler = DummyHandler("/api/queue/clear-failed", body=b"{}", content_length=2)
|
|
APP.Handler.do_POST(handler)
|
|
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertEqual(handler.json_payload["count"], 1)
|
|
self.assertEqual(APP.DOWNLOAD_QUEUE.list()["total"], 0)
|
|
|
|
def test_changelog_route_returns_project_changelog_content(self):
|
|
handler = DummyHandler("/api/changelog")
|
|
APP.Handler.do_GET(handler)
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertIn("content", handler.json_payload)
|
|
self.assertIn("# Changelog", handler.json_payload["content"])
|
|
|
|
def test_loaded_runtime_version_matches_version_file(self):
|
|
expected_version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
|
self.assertEqual(APP.VERSION, expected_version)
|
|
|
|
def test_filesystem_browse_route_lists_directories(self):
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
Path(temp_root, "folder-a").mkdir()
|
|
Path(temp_root, "folder-b").mkdir()
|
|
Path(temp_root, "file.txt").write_text("example", encoding="utf-8")
|
|
handler = DummyHandler(f"/api/fs/browse?path={APP.quote(temp_root)}&mode=dir")
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertEqual(handler.json_payload["path"], temp_root)
|
|
self.assertEqual([entry["name"] for entry in handler.json_payload["entries"]], ["folder-a", "folder-b"])
|
|
self.assertTrue(all(entry["is_dir"] for entry in handler.json_payload["entries"]))
|
|
|
|
def test_remote_filesystem_browse_rejects_paths_outside_configured_roots(self):
|
|
with tempfile.TemporaryDirectory() as allowed_root, tempfile.TemporaryDirectory() as blocked_root:
|
|
APP.save_runtime_config({"download_dir": allowed_root, "mode": "sub", "quality": "best"})
|
|
encoded = base64.b64encode(b"demo:secret-pass").decode("ascii")
|
|
handler = DummyHandler(f"/api/fs/browse?path={APP.quote(blocked_root)}&mode=dir")
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
handler.headers["Authorization"] = f"Basic {encoded}"
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN)
|
|
self.assertIn("allowed remote browse roots", handler.error_message.lower())
|
|
|
|
def test_remote_filesystem_browse_defaults_blank_path_to_allowed_root(self):
|
|
with tempfile.TemporaryDirectory() as allowed_root:
|
|
Path(allowed_root, "folder-a").mkdir()
|
|
APP.save_runtime_config({"download_dir": allowed_root, "mode": "sub", "quality": "best"})
|
|
encoded = base64.b64encode(b"demo:secret-pass").decode("ascii")
|
|
handler = DummyHandler("/api/fs/browse?path=&mode=dir")
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
handler.headers["Authorization"] = f"Basic {encoded}"
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertEqual(handler.json_payload["path"], allowed_root)
|
|
self.assertEqual(handler.json_payload["home_path"], allowed_root)
|
|
self.assertEqual(handler.json_payload["root_paths"][0], allowed_root)
|
|
self.assertIn(allowed_root, handler.json_payload["root_paths"])
|
|
|
|
def test_remote_path_roots_include_explicit_allowlist(self):
|
|
with tempfile.TemporaryDirectory() as base_root, tempfile.TemporaryDirectory() as extra_root:
|
|
APP.save_runtime_config({"download_dir": base_root, "mode": "sub", "quality": "best"})
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_REMOTE_PATH_ROOTS": extra_root,
|
|
},
|
|
clear=False,
|
|
):
|
|
roots = APP.app_support.configured_remote_path_roots(APP.get_config_snapshot())
|
|
|
|
self.assertIn(Path(base_root).resolve(), roots)
|
|
self.assertIn(Path(extra_root).resolve(), roots)
|
|
|
|
def test_remote_filesystem_browse_returns_all_allowed_root_paths(self):
|
|
with tempfile.TemporaryDirectory() as base_root, tempfile.TemporaryDirectory() as extra_root:
|
|
APP.save_runtime_config({"download_dir": base_root, "mode": "sub", "quality": "best"})
|
|
encoded = base64.b64encode(b"demo:secret-pass").decode("ascii")
|
|
handler = DummyHandler("/api/fs/browse?path=&mode=dir")
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
handler.headers["Authorization"] = f"Basic {encoded}"
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
"KAIZOKU_REMOTE_PATH_ROOTS": extra_root,
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertEqual(handler.json_payload["root_paths"][:2], [extra_root, base_root])
|
|
self.assertIn(extra_root, handler.json_payload["root_paths"])
|
|
self.assertIn(base_root, handler.json_payload["root_paths"])
|
|
|
|
def test_config_post_accepts_discord_webhook_fields(self):
|
|
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://discord.com/api/webhooks/123456/test-token","discord_webhook_events":["runtime_error","watchlist_refresh_failed","bad"]}'
|
|
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["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token")
|
|
self.assertEqual(
|
|
handler.json_payload["discord_webhook_events"],
|
|
["runtime_error", "watchlist_refresh_failed"],
|
|
)
|
|
|
|
def test_config_post_rejects_non_discord_webhook_host(self):
|
|
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://example.com/not-discord"}'
|
|
handler = DummyHandler("/api/config", body=body, content_length=len(body))
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
|
|
self.assertIn("official discord webhook host", handler.error_message.lower())
|
|
|
|
def test_config_post_partial_update_preserves_existing_paths(self):
|
|
with tempfile.TemporaryDirectory() as existing_root:
|
|
APP.save_runtime_config(
|
|
{
|
|
"download_dir": existing_root,
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"jellyfin_tv_dir": existing_root,
|
|
}
|
|
)
|
|
body = b'{"mode":"dub"}'
|
|
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_dir"], existing_root)
|
|
self.assertEqual(handler.json_payload["jellyfin_tv_dir"], existing_root)
|
|
self.assertEqual(handler.json_payload["mode"], "dub")
|
|
|
|
def test_remote_config_post_rejects_download_dir_outside_allowed_roots(self):
|
|
with tempfile.TemporaryDirectory() as allowed_root, tempfile.TemporaryDirectory() as blocked_root:
|
|
APP.save_runtime_config({"download_dir": allowed_root, "mode": "sub", "quality": "best"})
|
|
body = json.dumps(
|
|
{
|
|
"download_dir": blocked_root,
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
}
|
|
).encode("utf-8")
|
|
handler = DummyHandler("/api/config", body=body, content_length=len(body))
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
handler.headers["Authorization"] = f"Basic {base64.b64encode(b'demo:secret-pass').decode('ascii')}"
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.do_POST(handler)
|
|
|
|
self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN)
|
|
self.assertIn("remote download directory", handler.error_message.lower())
|
|
|
|
def test_remote_config_post_accepts_download_dir_inside_explicit_allowlist(self):
|
|
with tempfile.TemporaryDirectory() as current_root, tempfile.TemporaryDirectory() as allowed_root:
|
|
APP.save_runtime_config({"download_dir": current_root, "mode": "sub", "quality": "best"})
|
|
body = json.dumps(
|
|
{
|
|
"download_dir": allowed_root,
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
}
|
|
).encode("utf-8")
|
|
handler = DummyHandler("/api/config", body=body, content_length=len(body))
|
|
handler.client_address = ("192.168.1.25", 8421)
|
|
handler.headers["Authorization"] = f"Basic {base64.b64encode(b'demo:secret-pass').decode('ascii')}"
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
|
"KAIZOKU_REMOTE_PATH_ROOTS": allowed_root,
|
|
},
|
|
clear=False,
|
|
):
|
|
APP.Handler.do_POST(handler)
|
|
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertEqual(handler.json_payload["download_dir"], allowed_root)
|
|
|
|
def test_config_routes_hide_auth_fields_and_preserve_saved_credentials(self):
|
|
APP.save_runtime_config(
|
|
{
|
|
"download_dir": "/tmp/example",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"auth_username": "demo",
|
|
"auth_password": "secret-pass",
|
|
}
|
|
)
|
|
|
|
post_body = b'{"download_dir":"/tmp/updated","mode":"dub","quality":"720"}'
|
|
post_handler = DummyHandler("/api/config", body=post_body, content_length=len(post_body))
|
|
APP.Handler.do_POST(post_handler)
|
|
|
|
self.assertEqual(post_handler.json_status, HTTPStatus.OK)
|
|
self.assertNotIn("auth_username", post_handler.json_payload)
|
|
self.assertNotIn("auth_password", post_handler.json_payload)
|
|
self.assertEqual(APP.get_config_snapshot()["auth_username"], "demo")
|
|
self.assertEqual(APP.get_config_snapshot()["auth_password"], "secret-pass")
|
|
|
|
get_handler = DummyHandler("/api/config")
|
|
APP.Handler.do_GET(get_handler)
|
|
self.assertEqual(get_handler.json_status, HTTPStatus.OK)
|
|
self.assertNotIn("auth_username", get_handler.json_payload)
|
|
self.assertNotIn("auth_password", get_handler.json_payload)
|
|
|
|
def test_config_webhook_test_route_uses_unsaved_payload(self):
|
|
body = b'{"discord_webhook_url":"https://discord.com/api/webhooks/123456/test-token","discord_webhook_events":["runtime_error"]}'
|
|
handler = DummyHandler("/api/config/webhook/test", body=body, content_length=len(body))
|
|
test_webhook = mock.Mock(return_value={"message": "ok"})
|
|
handler.handler_context = mock.Mock(test_discord_webhook_config=test_webhook)
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertEqual(handler.json_payload["message"], "ok")
|
|
test_webhook.assert_called_once_with(
|
|
{"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": ["runtime_error"]}
|
|
)
|
|
|
|
def test_config_webhook_test_route_rejects_non_discord_webhook_url(self):
|
|
body = b'{"discord_webhook_url":"https://example.com/not-discord","discord_webhook_events":["runtime_error"]}'
|
|
handler = DummyHandler("/api/config/webhook/test", body=body, content_length=len(body))
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
|
|
self.assertIn("official discord webhook host", handler.error_message.lower())
|
|
|
|
def test_body_json_rejects_oversized_request(self):
|
|
handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=APP.MAX_JSON_BODY_BYTES + 1)
|
|
with self.assertRaises(APP.HttpError) as ctx:
|
|
APP.Handler.body_json(handler)
|
|
self.assertEqual(ctx.exception.status, HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
|
|
|
def test_body_json_requires_application_json_content_type(self):
|
|
handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=2)
|
|
handler.headers["Content-Type"] = "text/plain"
|
|
with self.assertRaises(ValueError) as ctx:
|
|
APP.Handler.body_json(handler)
|
|
self.assertIn("content-type", str(ctx.exception).lower())
|
|
|
|
def test_body_json_rejects_non_utf8_payload(self):
|
|
handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"\xff", content_length=1)
|
|
with self.assertRaises(ValueError) as ctx:
|
|
APP.Handler.body_json(handler)
|
|
self.assertIn("utf-8", str(ctx.exception).lower())
|
|
|
|
def test_config_post_rejects_non_object_json_body(self):
|
|
body = b"[]"
|
|
handler = DummyHandler("/api/config", body=body, content_length=len(body))
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
|
|
self.assertIn("json object", handler.error_message.lower())
|
|
|
|
def test_remove_post_missing_show_id_returns_bad_request(self):
|
|
handler = DummyHandler("/api/watchlist/remove", body=b"{}", content_length=2)
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
|
|
self.assertIn("show_id", handler.error_message)
|
|
|
|
def test_upload_thumbnail_post_missing_required_fields_returns_bad_request(self):
|
|
body = b'{"show_id":"show-1"}'
|
|
handler = DummyHandler("/api/watchlist/upload-thumbnail", body=body, content_length=len(body))
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
|
|
self.assertIn("data_url", handler.error_message)
|
|
|
|
def test_watchlist_add_duplicate_returns_ok_instead_of_created(self):
|
|
body = b'{"show_id":"show-1","title":"Show 1","category":"watching"}'
|
|
handler = DummyHandler("/api/watchlist/add", body=body, content_length=len(body))
|
|
result = {"message": "Already tracking Show 1.", "item": {"show_id": "show-1", "title": "Show 1"}, "created": False}
|
|
with mock.patch.object(APP.WATCHLIST, "add", return_value=result):
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.json_payload, result)
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
|
|
def test_watchlist_add_rejects_non_object_json_body(self):
|
|
body = b"[]"
|
|
handler = DummyHandler("/api/watchlist/add", body=body, content_length=len(body))
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
|
|
self.assertIn("json object", handler.error_message.lower())
|
|
|
|
def test_ensure_thumbnails_rejects_non_array_show_ids(self):
|
|
body = b'{"show_ids":"show-1"}'
|
|
handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body))
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
|
|
self.assertIn("show_ids", handler.error_message)
|
|
|
|
def test_ensure_thumbnails_parses_string_false_force(self):
|
|
body = b'{"show_ids":["show-1"],"force":"false"}'
|
|
handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body))
|
|
with mock.patch.object(APP.WATCHLIST, "ensure_thumbnails", return_value={"items": []}) as ensure_thumbnails:
|
|
APP.Handler.do_POST(handler)
|
|
ensure_thumbnails.assert_called_once_with(["show-1"], limit=6, force=False)
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
|
|
def test_ensure_thumbnails_rejects_invalid_force_value(self):
|
|
body = b'{"show_ids":["show-1"],"force":"maybe"}'
|
|
handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body))
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
|
|
self.assertIn("force", handler.error_message)
|
|
|
|
def test_thumbnail_route_serves_cached_file_without_fetching(self):
|
|
thumb_path = APP.THUMBNAIL_ROOT / "show-1.jpg"
|
|
thumb_path.parent.mkdir(parents=True, exist_ok=True)
|
|
thumb_path.write_bytes(b"jpg")
|
|
|
|
handler = DummyHandler("/api/watchlist/thumb/show-1")
|
|
with mock.patch.object(APP.WATCHLIST, "get", return_value={"thumbnail_path": "show-1.jpg"}) as get_item, mock.patch.object(
|
|
APP.WATCHLIST, "ensure_thumbnail", side_effect=AssertionError("thumbnail fetch should not run")
|
|
):
|
|
APP.Handler.do_GET(handler)
|
|
|
|
get_item.assert_called_once_with("show-1")
|
|
self.assertEqual(handler.file_path, thumb_path)
|
|
self.assertIsNone(handler.error_status)
|
|
|
|
def test_watchlist_refresh_status_route_returns_json(self):
|
|
handler = DummyHandler("/api/watchlist/refresh-status")
|
|
payload = {"job": {"id": "refresh-1", "status": "running"}, "running": True}
|
|
with mock.patch.object(APP.WATCHLIST_REFRESH, "status", return_value=payload):
|
|
APP.Handler.do_GET(handler)
|
|
self.assertEqual(handler.json_payload, payload)
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
|
|
def test_watchlist_homepage_route_returns_summary_counts(self):
|
|
handler = DummyHandler("/api/watchlist/homepage")
|
|
payload = {"watchlist": 1, "planned": 0, "finished": 2, "dropped": 0, "total": 3}
|
|
handler.handler_context = mock.Mock(get_watchlist_homepage_summary=mock.Mock(return_value=payload))
|
|
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.json_payload, payload)
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
|
|
def test_update_all_post_returns_accepted_for_new_job(self):
|
|
handler = DummyHandler("/api/watchlist/update-all", body=b"{}", content_length=2)
|
|
payload = {"message": "Watchlist refresh started.", "job": {"id": "refresh-1"}, "created": True}
|
|
with mock.patch.object(APP.WATCHLIST_REFRESH, "start", return_value=payload):
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.json_payload, payload)
|
|
self.assertEqual(handler.json_status, HTTPStatus.ACCEPTED)
|
|
|
|
def test_update_status_post_queues_refresh(self):
|
|
body = b'{"show_id":"show-1"}'
|
|
handler = DummyHandler("/api/watchlist/update-status", body=body, content_length=len(body))
|
|
queued_item = {"show_id": "show-1", "title": "Show 1", "status": "queued"}
|
|
with mock.patch.object(APP.WATCHLIST, "queue_refresh", return_value=queued_item):
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.json_payload, {"message": "Queued refresh for Show 1.", "item": queued_item})
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
|
|
def test_watchlist_download_all_post_returns_created_job(self):
|
|
body = b'{"show_id":"show-1","mode":"sub"}'
|
|
handler = DummyHandler("/api/watchlist/download-all", body=body, content_length=len(body))
|
|
payload = {"message": "Queued Show 1 for sub download.", "job": {"id": "job-1"}, "item": {"show_id": "show-1"}}
|
|
handler.handler_context = mock.Mock(download_watchlist_item=mock.Mock(return_value=payload))
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.json_payload, payload)
|
|
self.assertEqual(handler.json_status, HTTPStatus.CREATED)
|
|
|
|
def test_watchlist_auto_download_settings_post_returns_updated_item(self):
|
|
body = b'{"show_id":"show-1","mode":"dub","quality":"best","name":"Example Name","source_name":"Example Source","series":"2","episode_offset":"13"}'
|
|
handler = DummyHandler("/api/watchlist/update-auto-download", body=body, content_length=len(body))
|
|
payload = {"message": "Saved auto-download settings for Show 1.", "item": {"show_id": "show-1"}}
|
|
handler.handler_context = mock.Mock(update_watchlist_auto_download=mock.Mock(return_value=payload))
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.json_payload, payload)
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
handler.handler_context.update_watchlist_auto_download.assert_called_once_with(
|
|
"show-1", "dub", "best", "Example Name", "Example Source", "2", "13"
|
|
)
|
|
|
|
def test_watchlist_media_type_post_returns_updated_item(self):
|
|
body = b'{"show_id":"show-1","media_type":"movie"}'
|
|
handler = DummyHandler("/api/watchlist/update-media-type", body=body, content_length=len(body))
|
|
payload = {"message": "Saved Show 1 as Movie.", "item": {"show_id": "show-1", "media_type": "movie"}}
|
|
handler.handler_context = mock.Mock(update_watchlist_media_type=mock.Mock(return_value=payload))
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.json_payload, payload)
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
|
|
def test_jellyfin_sync_status_route_returns_json(self):
|
|
handler = DummyHandler("/api/config/jellyfin/sync-status")
|
|
payload = {"running": True, "job": {"id": "job-1", "status": "running"}}
|
|
handler.handler_context = mock.Mock(get_jellyfin_sync_status=mock.Mock(return_value=payload))
|
|
APP.Handler.do_GET(handler)
|
|
self.assertEqual(handler.json_payload, payload)
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
|
|
def test_jellyfin_sync_post_returns_job_payload(self):
|
|
body = b'{"download_dir":"/tmp/example","jellyfin_sync_enabled":true,"jellyfin_tv_dir":"/tmp/jellyfin-tv","jellyfin_movie_dir":"/tmp/jellyfin-movies"}'
|
|
handler = DummyHandler("/api/config/jellyfin/sync", body=body, content_length=len(body))
|
|
payload = {"message": "Jellyfin handoff started.", "created": True, "job": {"id": "job-1", "status": "pending"}}
|
|
handler.handler_context = mock.Mock(
|
|
get_config_snapshot=mock.Mock(return_value={}),
|
|
start_jellyfin_sync=mock.Mock(return_value=payload),
|
|
http_error=APP.HttpError,
|
|
)
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.json_payload, payload)
|
|
self.assertEqual(handler.json_status, HTTPStatus.ACCEPTED)
|
|
|
|
def test_jellyfin_sync_post_merges_partial_payload_with_saved_config(self):
|
|
body = b'{"jellyfin_sync_enabled":true}'
|
|
handler = DummyHandler("/api/config/jellyfin/sync", body=body, content_length=len(body))
|
|
payload = {"message": "Jellyfin handoff already queued.", "created": False, "job": {"id": "job-1", "status": "pending"}}
|
|
handler.handler_context = mock.Mock(
|
|
get_config_snapshot=mock.Mock(
|
|
return_value={
|
|
"download_dir": "/tmp/example",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"jellyfin_sync_enabled": False,
|
|
"jellyfin_tv_dir": "/tmp/jellyfin-tv",
|
|
"jellyfin_movie_dir": "/tmp/jellyfin-movies",
|
|
}
|
|
),
|
|
start_jellyfin_sync=mock.Mock(return_value=payload),
|
|
)
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.json_status, HTTPStatus.ACCEPTED)
|
|
handler.handler_context.start_jellyfin_sync.assert_called_once_with(
|
|
{
|
|
"download_dir": "/tmp/example",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
"jellyfin_sync_enabled": True,
|
|
"jellyfin_tv_dir": "/tmp/jellyfin-tv",
|
|
"jellyfin_movie_dir": "/tmp/jellyfin-movies",
|
|
}
|
|
)
|
|
|
|
def test_watchlist_reconcile_post_merges_partial_payload_with_saved_config(self):
|
|
body = b'{"download_dir":"/tmp/override"}'
|
|
handler = DummyHandler("/api/config/watchlist/reconcile-downloads", body=body, content_length=len(body))
|
|
payload = {"message": "ok", "total": 2, "changed": 1, "items": []}
|
|
handler.handler_context = mock.Mock(
|
|
get_config_snapshot=mock.Mock(
|
|
return_value={
|
|
"download_dir": "/tmp/example",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
}
|
|
),
|
|
reconcile_watchlist_downloaded_files=mock.Mock(return_value=payload),
|
|
)
|
|
APP.Handler.do_POST(handler)
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
handler.handler_context.reconcile_watchlist_downloaded_files.assert_called_once_with(
|
|
{
|
|
"download_dir": "/tmp/override",
|
|
"mode": "sub",
|
|
"quality": "best",
|
|
}
|
|
)
|
|
|
|
def test_watchlist_export_route_returns_json_attachment(self):
|
|
handler = DummyHandler("/api/config/watchlist/export")
|
|
payload = {"format": "kaizoku-watchlist-backup", "tables": {"watchlist": {"rows": []}}}
|
|
handler.handler_context = mock.Mock(
|
|
ensure_runtime=mock.Mock(),
|
|
runtime_state=mock.Mock(return_value={}),
|
|
export_watchlist_backup=mock.Mock(return_value=payload),
|
|
)
|
|
|
|
APP.Handler.do_GET(handler)
|
|
|
|
self.assertEqual(handler.response_status, HTTPStatus.OK)
|
|
headers = dict(handler.response_headers)
|
|
self.assertEqual(headers.get("Content-Type"), "application/json")
|
|
self.assertIn("attachment", headers.get("Content-Disposition", ""))
|
|
self.assertEqual(json.loads(handler.wfile.getvalue().decode("utf-8")), payload)
|
|
|
|
def test_watchlist_import_route_returns_import_summary(self):
|
|
body = b'{"format":"kaizoku-watchlist-backup","format_version":1,"tables":{"watchlist":{"rows":[]}}}'
|
|
handler = DummyHandler("/api/config/watchlist/import", body=body, content_length=len(body))
|
|
payload = {"message": "Imported 0 watchlist entries from backup.", "imported": 0, "removals": 0}
|
|
handler.handler_context = mock.Mock(import_watchlist_backup=mock.Mock(return_value=payload))
|
|
|
|
APP.Handler.do_POST(handler)
|
|
|
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
self.assertEqual(handler.json_payload, payload)
|
|
handler.handler_context.import_watchlist_backup.assert_called_once()
|
|
|
|
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
|
|
handler = DummyHandler("/api/config")
|
|
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
|
http_handler, "debug_log"
|
|
) as debug_log, mock.patch.object(http_handler, "send_discord_webhook_event") as send_webhook, mock.patch.object(
|
|
http_handler.traceback, "print_exc"
|
|
) as print_exc:
|
|
APP.Handler.exception(handler, RuntimeError("boom"))
|
|
|
|
print_exc.assert_not_called()
|
|
send_webhook.assert_called_once()
|
|
debug_log.assert_called_once()
|
|
self.assertEqual(handler.error_status, HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
self.assertEqual(handler.error_message, "Internal server error.")
|
|
|
|
|
|
class TemplateHelperTests(unittest.TestCase):
|
|
def test_log_message_skips_queue_poll_requests(self):
|
|
handler = object.__new__(APP.Handler)
|
|
handler.path = "/api/queue?page=1&per_page=10"
|
|
handler.command = "GET"
|
|
handler.address_string = lambda: "127.0.0.1"
|
|
|
|
with mock.patch("builtins.print") as print_mock:
|
|
APP.Handler.log_message(handler, '"GET /api/queue?page=1&per_page=10 HTTP/1.1" %s -', 200)
|
|
|
|
print_mock.assert_not_called()
|
|
|
|
def test_log_message_keeps_non_poll_requests(self):
|
|
handler = object.__new__(APP.Handler)
|
|
handler.path = "/api/config"
|
|
handler.command = "GET"
|
|
handler.address_string = lambda: "127.0.0.1"
|
|
|
|
with mock.patch("builtins.print") as print_mock:
|
|
APP.Handler.log_message(handler, '"GET /api/config HTTP/1.1" %s -', 200)
|
|
|
|
print_mock.assert_called_once()
|
|
|
|
def test_page_links_marks_active_page(self):
|
|
markup = APP.render_page_links("config")
|
|
self.assertIn('class="page-link active" href="/config"', markup)
|
|
self.assertIn('class="page-link" href="/queue"', markup)
|
|
self.assertIn('class="page-link" href="/watchlist"', markup)
|
|
|
|
def test_sidebar_brand_can_attach_optional_badge_id(self):
|
|
markup = APP.render_sidebar_brand("Shared note", "Search", badge_id="serverBadge")
|
|
self.assertIn('id="serverBadge"', markup)
|
|
self.assertIn("Shared note", markup)
|
|
|
|
def test_queue_page_queue_loader_guards_against_stale_responses(self):
|
|
self.assertIn("queueRequestId", APP.QUEUE_HTML)
|
|
self.assertIn("if (requestId !== state.queueRequestId) return data;", APP.QUEUE_HTML)
|
|
|
|
def test_queue_page_renders_queue_logs_newest_first(self):
|
|
self.assertIn('[...(job.log || [])].slice(-28).reverse().join("\\n")', APP.QUEUE_HTML)
|
|
|
|
def test_pages_share_browser_api_helper(self):
|
|
self.assertIn("async function api(path, options = {})", APP.INDEX_HTML)
|
|
self.assertIn("async function api(path, options = {})", APP.QUEUE_HTML)
|
|
self.assertIn("async function api(path, options = {})", APP.CONFIG_HTML)
|
|
self.assertIn("async function api(path, options = {})", APP.WATCHLIST_HTML)
|
|
|
|
def test_pages_include_favicon_link(self):
|
|
expected = '<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">'
|
|
self.assertIn(expected, APP.INDEX_HTML)
|
|
self.assertIn(expected, APP.QUEUE_HTML)
|
|
self.assertIn(expected, APP.CONFIG_HTML)
|
|
self.assertIn(expected, APP.WATCHLIST_HTML)
|
|
|
|
def test_config_page_places_dependencies_before_settings_grid(self):
|
|
self.assertIn('<section class="deps" id="deps"></section>', APP.CONFIG_HTML)
|
|
self.assertIn('<div class="settings-grid">', APP.CONFIG_HTML)
|
|
self.assertLess(APP.CONFIG_HTML.index('<section class="deps" id="deps"></section>'), APP.CONFIG_HTML.index('<div class="settings-grid">'))
|
|
self.assertIn("@media (max-width: 1080px)", APP.CONFIG_HTML)
|
|
self.assertIn("justify-items: stretch;", APP.CONFIG_HTML)
|
|
self.assertIn('id="versionLine"', APP.CONFIG_HTML)
|
|
self.assertIn('id="providerLine"', 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)
|
|
self.assertIn('changelogContent.textContent = data.content || "Changelog unavailable.";', APP.CONFIG_HTML)
|
|
|
|
def test_config_page_includes_host_path_browser_controls(self):
|
|
self.assertIn('class="ghost browse-path-btn"', APP.CONFIG_HTML)
|
|
self.assertIn('id="pathBrowserOverlay"', APP.CONFIG_HTML)
|
|
self.assertIn('/api/fs/browse?path=${encodeURIComponent(path || state.pathBrowser.path || "")}', APP.CONFIG_HTML)
|
|
self.assertIn('rootGroup.className = "browser-roots";', APP.CONFIG_HTML)
|
|
self.assertIn('const active = data.path === rootPath || String(data.path || "").startsWith(`${rootPath}/`);', APP.CONFIG_HTML)
|
|
|
|
def test_search_page_sends_watchlist_auto_download_series(self):
|
|
self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML)
|
|
self.assertIn('id="seasonInput" type="number" min="0"', APP.INDEX_HTML)
|
|
|
|
def test_search_page_closes_selection_modal_after_successful_actions(self):
|
|
self.assertIn('setNotice(data.message || "Added to watchlist.");\n closeSelectionModal();', APP.INDEX_HTML)
|
|
self.assertIn('setNotice("Added to queue. Open Queue to monitor progress.");\n closeSelectionModal();', APP.INDEX_HTML)
|
|
|
|
def test_search_page_selection_modal_controls_download_mode(self):
|
|
self.assertIn('select id="selectionMode"', APP.INDEX_HTML)
|
|
self.assertIn('auto_download_mode: $("selectionMode").value', APP.INDEX_HTML)
|
|
self.assertIn('mode: $("selectionMode").value', APP.INDEX_HTML)
|
|
self.assertIn('loadSelectedEpisodes($("selectionMode").value)', APP.INDEX_HTML)
|
|
|
|
def test_queue_page_includes_clear_failed_bulk_action(self):
|
|
self.assertIn('id="clearFailedBtn"', APP.QUEUE_HTML)
|
|
self.assertIn('/api/queue/clear-failed', APP.QUEUE_HTML)
|
|
self.assertIn('Removed failed jobs', APP.QUEUE_HTML)
|
|
|
|
def test_queue_and_watchlist_sidebars_do_not_show_help_panels(self):
|
|
self.assertNotIn("What you can do here", APP.QUEUE_HTML)
|
|
self.assertNotIn("How it works", APP.WATCHLIST_HTML)
|
|
|
|
def test_docker_uses_locked_node_dependencies(self):
|
|
package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
|
|
lock = json.loads((ROOT / "package-lock.json").read_text(encoding="utf-8"))
|
|
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
|
|
|
|
self.assertEqual(package["dependencies"]["cheerio"], "1.0.0")
|
|
self.assertEqual(lock["packages"]["node_modules/cheerio"]["version"], "1.0.0")
|
|
self.assertIn("COPY package.json package-lock.json /app/", dockerfile)
|
|
self.assertIn("npm ci --omit=dev", dockerfile)
|
|
self.assertIn("FROM lexiforest/curl-impersonate:v2.2.2 AS curl_impersonate", dockerfile)
|
|
self.assertIn("COPY --from=curl_impersonate /usr/local/bin/ /usr/local/bin/", dockerfile)
|
|
self.assertIn("LD_LIBRARY_PATH=/usr/local/lib", dockerfile)
|
|
|
|
def test_docker_compose_exposes_jellyfin_browse_roots(self):
|
|
compose = (ROOT / "docker-compose.yaml").read_text(encoding="utf-8")
|
|
entrypoint = (ROOT / "docker-entrypoint.sh").read_text(encoding="utf-8")
|
|
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
|
|
|
|
self.assertIn("KAIZOKU_REMOTE_PATH_ROOTS: ${KAIZOKU_REMOTE_PATH_ROOTS:-/downloads,/jellyfin/tv,/jellyfin/movies}", compose)
|
|
self.assertIn("${JELLYFIN_TV_DIR:-./jellyfin/tv}:/jellyfin/tv", compose)
|
|
self.assertIn("${JELLYFIN_MOVIE_DIR:-./jellyfin/movies}:/jellyfin/movies", compose)
|
|
self.assertIn("mkdir -p /downloads /jellyfin/tv /jellyfin/movies /app/.kaizoku", dockerfile)
|
|
self.assertIn("chown_path()", entrypoint)
|
|
self.assertIn("Warning: could not change ownership", entrypoint)
|
|
self.assertIn("for path in /downloads /jellyfin/tv /jellyfin/movies /app/.kaizoku /app/.kaizoku/home", entrypoint)
|
|
|
|
def test_active_code_does_not_reference_retired_metadata_source(self):
|
|
active_files = ("app.py", "app_support.py", "http_handler.py", "provider_bridge.py", "provider_downloader.py")
|
|
active_text = "\n".join((ROOT / filename).read_text(encoding="utf-8") for filename in active_files)
|
|
|
|
self.assertNotIn("allmanga", active_text.lower())
|
|
self.assertNotIn("allanime", active_text.lower())
|
|
self.assertNotIn("METADATA_API", active_text)
|
|
|
|
def test_queue_page_formats_jellyfin_handoff_jobs(self):
|
|
self.assertIn('job.job_type === "jellyfin_handoff"', APP.QUEUE_HTML)
|
|
self.assertIn('const progress = `${job.completed || 0}/${job.total || 0} entries`;', APP.QUEUE_HTML)
|
|
self.assertIn('const moved = `${job.moved || 0} moved`;', APP.QUEUE_HTML)
|
|
|
|
def test_queue_page_renders_download_progress_bar(self):
|
|
self.assertIn('class="progress-wrap"', APP.QUEUE_HTML)
|
|
self.assertIn('class="progress-fill"', APP.QUEUE_HTML)
|
|
self.assertIn("function progressPercent(job)", APP.QUEUE_HTML)
|
|
self.assertIn('item.querySelector(".progress-fill").style.width = `${percent}%`;', APP.QUEUE_HTML)
|
|
self.assertIn("segment ${progress.segment}/${progress.segment_total}", APP.QUEUE_HTML)
|
|
|
|
def test_search_page_queues_selected_title_with_result_index(self):
|
|
self.assertIn('query: state.selected.title,', APP.INDEX_HTML)
|
|
self.assertIn('result_index: state.selected.index,', APP.INDEX_HTML)
|
|
|
|
def test_search_page_renders_poster_results_under_selection_panel(self):
|
|
self.assertIn('class="results-grid" id="results"', APP.INDEX_HTML)
|
|
self.assertIn("Select results", APP.INDEX_HTML)
|
|
self.assertIn("resultThumbnailUrl(item)", APP.INDEX_HTML)
|
|
self.assertIn('/api/search/thumb?title=', APP.INDEX_HTML)
|
|
self.assertIn("result-subtitle", APP.INDEX_HTML)
|
|
self.assertIn("Also known as:", APP.INDEX_HTML)
|
|
|
|
def test_search_anime_includes_alternative_titles(self):
|
|
payload = {"results": [{"id": "show-1", "title": "Example Show", "provider": "anikoto"}]}
|
|
with mock.patch.object(provider_bridge, "search", return_value=payload), mock.patch.object(
|
|
provider_bridge, "episodes", return_value=[{"number": "1", "langs": ["sub"]}],
|
|
), mock.patch.object(
|
|
APP, "extract_anidb_alternative_titles_for_title",
|
|
return_value=[{"label": "English", "value": "Example Show English"}],
|
|
):
|
|
results = APP.search_anime("Example Show", "sub")
|
|
|
|
self.assertEqual(results[0]["alternative_titles"], [{"label": "English", "value": "Example Show English"}])
|
|
|
|
def test_pages_share_serial_polling_helper(self):
|
|
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML)
|
|
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML)
|
|
self.assertIn("startSerialPoll(() => pollVisibleJobs(), 2500)", APP.QUEUE_HTML)
|
|
self.assertIn("startSerialPoll(async () => {", APP.WATCHLIST_HTML)
|
|
self.assertIn("window.clearTimeout(timer);", APP.QUEUE_HTML)
|
|
self.assertIn('window.addEventListener("pagehide", stop, { once: true });', APP.QUEUE_HTML)
|
|
self.assertIn('window.addEventListener("beforeunload", stop, { once: true });', APP.WATCHLIST_HTML)
|
|
|
|
def test_watchlist_page_polls_when_queued_items_exist(self):
|
|
self.assertIn("queuedCount: 0", APP.WATCHLIST_HTML)
|
|
self.assertIn("else if (state.queuedCount > 0)", APP.WATCHLIST_HTML)
|
|
self.assertIn("fetchWatchlist(state.watchlistPage, { silent: true })", APP.WATCHLIST_HTML)
|
|
|
|
def test_watchlist_page_renders_single_refresh_progress_bar(self):
|
|
self.assertIn('class="refresh-progress" id="refreshJobStatus"', APP.WATCHLIST_HTML)
|
|
self.assertIn('class="refresh-progress-fill" id="refreshJobFill"', APP.WATCHLIST_HTML)
|
|
self.assertIn("linear-gradient(90deg, var(--accent), var(--accent-strong))", APP.WATCHLIST_HTML)
|
|
self.assertNotIn("var(--accent-2)", APP.WATCHLIST_HTML)
|
|
self.assertIn('role="progressbar"', APP.WATCHLIST_HTML)
|
|
self.assertIn("function refreshJobPercent(job)", APP.WATCHLIST_HTML)
|
|
self.assertIn('$("refreshJobFill").style.transform = `scaleX(${percent / 100})`;', APP.WATCHLIST_HTML)
|
|
self.assertIn('setAttribute("aria-valuenow", String(percent))', APP.WATCHLIST_HTML)
|
|
self.assertIn('`Refreshing ${job.current_title}`', APP.WATCHLIST_HTML)
|
|
self.assertEqual(APP.WATCHLIST_HTML.count('id="refreshJobStatus"'), 1)
|
|
self.assertNotIn("refreshed${job.current_title", APP.WATCHLIST_HTML)
|
|
|
|
def test_watchlist_page_highlights_fully_ready_cards(self):
|
|
self.assertIn(".anime-card.fully-ready", APP.WATCHLIST_HTML)
|
|
self.assertIn("const isFullyReady = item.sub_complete && item.dub_complete;", APP.WATCHLIST_HTML)
|
|
self.assertIn("card.className = watchlistCardClassName(item);", APP.WATCHLIST_HTML)
|
|
|
|
def test_watchlist_page_renders_provider_source_tag(self):
|
|
self.assertIn("function providerLabel(provider)", APP.WATCHLIST_HTML)
|
|
self.assertIn('appendStatusTag(statusRow, `Source: ${item.provider_label || providerLabel(item.provider)}`);', APP.WATCHLIST_HTML)
|
|
|
|
def test_watchlist_page_download_all_replaces_open_action(self):
|
|
self.assertIn("Download all", APP.WATCHLIST_HTML)
|
|
self.assertIn('api("/api/watchlist/download-all"', APP.WATCHLIST_HTML)
|
|
self.assertIn('class="download-picker"', APP.WATCHLIST_HTML)
|
|
self.assertIn('downloadPicker.classList.toggle("visible")', APP.WATCHLIST_HTML)
|
|
self.assertIn('downloadWatchlistItem(item, "sub"', APP.WATCHLIST_HTML)
|
|
self.assertIn('downloadWatchlistItem(item, "dub"', APP.WATCHLIST_HTML)
|
|
self.assertIn("grid-template-columns: repeat(2, minmax(0, 1fr));", APP.WATCHLIST_HTML)
|
|
self.assertLess(APP.WATCHLIST_HTML.index('<button class="primary" type="button">Refresh</button>'), APP.WATCHLIST_HTML.index('<button class="danger" type="button">Remove</button>'))
|
|
self.assertLess(APP.WATCHLIST_HTML.index('<button class="danger" type="button">Remove</button>'), APP.WATCHLIST_HTML.index('<button class="ghost" type="button">Download all</button>'))
|
|
|
|
def test_watchlist_page_renders_alternative_titles_toggle(self):
|
|
self.assertIn("Alternative titles", APP.WATCHLIST_HTML)
|
|
self.assertIn('class="ghost alt-titles-toggle"', APP.WATCHLIST_HTML)
|
|
self.assertIn('id="altTitlesOverlay"', APP.WATCHLIST_HTML)
|
|
self.assertIn("openAltTitlesOverlay(item)", APP.WATCHLIST_HTML)
|
|
self.assertIn("closeAltTitlesOverlay()", APP.WATCHLIST_HTML)
|
|
self.assertIn("AniDB English alternative titles were not available for this show.", APP.WATCHLIST_HTML)
|
|
|
|
def test_watchlist_page_renders_auto_download_editor(self):
|
|
self.assertIn("Auto-download", APP.WATCHLIST_HTML)
|
|
self.assertIn('id="autoDownloadOverlay"', APP.WATCHLIST_HTML)
|
|
self.assertIn('api("/api/watchlist/update-auto-download"', APP.WATCHLIST_HTML)
|
|
self.assertIn("openAutoDownloadOverlay(item)", APP.WATCHLIST_HTML)
|
|
self.assertIn("closeAutoDownloadOverlay()", APP.WATCHLIST_HTML)
|
|
self.assertIn("Save auto-download", APP.WATCHLIST_HTML)
|
|
self.assertIn("Manual add does not trigger it", APP.WATCHLIST_HTML)
|
|
self.assertIn('id="autoDownloadSeries" type="number" min="0"', APP.WATCHLIST_HTML)
|
|
self.assertIn("Source name", APP.WATCHLIST_HTML)
|
|
self.assertIn('id="autoDownloadSourceName"', APP.WATCHLIST_HTML)
|
|
self.assertIn("Name source", APP.WATCHLIST_HTML)
|
|
self.assertIn('class="wide-half">Name source', APP.WATCHLIST_HTML)
|
|
self.assertIn('class="wide-half">Name', APP.WATCHLIST_HTML)
|
|
self.assertIn("Custom", APP.WATCHLIST_HTML)
|
|
self.assertIn("Series", APP.WATCHLIST_HTML)
|
|
self.assertIn("Episode offset", APP.WATCHLIST_HTML)
|
|
|
|
def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self):
|
|
self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML)
|
|
self.assertIn("function thumbnailWarmupToken(item)", APP.WATCHLIST_HTML)
|
|
self.assertIn("if (state.thumbnailWarmupTokens[item.show_id] === token) continue;", APP.WATCHLIST_HTML)
|
|
self.assertIn("state.thumbnailWarmupPending.delete(showId);", APP.WATCHLIST_HTML)
|
|
|
|
|
|
class AniDbCacheTests(unittest.TestCase):
|
|
def test_get_cached_anidb_titles_reuses_cached_entries(self):
|
|
original_cache = dict(APP.ANIDB_TITLES_CACHE)
|
|
try:
|
|
cached_entries = [{"aid": "1", "title": "Example", "normalized_title": "example"}]
|
|
APP.ANIDB_TITLES_CACHE.update(
|
|
{
|
|
"mtime": 123.0,
|
|
"entries": cached_entries,
|
|
"title_index": {"example": [0]},
|
|
"token_index": {"example": [0]},
|
|
"match_cache": {},
|
|
}
|
|
)
|
|
with mock.patch.object(APP, "ensure_anidb_titles_dump") as ensure_dump:
|
|
ensure_dump.return_value = mock.Mock(exists=lambda: True, stat=lambda: mock.Mock(st_mtime=123.0))
|
|
result = APP.get_cached_anidb_titles()
|
|
self.assertIs(result, cached_entries)
|
|
finally:
|
|
APP.ANIDB_TITLES_CACHE.clear()
|
|
APP.ANIDB_TITLES_CACHE.update(original_cache)
|
|
|
|
|
|
class ProviderDownloaderTests(unittest.TestCase):
|
|
def test_episode_key_keeps_episode_ten_distinct_from_one(self):
|
|
self.assertEqual(provider_downloader.episode_key(1), "1")
|
|
self.assertEqual(provider_downloader.episode_key(10), "10")
|
|
self.assertEqual(provider_downloader.episode_key("10.0"), "10")
|
|
self.assertEqual(provider_downloader.episode_key("10.5"), "10.5")
|
|
|
|
def test_wanted_episodes_preserves_range_order_and_existing_ids(self):
|
|
episodes = [
|
|
{"number": 10, "id": "ep10"},
|
|
{"number": 2, "id": "ep2"},
|
|
{"number": 11, "id": "ep11"},
|
|
{"number": 12, "id": "ep12"},
|
|
]
|
|
|
|
wanted = provider_downloader.wanted_episodes("10-12", episodes)
|
|
|
|
self.assertEqual([episode["number"] for episode in wanted], [10, 11, 12])
|
|
self.assertEqual([episode["id"] for episode in wanted], ["ep10", "ep11", "ep12"])
|
|
|
|
def test_best_hls_variant_url_selects_highest_bandwidth(self):
|
|
master = """#EXTM3U
|
|
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360
|
|
low/index.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=5500000,RESOLUTION=1920x1080
|
|
high/index.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720
|
|
mid/index.m3u8
|
|
"""
|
|
|
|
self.assertEqual(
|
|
provider_downloader.best_hls_variant_url("https://cdn.example/show/master.m3u8", master),
|
|
"https://cdn.example/show/high/index.m3u8",
|
|
)
|
|
|
|
def test_parse_hls_segments_resolves_plain_and_encrypted_segments(self):
|
|
playlist = """#EXTM3U
|
|
#EXT-X-MEDIA-SEQUENCE:7
|
|
#EXTINF:4.0,
|
|
seg-1
|
|
#EXT-X-KEY:METHOD=AES-128,URI="keys/key.bin"
|
|
#EXTINF:4.0,
|
|
https://cdn.example/raw-segment
|
|
#EXTINF:4.0,
|
|
seg-3.ts
|
|
"""
|
|
|
|
segments = provider_downloader.parse_hls_segments("https://cdn.example/path/index.m3u8", playlist)
|
|
|
|
self.assertEqual(segments[0], {"url": "https://cdn.example/path/seg-1"})
|
|
self.assertEqual(
|
|
segments[1],
|
|
{
|
|
"url": "https://cdn.example/raw-segment",
|
|
"key_url": "https://cdn.example/path/keys/key.bin",
|
|
"iv": "8",
|
|
},
|
|
)
|
|
self.assertEqual(segments[2]["iv"], "9")
|
|
|
|
def test_strip_png_header_removes_short_wrapper(self):
|
|
payload = b"\x89PNG\r\n\x1a\njunkIENDabcdVIDEO"
|
|
|
|
self.assertEqual(provider_downloader.strip_png_header(payload), b"VIDEO")
|
|
|
|
def test_request_headers_keep_referer_without_synthetic_origin(self):
|
|
headers = provider_downloader.request_headers({"Referer": "https://megaplay.example/"})
|
|
|
|
self.assertEqual(headers["Referer"], "https://megaplay.example/")
|
|
self.assertNotIn("Origin", headers)
|
|
self.assertEqual(headers["Sec-Fetch-Dest"], "video")
|
|
|
|
def test_curl_fetch_bytes_returns_body_before_status_marker(self):
|
|
proc = mock.Mock(returncode=0, stdout=b"video-bytes\nKAIZOKU_HTTP_STATUS:200", stderr=b"")
|
|
|
|
with tempfile.TemporaryDirectory() as temp_root, mock.patch.object(
|
|
provider_downloader, "curl_cookie_file", return_value=Path(temp_root) / "cookies.txt"
|
|
), mock.patch.object(provider_downloader, "curl_candidates", return_value=["/usr/bin/curl"]), mock.patch.object(
|
|
provider_downloader.subprocess, "run", return_value=proc
|
|
) as run:
|
|
data = provider_downloader.curl_fetch_bytes("https://cdn.example/segment.ts", headers={"Referer": "https://player.example/"})
|
|
|
|
self.assertEqual(data, b"video-bytes")
|
|
self.assertIn("--header", run.call_args.args[0])
|
|
self.assertIn("Referer: https://player.example/", run.call_args.args[0])
|
|
self.assertIn("--cookie-jar", run.call_args.args[0])
|
|
|
|
def test_media_request_preserves_playlist_cookie_for_segments(self):
|
|
jar = http.cookiejar.CookieJar()
|
|
cookie = http.cookiejar.Cookie(
|
|
version=0,
|
|
name="cdn_session",
|
|
value="allowed",
|
|
port=None,
|
|
port_specified=False,
|
|
domain="cdn.example",
|
|
domain_specified=True,
|
|
domain_initial_dot=False,
|
|
path="/",
|
|
path_specified=True,
|
|
secure=True,
|
|
expires=None,
|
|
discard=True,
|
|
comment=None,
|
|
comment_url=None,
|
|
rest={},
|
|
rfc2109=False,
|
|
)
|
|
jar.set_cookie(cookie)
|
|
|
|
with mock.patch.object(provider_downloader, "MEDIA_COOKIE_JAR", jar):
|
|
request = provider_downloader.media_request("https://cdn.example/video/segment.ts")
|
|
|
|
self.assertEqual(request.get_header("Cookie"), "cdn_session=allowed")
|
|
|
|
def test_fetch_bytes_keeps_set_cookie_for_next_media_request(self):
|
|
class Response:
|
|
def __init__(self, url, body, cookie=""):
|
|
self.url = url
|
|
self.body = body
|
|
self.headers = Message()
|
|
if cookie:
|
|
self.headers.add_header("Set-Cookie", cookie)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def info(self):
|
|
return self.headers
|
|
|
|
def geturl(self):
|
|
return self.url
|
|
|
|
def read(self):
|
|
return self.body
|
|
|
|
requests = []
|
|
responses = iter(
|
|
[
|
|
Response("https://cdn.example/index.m3u8", b"playlist", "cdn_session=allowed; Path=/; Secure"),
|
|
Response("https://cdn.example/video/segment.ts", b"video"),
|
|
]
|
|
)
|
|
|
|
def fake_urlopen(request, timeout):
|
|
requests.append(request)
|
|
return next(responses)
|
|
|
|
with mock.patch.object(provider_downloader, "MEDIA_COOKIE_JAR", http.cookiejar.CookieJar()), mock.patch.object(
|
|
provider_downloader.urllib.request, "urlopen", side_effect=fake_urlopen
|
|
):
|
|
provider_downloader.fetch_bytes("https://cdn.example/index.m3u8")
|
|
provider_downloader.fetch_bytes("https://cdn.example/video/segment.ts")
|
|
|
|
self.assertEqual(requests[1].get_header("Cookie"), "cdn_session=allowed")
|
|
|
|
def test_curl_fetch_uses_python_media_session_cookie(self):
|
|
proc = mock.Mock(returncode=0, stdout=b"video\nKAIZOKU_HTTP_STATUS:200", stderr=b"")
|
|
with tempfile.TemporaryDirectory() as temp_root, mock.patch.object(
|
|
provider_downloader, "curl_cookie_file", return_value=Path(temp_root) / "cookies.txt"
|
|
), mock.patch.object(provider_downloader, "curl_candidates", return_value=["/usr/bin/curl"]), mock.patch.object(
|
|
provider_downloader, "media_cookie_header", return_value="cdn_session=allowed"
|
|
), mock.patch.object(provider_downloader.subprocess, "run", return_value=proc) as run:
|
|
provider_downloader.curl_fetch_bytes("https://cdn.example/segment.ts")
|
|
|
|
self.assertIn("Cookie: cdn_session=allowed", run.call_args.args[0])
|
|
|
|
def test_curl_request_headers_preserve_browser_wrapper_defaults(self):
|
|
headers = provider_downloader.curl_request_headers(
|
|
"/usr/local/bin/curl_chrome142",
|
|
{"Referer": "https://player.example/"},
|
|
)
|
|
|
|
self.assertEqual(headers, {"Referer": "https://player.example/"})
|
|
|
|
def test_curl_candidates_prefer_new_browser_wrappers(self):
|
|
def fake_which(name):
|
|
if name in {"curl_chrome142", "curl_chrome120", "curl"}:
|
|
return f"/usr/local/bin/{name}"
|
|
return None
|
|
|
|
with mock.patch.object(provider_downloader.shutil, "which", side_effect=fake_which), mock.patch.object(
|
|
provider_downloader.os, "access", return_value=False
|
|
):
|
|
candidates = provider_downloader.curl_candidates()
|
|
|
|
self.assertEqual(candidates[:3], ["/usr/local/bin/curl_chrome142", "/usr/local/bin/curl_chrome120", "/usr/local/bin/curl"])
|
|
|
|
def test_fetch_bytes_retries_forbidden_media_with_curl_fallback(self):
|
|
error = urllib.error.HTTPError(
|
|
"https://cdn.example/segment.ts",
|
|
403,
|
|
"Forbidden",
|
|
hdrs={},
|
|
fp=None,
|
|
)
|
|
|
|
try:
|
|
with mock.patch.object(provider_downloader.urllib.request, "urlopen", side_effect=error), mock.patch.object(
|
|
provider_downloader, "curl_candidates", return_value=["/usr/bin/curl"]
|
|
), mock.patch.object(provider_downloader, "curl_fetch_bytes", return_value=b"video") as curl_fetch:
|
|
data = provider_downloader.fetch_bytes("https://cdn.example/segment.ts")
|
|
finally:
|
|
error.close()
|
|
|
|
self.assertEqual(data, b"video")
|
|
curl_fetch.assert_called_once()
|
|
|
|
def test_hls_segments_need_native_download_for_extensionless_provider_urls(self):
|
|
self.assertTrue(
|
|
provider_downloader.hls_segments_need_native_download(
|
|
[{"url": "https://p16-ad-sg.ibyteimg.com/obj/ad-site-i18n/202606025d0da6f666bd3d7b4e7ab879"}]
|
|
)
|
|
)
|
|
self.assertFalse(
|
|
provider_downloader.hls_segments_need_native_download(
|
|
[{"url": "https://cdn.example/video/segment-1.ts"}]
|
|
)
|
|
)
|
|
|
|
def test_hls_segments_need_native_download_for_snapcdn_disguised_segments(self):
|
|
self.assertTrue(
|
|
provider_downloader.hls_segments_need_native_download(
|
|
[{"url": "https://shard-102.snapcdn.top/anime/show/episode/seg-f1-00176.jpg"}]
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
provider_downloader.hls_segments_need_native_download(
|
|
[{"url": "https://cdn.example/video/seg-f1-00170.js"}]
|
|
)
|
|
)
|
|
|
|
def test_hls_preflight_failure_does_not_fall_through_to_direct_ffmpeg(self):
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
target = Path(temp_root) / "Episode.mp4"
|
|
stream = {"url": "https://cdn.example/master.m3u8", "isM3U8": True, "headers": {}}
|
|
with mock.patch.object(provider_downloader, "resolve_hls_input_url", return_value=stream["url"]), mock.patch.object(
|
|
provider_downloader, "hls_media_playlist", side_effect=RuntimeError("playlist blocked")
|
|
), mock.patch.object(provider_downloader, "download_hls_segments", return_value=1) as download_segments, mock.patch.object(
|
|
provider_downloader, "run_ffmpeg"
|
|
) as run_ffmpeg:
|
|
code = provider_downloader.download_episode(stream, target, episode_number=1)
|
|
|
|
self.assertEqual(code, 1)
|
|
download_segments.assert_called_once()
|
|
run_ffmpeg.assert_not_called()
|
|
|
|
def test_native_segment_failure_does_not_retry_same_source_from_preflight(self):
|
|
playlist = """#EXTM3U
|
|
#EXTINF:4.0,
|
|
https://shard-102.snapcdn.top/anime/show/episode/seg-f1-00083.css
|
|
"""
|
|
with tempfile.TemporaryDirectory() as temp_root:
|
|
target = Path(temp_root) / "Episode.mp4"
|
|
stream = {"url": "https://cdn.example/master.m3u8", "isM3U8": True, "headers": {}}
|
|
with mock.patch.object(provider_downloader, "resolve_hls_input_url", return_value=stream["url"]), mock.patch.object(
|
|
provider_downloader, "hls_media_playlist", return_value=(stream["url"], playlist)
|
|
), mock.patch.object(
|
|
provider_downloader, "download_hls_segments", side_effect=RuntimeError("segment forbidden")
|
|
) as download_segments, mock.patch.object(provider_downloader, "run_ffmpeg") as run_ffmpeg:
|
|
code = provider_downloader.download_episode(stream, target, episode_number=1)
|
|
|
|
self.assertEqual(code, 1)
|
|
download_segments.assert_called_once()
|
|
run_ffmpeg.assert_not_called()
|
|
|
|
def test_fetch_bytes_with_retries_recovers_from_segment_rate_limit(self):
|
|
error = urllib.error.HTTPError(
|
|
"https://cdn.example/video/raw-segment",
|
|
429,
|
|
"Too Many Requests",
|
|
hdrs={},
|
|
fp=None,
|
|
)
|
|
|
|
try:
|
|
with mock.patch.object(
|
|
provider_downloader,
|
|
"fetch_bytes",
|
|
side_effect=[error, b"video"],
|
|
) as fetch_bytes, mock.patch.object(provider_downloader.time, "sleep") as sleep:
|
|
data = provider_downloader.fetch_bytes_with_retries(
|
|
"https://cdn.example/video/raw-segment",
|
|
retries=1,
|
|
retry_label="segment",
|
|
)
|
|
finally:
|
|
error.close()
|
|
|
|
self.assertEqual(data, b"video")
|
|
self.assertEqual(fetch_bytes.call_count, 2)
|
|
sleep.assert_called_once()
|
|
|
|
def test_fetch_bytes_with_retries_recovers_from_temporary_dns_failure(self):
|
|
error = urllib.error.URLError(OSError(-3, "Temporary failure in name resolution"))
|
|
|
|
with mock.patch.object(
|
|
provider_downloader,
|
|
"fetch_bytes",
|
|
side_effect=[error, b"video"],
|
|
) as fetch_bytes, mock.patch.object(provider_downloader.time, "sleep") as sleep:
|
|
data = provider_downloader.fetch_bytes_with_retries(
|
|
"https://cdn.example/video/raw-segment",
|
|
retries=1,
|
|
retry_label="segment",
|
|
)
|
|
|
|
self.assertEqual(data, b"video")
|
|
self.assertEqual(fetch_bytes.call_count, 2)
|
|
sleep.assert_called_once()
|
|
|
|
def test_fetch_bytes_with_retries_raises_dns_failure_after_limit(self):
|
|
error = urllib.error.URLError(OSError(-3, "Temporary failure in name resolution"))
|
|
|
|
with mock.patch.object(provider_downloader, "fetch_bytes", side_effect=error), mock.patch.object(
|
|
provider_downloader.time, "sleep"
|
|
) as sleep:
|
|
with self.assertRaises(urllib.error.URLError):
|
|
provider_downloader.fetch_bytes_with_retries(
|
|
"https://cdn.example/video/raw-segment",
|
|
retries=1,
|
|
retry_label="segment",
|
|
)
|
|
|
|
sleep.assert_called_once()
|
|
|
|
def test_provider_stream_candidates_returns_resolved_sources(self):
|
|
with mock.patch.object(
|
|
provider_downloader,
|
|
"bridge",
|
|
return_value={
|
|
"sources": [
|
|
{"url": "https://cdn.example/one.m3u8", "server": "HD-1"},
|
|
{"server": "broken"},
|
|
{"url": "https://cdn.example/two.m3u8", "server": "HD-2"},
|
|
]
|
|
},
|
|
):
|
|
sources = provider_downloader.provider_stream_candidates("anikoto", "ep1", "dub", "best")
|
|
|
|
self.assertEqual([source["server"] for source in sources], ["HD-1", "HD-2"])
|
|
|
|
def test_concise_error_keeps_first_provider_error_line(self):
|
|
error = RuntimeError("Error: Request failed with status code 403\n at async main")
|
|
|
|
self.assertEqual(provider_downloader.concise_error(error), "Error: Request failed with status code 403")
|
|
|
|
def test_anikoto_episode_language_probe_ignores_empty_dub_group(self):
|
|
script = r"""
|
|
global.axios = {
|
|
get: async (url) => {
|
|
if (url.includes("/ajax/episode/list/123")) {
|
|
return {
|
|
data: {
|
|
result: '<div class="filter type"><button data-value="dub">Dub</button></div><a data-id="ep4" data-ids="servers4" data-num="4" title="Episode 4"></a>'
|
|
}
|
|
};
|
|
}
|
|
if (url.includes("/ajax/server/list?servers=servers4")) {
|
|
return {
|
|
data: {
|
|
result: '<div class="servers"><div class="type" data-type="sub"><ul><li data-id="s1" data-link-id="l1">HD-1</li></ul></div><div class="type" data-type="dub"><ul></ul></div></div>'
|
|
}
|
|
};
|
|
}
|
|
throw new Error(`unexpected url ${url}`);
|
|
}
|
|
};
|
|
const provider = require("./providers/extensions/Anime/anikoto.js");
|
|
provider.fetchEpisode("123").then((data) => {
|
|
console.log(JSON.stringify(data.episodes[0].langs));
|
|
}).catch((err) => {
|
|
console.error(err && err.stack ? err.stack : err);
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
proc = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=ROOT,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
self.assertEqual(json.loads(proc.stdout), ["sub"])
|
|
|
|
def test_anikoto_process_server_decodes_encrypted_megaplay_sources(self):
|
|
script = r"""
|
|
const encryptedPayload = "wdeBruh3qqn_i5wUNnyaPbW9ZonPa4hISsp_E11QSCvgMDwqFDKp4YU05edNoqP7oBt0VrdAY8ZIJSs6S-axabGaqAYhyjxDjPGgJ58M9y9y4vWP8mNi0PiPKVQ2bHBRhkqlVFSz5WHTMs6CrfU3KVwQZ1ZH2ymo75JCecqjQJo";
|
|
global.setDynamicReferer = () => {};
|
|
global.setFallbackReferer = () => {};
|
|
global.axios = {
|
|
get: async (url) => {
|
|
if (url.includes("/ajax/server?get=dub-link")) {
|
|
return { data: { result: { url: "https://megaplay.buzz/stream/s-2/664726/dub" } } };
|
|
}
|
|
if (url.includes("/stream/s-2/664726/dub")) {
|
|
return {
|
|
data: `
|
|
<div id="megaplay-player" data-id="179510"></div>
|
|
<script>
|
|
const settings = {
|
|
type: 'dub',
|
|
cidu : '6aa1ade0620e3',
|
|
};
|
|
</script>
|
|
`
|
|
};
|
|
}
|
|
if (url.includes("/stream/getSources?id=179510&type=dub&cidu=6aa1ade0620e3&s=bcdn")) {
|
|
return { data: { enc: encryptedPayload, tracks: [{ file: "https://cdn.example/sub.vtt", label: "English" }] } };
|
|
}
|
|
throw new Error(`unexpected url ${url}`);
|
|
}
|
|
};
|
|
const provider = require("./providers/extensions/Anime/anikoto.js");
|
|
provider.processServer({ name: "HD-2", type: "dub", linkId: "dub-link" }).then((source) => {
|
|
console.log(JSON.stringify(source));
|
|
}).catch((err) => {
|
|
console.error(err && err.stack ? err.stack : err);
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
proc = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=ROOT,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
source = json.loads(proc.stdout)
|
|
self.assertEqual(
|
|
source["url"],
|
|
"https://ncdn.imgnex.top/anime/a7e93c78e547abf7dce1e0f3f0b77977/0eb80d76b8867b42b6e6e62ebe515704/master.m3u8",
|
|
)
|
|
self.assertEqual(source["type"], "dub")
|
|
self.assertEqual(source["subtitles"][0]["url"], "https://cdn.example/sub.vtt")
|
|
self.assertEqual(source["subtitles"][0]["referer"], "https://megaplay.buzz/")
|
|
|
|
def test_provider_downloader_tries_next_same_provider_source_after_failure(self):
|
|
calls = []
|
|
|
|
def fake_bridge(command, provider, *args):
|
|
if command == "info":
|
|
return {"title": "Retry Source Show"}
|
|
if command == "episodes":
|
|
return {"episodes": [{"number": 1, "id": "ep1"}]}
|
|
if command == "resolve-all":
|
|
self.assertEqual(args[1], "dub")
|
|
return {
|
|
"sources": [
|
|
{"url": "https://cdn.example/first.m3u8", "server": "HD-1", "type": "dub"},
|
|
{"url": "https://cdn.example/second.m3u8", "server": "HD-2", "type": "dub"},
|
|
]
|
|
}
|
|
raise AssertionError(f"unexpected bridge command {command}")
|
|
|
|
def fake_download(stream, target, episode_number=None, episode_index=None, episode_total=None):
|
|
calls.append(stream["server"])
|
|
return 1 if stream["server"] == "HD-1" else 0
|
|
|
|
argv = [
|
|
"provider_downloader.py",
|
|
"--provider",
|
|
"anikoto",
|
|
"--show-id",
|
|
"anikoto:show-1",
|
|
"--title",
|
|
"Retry Source Show",
|
|
"--episodes",
|
|
"1",
|
|
"--mode",
|
|
"dub",
|
|
"--quality",
|
|
"best",
|
|
"--output-dir",
|
|
"/tmp/kaizoku-provider-test",
|
|
]
|
|
with mock.patch.object(provider_downloader, "bridge", side_effect=fake_bridge), mock.patch.object(
|
|
provider_downloader, "download_episode", side_effect=fake_download
|
|
), mock.patch.object(provider_downloader, "download_subtitles", return_value=[]), mock.patch.object(
|
|
provider_downloader.sys, "argv", argv
|
|
):
|
|
with self.assertRaises(SystemExit) as raised:
|
|
provider_downloader.main()
|
|
|
|
self.assertEqual(raised.exception.code, 0)
|
|
self.assertEqual(calls, ["HD-1", "HD-2"])
|
|
|
|
def test_provider_downloader_tries_next_source_after_playlist_exception(self):
|
|
calls = []
|
|
|
|
def fake_bridge(command, provider, *args):
|
|
if command == "info":
|
|
return {"title": "Retry Exception Show"}
|
|
if command == "episodes":
|
|
return {"episodes": [{"number": 1, "id": "ep1"}]}
|
|
if command == "resolve-all":
|
|
return {
|
|
"sources": [
|
|
{"url": "https://bad.example/master.m3u8", "server": "StreamHG", "type": "dub"},
|
|
{"url": "https://good.example/master.m3u8", "server": "Earnvids", "type": "dub"},
|
|
]
|
|
}
|
|
raise AssertionError(f"unexpected bridge command {command}")
|
|
|
|
def fake_download(stream, target, episode_number=None, episode_index=None, episode_total=None):
|
|
calls.append(stream["server"])
|
|
if stream["server"] == "StreamHG":
|
|
raise RuntimeError("HTTP Error 502: Bad Gateway")
|
|
return 0
|
|
|
|
argv = [
|
|
"provider_downloader.py",
|
|
"--provider",
|
|
"anikoto",
|
|
"--show-id",
|
|
"anikoto:show-1",
|
|
"--title",
|
|
"Retry Exception Show",
|
|
"--episodes",
|
|
"1",
|
|
"--mode",
|
|
"dub",
|
|
"--quality",
|
|
"best",
|
|
"--output-dir",
|
|
"/tmp/kaizoku-provider-test",
|
|
]
|
|
with mock.patch.object(provider_downloader, "bridge", side_effect=fake_bridge), mock.patch.object(
|
|
provider_downloader, "download_episode", side_effect=fake_download
|
|
), mock.patch.object(provider_downloader, "download_subtitles", return_value=[]), mock.patch.object(
|
|
provider_downloader.sys, "argv", argv
|
|
):
|
|
with self.assertRaises(SystemExit) as raised:
|
|
provider_downloader.main()
|
|
|
|
self.assertEqual(raised.exception.code, 0)
|
|
self.assertEqual(calls, ["StreamHG", "Earnvids"])
|
|
|
|
def test_provider_bridge_resolve_does_not_fallback_from_dub_to_sub(self):
|
|
bridge_js = (ROOT / "providers" / "bridge.js").read_text(encoding="utf-8")
|
|
|
|
self.assertNotIn('mode === "dub"', bridge_js)
|
|
self.assertNotIn('fetchEpisodeSources(episodeId, "sub")', bridge_js)
|
|
|
|
def test_provider_bridge_deduplicates_identical_resolved_streams(self):
|
|
script = r"""
|
|
const Module = require("module");
|
|
const originalLoad = Module._load;
|
|
const provider = {
|
|
fetchEpisodeSources: async () => ({
|
|
sources: [
|
|
{ name: "HD-1", type: "dub", isUnresolved: true, rawServer: { name: "HD-1", referer: "https://player.example/" } },
|
|
{ name: "Vidstream-1", type: "dub", isUnresolved: true, rawServer: { name: "Vidstream-1", referer: "https://player.example/" } },
|
|
{ name: "Vidstream-2", type: "dub", isUnresolved: true, rawServer: { name: "Vidstream-2", referer: "https://other-player.example/" } },
|
|
],
|
|
}),
|
|
processServer: async (server) => ({
|
|
url: "https://cdn.example/master.m3u8",
|
|
isM3U8: true,
|
|
type: "dub",
|
|
headers: { Referer: server.referer },
|
|
}),
|
|
};
|
|
Module._load = function(request, parent, isMain) {
|
|
if (String(request).endsWith("/providers/extensions/Anime/anikoto.js")) return provider;
|
|
return originalLoad.call(this, request, parent, isMain);
|
|
};
|
|
process.argv = ["node", "bridge.js", "resolve-all", "anikoto", "ep1", "dub", "best"];
|
|
require("./providers/bridge.js");
|
|
"""
|
|
proc = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=ROOT,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
sources = json.loads(proc.stdout)["sources"]
|
|
self.assertEqual(len(sources), 2)
|
|
self.assertEqual(
|
|
[source["headers"]["Referer"] for source in sources],
|
|
["https://player.example/", "https://other-player.example/"],
|
|
)
|
|
|
|
|
|
class DockerfilePackagingTests(unittest.TestCase):
|
|
def test_provider_updates_module_is_copied_into_image(self):
|
|
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
|
|
|
|
self.assertIn("provider_updates.py", dockerfile)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|