diff --git a/app.py b/app.py index 3f5436c..7247718 100644 --- a/app.py +++ b/app.py @@ -23,6 +23,7 @@ from urllib.parse import quote, urljoin, urlparse import app_support import provider_bridge +import provider_updates from app_support import ( AGENT, ANIDB_ANIME_PAGE, @@ -97,6 +98,8 @@ WATCHLIST_REFRESH = None WATCHLIST_AUTO_REFRESH = None WATCHLIST_JELLYFIN_SYNC = None WATCHLIST_BACKUP_FORMAT_VERSION = 1 +PROVIDER_UPDATE_STATUS = provider_updates.default_status() +PROVIDER_UPDATE_CHECK_THREAD = None def parse_iso_timestamp(value): @@ -3183,6 +3186,44 @@ def runtime_state(): } +def get_provider_update_status(): + with RUNTIME_LOCK: + return json.loads(json.dumps(PROVIDER_UPDATE_STATUS)) + + +def set_provider_update_status(status): + if not isinstance(status, dict): + return + with RUNTIME_LOCK: + global PROVIDER_UPDATE_STATUS + PROVIDER_UPDATE_STATUS = json.loads(json.dumps(status)) + + +def run_provider_update_check(): + try: + set_provider_update_status(provider_updates.check_provider_updates()) + except Exception as exc: + debug_log("provider_updates.error", error=exc) + set_provider_update_status(provider_updates.error_status(exc)) + + +def start_provider_update_check(): + global PROVIDER_UPDATE_CHECK_THREAD + with RUNTIME_LOCK: + if PROVIDER_UPDATE_CHECK_THREAD is not None and PROVIDER_UPDATE_CHECK_THREAD.is_alive(): + return PROVIDER_UPDATE_CHECK_THREAD + PROVIDER_UPDATE_STATUS.update( + provider_updates.default_status("checking", "Checking provider JavaScript for upstream updates...") + ) + PROVIDER_UPDATE_CHECK_THREAD = threading.Thread( + target=run_provider_update_check, + name="provider-update-check", + daemon=True, + ) + PROVIDER_UPDATE_CHECK_THREAD.start() + return PROVIDER_UPDATE_CHECK_THREAD + + def build_runtime(start_workers=None): ensure_project_app_root() worker_state = background_workers_enabled() if start_workers is None else bool(start_workers) and background_workers_enabled() @@ -3323,6 +3364,7 @@ Handler = build_handler_class( episode_list=episode_list, export_watchlist_backup=export_watchlist_backup, get_config_snapshot=get_config_snapshot, + get_provider_update_status=get_provider_update_status, get_watchlist_homepage_summary=get_watchlist_homepage_summary, get_jellyfin_sync_status=get_jellyfin_sync_status, get_watchlist=get_watchlist, @@ -3367,6 +3409,7 @@ def main(): host = server_host() port = server_port() server = ThreadingHTTPServer((host, port), Handler) + start_provider_update_check() debug_log("server.start", version=VERSION, host=host, port=port, argv=sys.argv[1:]) print(f"Serving {APP_NAME} {VERSION} on http://{host}:{port}/") try: diff --git a/config_page.py b/config_page.py index 6c8e507..1e964d2 100644 --- a/config_page.py +++ b/config_page.py @@ -243,6 +243,14 @@ CONFIG_HTML = r""" } .badge.ok { color: #95e4ba; border-color: rgba(149, 228, 186, 0.24); } .badge.bad { color: #ffd0da; border-color: rgba(255, 144, 164, 0.22); } + .badge.warn { color: #ffe6a8; border-color: rgba(255, 217, 118, 0.28); } + .provider-update-list { + display: grid; + gap: 8px; + } + .provider-update-list:empty { + display: none; + } .field-hint { color: var(--muted); font-size: 12px; @@ -466,6 +474,13 @@ CONFIG_HTML = r""" +
+

Provider updates

+
+

Checking upstream provider files...

+
+
+
@@ -1084,6 +1099,52 @@ CONFIG_HTML = r""" $("providerLine").textContent = `Providers: ${(data.providers || []).join(", ") || "Unavailable"}`; } + function providerUpdateBadgeClass(status) { + if (status === "up_to_date") return "ok"; + if (status === "updates_available") return "warn"; + if (status === "error") return "bad"; + return ""; + } + + function renderProviderUpdates(data) { + const line = $("providerUpdatesLine"); + const list = $("providerUpdatesList"); + const status = data.status || "idle"; + const checked = data.checked_files ? `${data.checked_files} checked` : ""; + const checkedAt = data.checked_at ? ` · ${new Date(data.checked_at).toLocaleString()}` : ""; + line.textContent = `${data.message || "Provider update status unavailable."}${checked ? ` · ${checked}` : ""}${checkedAt}`; + list.innerHTML = ""; + + const files = [ + ...(data.updated_files || []).map((item) => item.path), + ...(data.missing_upstream_files || []) + ]; + if (!files.length && status !== "checking") { + const badge = document.createElement("span"); + badge.className = `badge ${providerUpdateBadgeClass(status)}`; + badge.textContent = status === "up_to_date" ? "up to date" : status.replace(/_/g, " "); + list.appendChild(badge); + return; + } + for (const path of files) { + const badge = document.createElement("span"); + badge.className = `badge ${providerUpdateBadgeClass(status)}`; + badge.textContent = path; + list.appendChild(badge); + } + } + + async function loadProviderUpdates() { + try { + renderProviderUpdates(await api("/api/provider-updates")); + } catch (error) { + renderProviderUpdates({ + status: "error", + message: error.message || "Provider update status unavailable." + }); + } + } + async function openChangelog() { try { changelogContent.textContent = "Loading changelog..."; @@ -1138,9 +1199,11 @@ CONFIG_HTML = r""" await pollJellyfinSyncStatus(false); await loadDeps(); await loadVersion(); + await loadProviderUpdates(); window.setInterval(() => { pollJellyfinSyncStatus(); }, 2500); + window.setInterval(loadProviderUpdates, 10000); } catch (error) { setNotice(error.message); } diff --git a/http_handler.py b/http_handler.py index af3efbf..5d0d18b 100644 --- a/http_handler.py +++ b/http_handler.py @@ -57,6 +57,7 @@ class HandlerContext: export_watchlist_backup: object get_config_snapshot: object get_jellyfin_sync_status: object + get_provider_update_status: object get_watchlist_homepage_summary: object get_watchlist: object get_watchlist_refresh_status: object @@ -602,6 +603,8 @@ class Handler(BaseHTTPRequestHandler): self.json({"content": load_project_text_file(CHANGELOG_FILE, default="Changelog unavailable.")}) elif parsed.path == "/api/dependencies": self.json(dependency_status()) + elif parsed.path == "/api/provider-updates": + self.json(Handler._context(self).get_provider_update_status()) elif parsed.path == "/api/fs/browse": params = parse_qs(parsed.query) client_host = Handler._effective_client_host(self) diff --git a/provider_updates.py b/provider_updates.py new file mode 100644 index 0000000..99121a9 --- /dev/null +++ b/provider_updates.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 + +"""Check bundled provider JavaScript against the upstream extensions repo.""" + +import hashlib +import json +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +from app_support import AGENT, PROJECT_ROOT + + +UPSTREAM_REPO_URL = "https://github.com/TheYogMehta/extensions" +UPSTREAM_BRANCH = "main" +UPSTREAM_TREE_URL = ( + f"https://api.github.com/repos/TheYogMehta/extensions/git/trees/{UPSTREAM_BRANCH}?recursive=1" +) +UPSTREAM_RAW_BASE = f"https://raw.githubusercontent.com/TheYogMehta/extensions/{UPSTREAM_BRANCH}/" +LOCAL_PROVIDER_ROOT = PROJECT_ROOT / "providers" / "extensions" +UPSTREAM_PROVIDER_PREFIX = "extensions/" + + +def now_iso(): + return datetime.now(timezone.utc).isoformat() + + +def default_status(status="idle", message="Provider update check has not run yet."): + return { + "status": status, + "message": message, + "checked_at": "", + "upstream_url": UPSTREAM_REPO_URL, + "upstream_branch": UPSTREAM_BRANCH, + "upstream_tree_sha": "", + "checked_files": 0, + "updated_files": [], + "missing_upstream_files": [], + } + + +def local_provider_js_files(root=LOCAL_PROVIDER_ROOT): + root = Path(root) + if not root.exists(): + return {} + files = {} + for path in sorted(root.rglob("*.js")): + if path.is_file(): + files[path.relative_to(root).as_posix()] = path + return files + + +def sha256_bytes(content): + return hashlib.sha256(content).hexdigest() + + +def fetch_json(url, timeout=10): + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": AGENT, + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def fetch_bytes(url, timeout=10): + request = urllib.request.Request( + url, + headers={ + "Accept": "application/octet-stream", + "User-Agent": AGENT, + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() + + +def upstream_provider_js_paths(timeout=10): + payload = fetch_json(UPSTREAM_TREE_URL, timeout=timeout) + tree = payload.get("tree") if isinstance(payload, dict) else [] + paths = set() + for item in tree or []: + path = str((item or {}).get("path") or "") + if (item or {}).get("type") == "blob" and path.startswith(UPSTREAM_PROVIDER_PREFIX) and path.endswith(".js"): + paths.add(path[len(UPSTREAM_PROVIDER_PREFIX) :]) + return paths, str(payload.get("sha") or "") if isinstance(payload, dict) else "" + + +def check_provider_updates(local_root=LOCAL_PROVIDER_ROOT, timeout=10): + checked_at = now_iso() + local_files = local_provider_js_files(local_root) + if not local_files: + status = default_status("error", "No local provider JavaScript files were found.") + status["checked_at"] = checked_at + return status + + upstream_paths, upstream_tree_sha = upstream_provider_js_paths(timeout=timeout) + updated_files = [] + missing_upstream_files = [] + checked_files = 0 + + for relative_path, local_path in local_files.items(): + if relative_path not in upstream_paths: + missing_upstream_files.append(relative_path) + continue + checked_files += 1 + local_content = local_path.read_bytes() + remote_url = f"{UPSTREAM_RAW_BASE}{UPSTREAM_PROVIDER_PREFIX}{relative_path}" + remote_content = fetch_bytes(remote_url, timeout=timeout) + local_hash = sha256_bytes(local_content) + remote_hash = sha256_bytes(remote_content) + if local_hash != remote_hash: + updated_files.append( + { + "path": relative_path, + "local_sha256": local_hash, + "upstream_sha256": remote_hash, + "upstream_url": remote_url, + } + ) + + has_updates = bool(updated_files or missing_upstream_files) + if has_updates: + message = "Provider updates are available from TheYogMehta/extensions." + status_name = "updates_available" + else: + message = "Bundled provider JavaScript is up to date." + status_name = "up_to_date" + + return { + "status": status_name, + "message": message, + "checked_at": checked_at, + "upstream_url": UPSTREAM_REPO_URL, + "upstream_branch": UPSTREAM_BRANCH, + "upstream_tree_sha": upstream_tree_sha, + "checked_files": checked_files, + "updated_files": updated_files, + "missing_upstream_files": missing_upstream_files, + } + + +def error_status(exc): + message = str(exc) + if isinstance(exc, urllib.error.URLError) and getattr(exc, "reason", None): + message = str(exc.reason) + status = default_status("error", f"Provider update check failed: {message}") + status["checked_at"] = now_iso() + return status diff --git a/test_app.py b/test_app.py index d333778..b1a51ff 100644 --- a/test_app.py +++ b/test_app.py @@ -24,6 +24,7 @@ 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 @@ -3231,11 +3232,14 @@ class StartupBehaviorTests(unittest.TestCase): 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, "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() @@ -3344,6 +3348,56 @@ class StartupBehaviorTests(unittest.TestCase): 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_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")