Add provider update startup check
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -243,6 +243,14 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
}
|
||||
.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"""<!doctype html>
|
||||
</div>
|
||||
<button class="ghost wide-button" id="openChangelogBtn" type="button">Changelog</button>
|
||||
</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>
|
||||
|
||||
<main>
|
||||
@@ -1084,6 +1099,52 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
$("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"""<!doctype html>
|
||||
await pollJellyfinSyncStatus(false);
|
||||
await loadDeps();
|
||||
await loadVersion();
|
||||
await loadProviderUpdates();
|
||||
window.setInterval(() => {
|
||||
pollJellyfinSyncStatus();
|
||||
}, 2500);
|
||||
window.setInterval(loadProviderUpdates, 10000);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user