Add provider update startup check

This commit is contained in:
Dymas
2026-08-11 22:14:07 +02:00
parent d13b7da5b2
commit 7f5088f70d
5 changed files with 317 additions and 1 deletions
+43
View File
@@ -23,6 +23,7 @@ from urllib.parse import quote, urljoin, urlparse
import app_support import app_support
import provider_bridge import provider_bridge
import provider_updates
from app_support import ( from app_support import (
AGENT, AGENT,
ANIDB_ANIME_PAGE, ANIDB_ANIME_PAGE,
@@ -97,6 +98,8 @@ WATCHLIST_REFRESH = None
WATCHLIST_AUTO_REFRESH = None WATCHLIST_AUTO_REFRESH = None
WATCHLIST_JELLYFIN_SYNC = None WATCHLIST_JELLYFIN_SYNC = None
WATCHLIST_BACKUP_FORMAT_VERSION = 1 WATCHLIST_BACKUP_FORMAT_VERSION = 1
PROVIDER_UPDATE_STATUS = provider_updates.default_status()
PROVIDER_UPDATE_CHECK_THREAD = None
def parse_iso_timestamp(value): 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): def build_runtime(start_workers=None):
ensure_project_app_root() ensure_project_app_root()
worker_state = background_workers_enabled() if start_workers is None else bool(start_workers) and background_workers_enabled() 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, episode_list=episode_list,
export_watchlist_backup=export_watchlist_backup, export_watchlist_backup=export_watchlist_backup,
get_config_snapshot=get_config_snapshot, get_config_snapshot=get_config_snapshot,
get_provider_update_status=get_provider_update_status,
get_watchlist_homepage_summary=get_watchlist_homepage_summary, get_watchlist_homepage_summary=get_watchlist_homepage_summary,
get_jellyfin_sync_status=get_jellyfin_sync_status, get_jellyfin_sync_status=get_jellyfin_sync_status,
get_watchlist=get_watchlist, get_watchlist=get_watchlist,
@@ -3367,6 +3409,7 @@ def main():
host = server_host() host = server_host()
port = server_port() port = server_port()
server = ThreadingHTTPServer((host, port), Handler) server = ThreadingHTTPServer((host, port), Handler)
start_provider_update_check()
debug_log("server.start", version=VERSION, host=host, port=port, argv=sys.argv[1:]) debug_log("server.start", version=VERSION, host=host, port=port, argv=sys.argv[1:])
print(f"Serving {APP_NAME} {VERSION} on http://{host}:{port}/") print(f"Serving {APP_NAME} {VERSION} on http://{host}:{port}/")
try: try:
+63
View File
@@ -243,6 +243,14 @@ CONFIG_HTML = r"""<!doctype html>
} }
.badge.ok { color: #95e4ba; border-color: rgba(149, 228, 186, 0.24); } .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.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 { .field-hint {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
@@ -466,6 +474,13 @@ CONFIG_HTML = r"""<!doctype html>
</div> </div>
<button class="ghost wide-button" id="openChangelogBtn" type="button">Changelog</button> <button class="ghost wide-button" id="openChangelogBtn" type="button">Changelog</button>
</section> </section>
<section class="settings" id="providerUpdatesPanel">
<h2>Provider updates</h2>
<div class="stack muted">
<p id="providerUpdatesLine">Checking upstream provider files...</p>
<div class="provider-update-list" id="providerUpdatesList"></div>
</div>
</section>
</aside> </aside>
<main> <main>
@@ -1084,6 +1099,52 @@ CONFIG_HTML = r"""<!doctype html>
$("providerLine").textContent = `Providers: ${(data.providers || []).join(", ") || "Unavailable"}`; $("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() { async function openChangelog() {
try { try {
changelogContent.textContent = "Loading changelog..."; changelogContent.textContent = "Loading changelog...";
@@ -1138,9 +1199,11 @@ CONFIG_HTML = r"""<!doctype html>
await pollJellyfinSyncStatus(false); await pollJellyfinSyncStatus(false);
await loadDeps(); await loadDeps();
await loadVersion(); await loadVersion();
await loadProviderUpdates();
window.setInterval(() => { window.setInterval(() => {
pollJellyfinSyncStatus(); pollJellyfinSyncStatus();
}, 2500); }, 2500);
window.setInterval(loadProviderUpdates, 10000);
} catch (error) { } catch (error) {
setNotice(error.message); setNotice(error.message);
} }
+3
View File
@@ -57,6 +57,7 @@ class HandlerContext:
export_watchlist_backup: object export_watchlist_backup: object
get_config_snapshot: object get_config_snapshot: object
get_jellyfin_sync_status: object get_jellyfin_sync_status: object
get_provider_update_status: object
get_watchlist_homepage_summary: object get_watchlist_homepage_summary: object
get_watchlist: object get_watchlist: object
get_watchlist_refresh_status: 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.")}) self.json({"content": load_project_text_file(CHANGELOG_FILE, default="Changelog unavailable.")})
elif parsed.path == "/api/dependencies": elif parsed.path == "/api/dependencies":
self.json(dependency_status()) 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": elif parsed.path == "/api/fs/browse":
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
client_host = Handler._effective_client_host(self) client_host = Handler._effective_client_host(self)
+153
View File
@@ -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
+55 -1
View File
@@ -24,6 +24,7 @@ import queue_jobs
import http_handler import http_handler
import provider_bridge import provider_bridge
import provider_downloader import provider_downloader
import provider_updates
os.environ.setdefault("KAIZOKU_DISABLE_WORKER", "1") os.environ.setdefault("KAIZOKU_DISABLE_WORKER", "1")
os.environ["KAIZOKU_STATE_ROOT"] = TEMP_STATE.name 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( with mock.patch.object(APP, "initialize_runtime") as initialize_runtime, mock.patch.object(
APP, "ThreadingHTTPServer", return_value=server 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 shutdown_runtime.return_value = True
APP.main() APP.main()
initialize_runtime.assert_not_called() initialize_runtime.assert_not_called()
start_provider_update_check.assert_called_once()
shutdown_runtime.assert_called_once_with(wait=True, cancel_active_downloads=True) shutdown_runtime.assert_called_once_with(wait=True, cancel_active_downloads=True)
server.serve_forever.assert_called_once() server.serve_forever.assert_called_once()
server.server_close.assert_called_once() server.server_close.assert_called_once()
@@ -3344,6 +3348,56 @@ class StartupBehaviorTests(unittest.TestCase):
APP.initialize_runtime(start_workers=False) 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): class HandlerRouteTests(unittest.TestCase):
def test_runtime_backed_route_respects_worker_disable_flag(self): def test_runtime_backed_route_respects_worker_disable_flag(self):
handler = DummyHandler("/api/watchlist/refresh-status") handler = DummyHandler("/api/watchlist/refresh-status")