Expand integration coverage and fix settings UI
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run deterministic Multilang browser checks against JF12 and restore its full state.
|
||||
|
||||
The runner creates a temporary local-NFO movie library, seeds only its real item
|
||||
IDs into Multilang's SQLite database, runs Playwright, then restores and hashes
|
||||
the JF12 config, data, cache, and injected web index. It refuses any target
|
||||
other than the disposable jellyfin12 instance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from run_jellyfin12_live_tests import JellyfinApi, RemoteState, wait_for_login
|
||||
|
||||
|
||||
FIXTURE_LIBRARY = "Multilang Browser Fixtures"
|
||||
FIXTURE_PATH = "/config/multilang-browser-fixtures/media"
|
||||
FIXTURES = (
|
||||
("The Anchor", "Ankkuri", "Ankkurin suomenkielinen kuvaus", "Draama"),
|
||||
("The Beacon", "Majakka", "Majakan suomenkielinen kuvaus", "Komedia"),
|
||||
("The Clock", None, None, "Draama"),
|
||||
)
|
||||
|
||||
|
||||
def request_empty(api: JellyfinApi, method: str, path: str, payload: dict | None = None) -> None:
|
||||
body = None if payload is None else json.dumps(payload).encode()
|
||||
response = api.request(method, path, body, {"Content-Type": "application/json"} if body else None)
|
||||
if not 200 <= response.status < 300:
|
||||
raise RuntimeError(f"{method} {path} returned HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
|
||||
|
||||
|
||||
def write_fixture_files(remote: RemoteState) -> None:
|
||||
host_fixture_path = f"{remote.args.state_root.rstrip('/')}/config/multilang-browser-fixtures/media"
|
||||
files: dict[str, str] = {}
|
||||
for index, (title, _, _, genre) in enumerate(FIXTURES, start=1):
|
||||
directory = title.replace(" ", "_")
|
||||
nfo = f"""<?xml version=\"1.0\" encoding=\"utf-8\"?>
|
||||
<movie>
|
||||
<title>{title}</title>
|
||||
<sorttitle>{title}</sorttitle>
|
||||
<originaltitle>{title}</originaltitle>
|
||||
<plot>English overview for {title}.</plot>
|
||||
<tagline>English tagline for {title}.</tagline>
|
||||
<year>2024</year>
|
||||
<premiered>2024-01-{index:02d}</premiered>
|
||||
<genre>{'Drama' if genre == 'Draama' else 'Comedy'}</genre>
|
||||
<language>en</language>
|
||||
<uniqueid type=\"tmdb\" default=\"true\">99000{index}</uniqueid>
|
||||
</movie>
|
||||
"""
|
||||
files[f"{directory}/movie.nfo"] = nfo
|
||||
files[f"{directory}/{title}.mkv"] = ""
|
||||
|
||||
commands = [f"mkdir -p {shlex.quote(host_fixture_path)}"]
|
||||
for relative, contents in files.items():
|
||||
target = f"{host_fixture_path}/{relative}"
|
||||
encoded = base64.b64encode(contents.encode()).decode()
|
||||
commands.append(f"mkdir -p {shlex.quote(str(Path(target).parent))}")
|
||||
commands.append(f"printf %s {shlex.quote(encoded)} | base64 -d > {shlex.quote(target)}")
|
||||
remote.run("bash -lc " + shlex.quote("; ".join(commands)))
|
||||
|
||||
|
||||
def wait_for_fixture_items(api: JellyfinApi, user_id: str) -> tuple[str, dict[str, str]]:
|
||||
for _ in range(90):
|
||||
views = api.json("GET", f"/Users/{user_id}/Views").get("Items", [])
|
||||
library = next((view for view in views if view.get("Name") == FIXTURE_LIBRARY), None)
|
||||
if library:
|
||||
items = api.json(
|
||||
"GET",
|
||||
f"/Users/{user_id}/Items?{urlencode({'ParentId': library['Id'], 'Recursive': 'true', 'IncludeItemTypes': 'Movie'})}",
|
||||
).get("Items", [])
|
||||
ids = {item.get("Name"): item.get("Id") for item in items}
|
||||
if all(title in ids for title, *_ in FIXTURES):
|
||||
return library["Id"], {title: ids[title] for title, *_ in FIXTURES}
|
||||
time.sleep(1)
|
||||
raise RuntimeError("JF12 did not index every deterministic browser fixture within 90 seconds.")
|
||||
|
||||
|
||||
def create_fixture_library(api: JellyfinApi, user_id: str, remote: RemoteState) -> tuple[str, dict[str, str]]:
|
||||
write_fixture_files(remote)
|
||||
request_empty(
|
||||
api,
|
||||
"POST",
|
||||
"/Library/VirtualFolders?" + urlencode({
|
||||
"name": FIXTURE_LIBRARY,
|
||||
"collectionType": "movies",
|
||||
"paths": FIXTURE_PATH,
|
||||
"refreshLibrary": "true",
|
||||
}),
|
||||
{"LibraryOptions": {"EnableRealtimeMonitor": False, "EnableInternetProviders": False}},
|
||||
)
|
||||
return wait_for_fixture_items(api, user_id)
|
||||
|
||||
|
||||
def seed_translations(remote: RemoteState, item_ids: dict[str, str]) -> None:
|
||||
rows = []
|
||||
for index, (title, translated, overview, genre) in enumerate(FIXTURES, start=1):
|
||||
rows.append({
|
||||
"item_id": item_ids[title].replace("-", "").lower(),
|
||||
"tmdb_id": f"99000{index}",
|
||||
"original_title": title,
|
||||
"original_language": "sv" if title == "The Beacon" else "en",
|
||||
"translations": {"title": translated, "overview": overview, "tagline": translated and f"{translated} - suomenkielinen iskulause"},
|
||||
"genre_id": 18 if genre == "Draama" else 35,
|
||||
"genre": genre,
|
||||
})
|
||||
|
||||
encoded = base64.b64encode(json.dumps(rows).encode()).decode()
|
||||
script = r'''
|
||||
import base64, json, os, sqlite3, sys, time
|
||||
rows = json.loads(base64.b64decode(sys.argv[1]))
|
||||
database = sys.argv[2]
|
||||
connection = sqlite3.connect(database)
|
||||
now = int(time.time())
|
||||
for row in rows:
|
||||
connection.execute("""
|
||||
INSERT INTO facts(item_id, tmdb_id, kind, original_title, original_language, original_language_all,
|
||||
origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language,
|
||||
genre_tmdb_ids_json, missing_checked_at, full_checked_at)
|
||||
VALUES (?, ?, 'movie', ?, ?, ?, '[\"US\"]', '[\"US\"]', '[\"en\"]', 'en', ?, ?, ?)
|
||||
ON CONFLICT(item_id) DO UPDATE SET tmdb_id=excluded.tmdb_id, kind=excluded.kind,
|
||||
original_title=excluded.original_title, original_language=excluded.original_language,
|
||||
original_language_all=excluded.original_language_all, genre_tmdb_ids_json=excluded.genre_tmdb_ids_json,
|
||||
missing_checked_at=excluded.missing_checked_at, full_checked_at=excluded.full_checked_at
|
||||
""", (row["item_id"], row["tmdb_id"], row["original_title"], row["original_language"], row["original_language"], json.dumps([row["genre_id"]]), now, now))
|
||||
for field, value in row["translations"].items():
|
||||
if value:
|
||||
connection.execute("""
|
||||
INSERT INTO translations(item_id, lang, field, text) VALUES (?, 'fi', ?, ?)
|
||||
ON CONFLICT(item_id, lang, field) DO UPDATE SET text=excluded.text
|
||||
""", (row["item_id"], field, value))
|
||||
connection.execute("""
|
||||
INSERT INTO genres(tmdb_id, media, lang, name, name_norm) VALUES (?, 'movie', 'fi', ?, ?)
|
||||
ON CONFLICT(tmdb_id, media, lang) DO UPDATE SET name=excluded.name, name_norm=excluded.name_norm
|
||||
""", (row["genre_id"], row["genre"], row["genre"].lower()))
|
||||
connection.commit()
|
||||
'''
|
||||
remote.stop()
|
||||
try:
|
||||
database = f"{remote.args.state_root.rstrip('/')}/data/multilang/translations.sqlite"
|
||||
remote.run("python3 -c " + shlex.quote(script) + " " + shlex.quote(encoded) + " " + shlex.quote(database))
|
||||
finally:
|
||||
remote.start()
|
||||
|
||||
|
||||
def configure_multilang(api: JellyfinApi) -> None:
|
||||
config = api.json("GET", "/Multilang/AdminConfig")
|
||||
config.update({
|
||||
"Languages": ["en", "fi"],
|
||||
"ItemsProxyCacheThresholdMs": 0,
|
||||
"ItemsProxyCacheTtlMinutes": 5,
|
||||
"ItemsProxyCacheMaxMiB": 8,
|
||||
})
|
||||
api.json("POST", "/Multilang/AdminConfig", config)
|
||||
|
||||
rules = api.json("GET", "/Multilang/UserRules/self")
|
||||
rules.update({
|
||||
"Enabled": True,
|
||||
"SortLocale": "fi-FI",
|
||||
"Categories": [],
|
||||
"FallbackFieldActions": {
|
||||
"title": ["Language:fi", "Jellyfin"],
|
||||
"overview": ["Language:fi", "Jellyfin"],
|
||||
"tagline": ["Language:fi", "Jellyfin"],
|
||||
"poster": ["Jellyfin"],
|
||||
"logo": ["Jellyfin"],
|
||||
"banner": ["Jellyfin"],
|
||||
"thumb": ["Jellyfin"],
|
||||
"backdrop": ["Jellyfin"],
|
||||
},
|
||||
})
|
||||
api.json("POST", "/Multilang/UserRules/self", rules)
|
||||
api.json("POST", "/Multilang/ClearCache")
|
||||
|
||||
|
||||
def run_playwright(args: argparse.Namespace, fixture_file: Path) -> None:
|
||||
browser_dir = Path(__file__).resolve().parents[1] / "tests" / "browser"
|
||||
if not (browser_dir / "node_modules" / "@playwright" / "test").exists():
|
||||
raise RuntimeError("Playwright is not installed. Run: cd tests/browser && npm install && npx playwright install chromium")
|
||||
|
||||
environment = os.environ | {
|
||||
"JELLYFIN_BASE_URL": args.base_url.rstrip("/"),
|
||||
"MULTILANG_BROWSER_FIXTURE": str(fixture_file),
|
||||
}
|
||||
result = subprocess.run(["npm", "test"], cwd=browser_dir, env=environment)
|
||||
if result.returncode:
|
||||
raise RuntimeError("Playwright browser checks failed.")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base-url", required=True)
|
||||
parser.add_argument("--username", required=True)
|
||||
parser.add_argument("--password", required=True)
|
||||
parser.add_argument("--ssh-host", required=True)
|
||||
parser.add_argument("--ssh-user", default="root")
|
||||
parser.add_argument("--local-docker", action="store_true")
|
||||
parser.add_argument("--container", default="jellyfin12")
|
||||
parser.add_argument("--state-root", required=True)
|
||||
parser.add_argument("--confirm-destructive", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if not args.confirm_destructive:
|
||||
parser.error("--confirm-destructive is required")
|
||||
if args.container != "jellyfin12" or not args.state_root.rstrip("/").endswith("/jellyfin12"):
|
||||
parser.error("This runner only permits the disposable jellyfin12 instance.")
|
||||
if ":8097" not in args.base_url:
|
||||
parser.error("This runner only permits the JF12 URL on port 8097.")
|
||||
args.full_server_state = True
|
||||
return args
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
remote = RemoteState(args)
|
||||
fixture_file = Path(__file__).resolve().parents[1] / "tests" / "browser" / ".auth" / f"fixture-{uuid.uuid4().hex}.json"
|
||||
success = False
|
||||
try:
|
||||
remote.verify_target()
|
||||
remote.snapshot()
|
||||
api, user_id = wait_for_login(args.base_url, args.username, args.password)
|
||||
library_id, item_ids = create_fixture_library(api, user_id, remote)
|
||||
seed_translations(remote, item_ids)
|
||||
api, user_id = wait_for_login(args.base_url, args.username, args.password)
|
||||
configure_multilang(api)
|
||||
fixture_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fixture_file.write_text(json.dumps({
|
||||
"username": args.username,
|
||||
"password": args.password,
|
||||
"token": api.token,
|
||||
"userId": user_id,
|
||||
"libraryId": library_id,
|
||||
"jellyfinTitles": [title for title, *_ in FIXTURES],
|
||||
"translatedTitles": [translated or title for title, translated, *_ in FIXTURES],
|
||||
"orderedItemIds": [item_ids[title] for title, *_ in FIXTURES],
|
||||
"translatedSortedTitles": ["Ankkuri", "The Clock", "Majakka"],
|
||||
"translatedOrderedItemIds": [item_ids["The Anchor"], item_ids["The Clock"], item_ids["The Beacon"]],
|
||||
}))
|
||||
run_playwright(args, fixture_file)
|
||||
success = True
|
||||
finally:
|
||||
fixture_file.unlink(missing_ok=True)
|
||||
try:
|
||||
if remote.expected_web_hash:
|
||||
remote.restore_and_verify()
|
||||
print("Restoration hash check passed for JF12 config, data, cache, and injected web index.")
|
||||
remote.discard_snapshot()
|
||||
else:
|
||||
remote.discard_snapshot()
|
||||
except Exception as error:
|
||||
print(f"RESTORATION FAILED: {error}", file=sys.stderr)
|
||||
return 2
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Destructive Multilang integration checks with verified JF12 state restoration.
|
||||
|
||||
The script intentionally accepts all deployment details as arguments. It refuses
|
||||
to run unless both the container and persistent state path are the JF12 test
|
||||
instance. It snapshots only Multilang-owned state and the injected web index,
|
||||
then restores and hashes those paths in a finally block. Run it from a separate
|
||||
machine through SSH, or use --local-docker when it runs on the Docker host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
PLUGIN_CONFIG = "config/plugins/configurations/Jellyfin.Plugin.Multilang.xml"
|
||||
PLUGIN_DATA = "data/multilang"
|
||||
WEB_INDEX = "/jellyfin/jellyfin-web/index.html"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Response:
|
||||
status: int
|
||||
body: bytes
|
||||
content_type: str
|
||||
|
||||
|
||||
class JellyfinApi:
|
||||
def __init__(self, base_url: str, token: str | None = None) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
|
||||
def request(self, method: str, path: str, body: bytes | None = None, headers: dict[str, str] | None = None) -> Response:
|
||||
request_headers = dict(headers or {})
|
||||
if self.token:
|
||||
request_headers.setdefault("Authorization", f'MediaBrowser Token="{self.token}"')
|
||||
request_headers.setdefault("X-Emby-Token", self.token)
|
||||
request_headers.setdefault("X-MediaBrowser-Token", self.token)
|
||||
request = Request(self.base_url + path, body, request_headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=60) as response:
|
||||
return Response(response.status, response.read(), response.headers.get_content_type())
|
||||
except HTTPError as error:
|
||||
return Response(error.code, error.read(), error.headers.get_content_type())
|
||||
|
||||
def json(self, method: str, path: str, payload: Any | None = None) -> Any:
|
||||
body = None if payload is None else json.dumps(payload).encode()
|
||||
headers = {} if body is None else {"Content-Type": "application/json"}
|
||||
response = self.request(method, path, body, headers)
|
||||
if response.status < 200 or response.status >= 300:
|
||||
raise RuntimeError(f"{method} {path} returned HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
|
||||
return json.loads(response.body)
|
||||
|
||||
def multipart(self, path: str, fields: dict[str, str], filename: str, contents: bytes) -> Any:
|
||||
boundary = "----multilang-live-test-" + uuid.uuid4().hex
|
||||
chunks: list[bytes] = []
|
||||
for key, value in fields.items():
|
||||
chunks.extend([
|
||||
f"--{boundary}\r\n".encode(),
|
||||
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode(),
|
||||
value.encode(),
|
||||
b"\r\n",
|
||||
])
|
||||
chunks.extend([
|
||||
f"--{boundary}\r\n".encode(),
|
||||
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode(),
|
||||
b"Content-Type: application/zip\r\n\r\n",
|
||||
contents,
|
||||
b"\r\n",
|
||||
f"--{boundary}--\r\n".encode(),
|
||||
])
|
||||
response = self.request("POST", path, b"".join(chunks), {"Content-Type": f"multipart/form-data; boundary={boundary}"})
|
||||
if response.status < 200 or response.status >= 300:
|
||||
raise RuntimeError(f"POST {path} returned HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
|
||||
return json.loads(response.body)
|
||||
|
||||
|
||||
class RemoteState:
|
||||
def __init__(self, args: argparse.Namespace) -> None:
|
||||
self.args = args
|
||||
self.snapshot_dir = f"/tmp/multilang-live-test-{uuid.uuid4().hex}"
|
||||
self.expected_hashes = ""
|
||||
self.expected_web_hash = ""
|
||||
|
||||
def run(self, command: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
if self.args.local_docker:
|
||||
result = subprocess.run(["bash", "-lc", command], text=True, capture_output=True)
|
||||
else:
|
||||
target = f"{self.args.ssh_user}@{self.args.ssh_host}"
|
||||
result = subprocess.run(["ssh", target, command], text=True, capture_output=True)
|
||||
if check and result.returncode:
|
||||
raise RuntimeError(f"Remote command failed: {command}\n{result.stderr.strip()}")
|
||||
return result
|
||||
|
||||
def docker(self, command: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return self.run("docker " + command, check)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.docker("stop " + shlex.quote(self.args.container))
|
||||
|
||||
def start(self) -> None:
|
||||
self.docker("start " + shlex.quote(self.args.container))
|
||||
|
||||
def verify_target(self) -> None:
|
||||
mounts = self.docker("inspect -f '{{ range .Mounts }}{{ println .Source }}{{ end }}' " + shlex.quote(self.args.container)).stdout
|
||||
root = self.args.state_root.rstrip("/") + "/"
|
||||
mounted_paths = mounts.splitlines()
|
||||
if not any(path.startswith(root) for path in mounted_paths):
|
||||
raise RuntimeError("The requested JF12 state root does not contain any mounts for the requested container.")
|
||||
for required in ("config", "data"):
|
||||
if root + required not in mounted_paths:
|
||||
raise RuntimeError(f"The requested JF12 state root does not mount {required} into the requested container.")
|
||||
if self.args.full_server_state and root + "cache" not in mounted_paths:
|
||||
raise RuntimeError("Full-state verification requires the JF12 cache mount.")
|
||||
|
||||
def state_paths(self) -> tuple[str, ...]:
|
||||
return ("config", "data", "cache") if self.args.full_server_state else (PLUGIN_DATA, PLUGIN_CONFIG)
|
||||
|
||||
def hash_plugin_state(self) -> str:
|
||||
root = shlex.quote(self.args.state_root)
|
||||
paths = " ".join(shlex.quote(path) for path in self.state_paths())
|
||||
command = (
|
||||
f"cd {root} && "
|
||||
f"find {paths} -type f -print0 2>/dev/null "
|
||||
"| sort -z | xargs -0 -r sha256sum"
|
||||
)
|
||||
return self.run(command).stdout
|
||||
|
||||
def snapshot(self) -> None:
|
||||
self.stop()
|
||||
try:
|
||||
root = shlex.quote(self.args.state_root)
|
||||
snapshot = shlex.quote(self.snapshot_dir)
|
||||
paths = " ".join(shlex.quote(path) for path in self.state_paths())
|
||||
self.run(f"mkdir -p {snapshot}")
|
||||
self.expected_hashes = self.hash_plugin_state()
|
||||
self.run(
|
||||
f"tar -C {root} --ignore-failed-read -czf {snapshot}/plugin-state.tar.gz {paths}"
|
||||
)
|
||||
self.docker(
|
||||
"cp " + shlex.quote(self.args.container + ":" + WEB_INDEX) + " " + shlex.quote(self.snapshot_dir + "/index.html")
|
||||
)
|
||||
self.expected_web_hash = self.run("sha256sum " + shlex.quote(self.snapshot_dir + "/index.html")).stdout.split()[0]
|
||||
finally:
|
||||
self.start()
|
||||
|
||||
def restore_and_verify(self) -> None:
|
||||
self.stop()
|
||||
try:
|
||||
root = shlex.quote(self.args.state_root)
|
||||
snapshot = shlex.quote(self.snapshot_dir)
|
||||
removals = " ".join(f"{root}/{shlex.quote(path)}" for path in self.state_paths())
|
||||
self.run(
|
||||
f"rm -rf {removals} && "
|
||||
f"tar -C {root} -xzf {snapshot}/plugin-state.tar.gz"
|
||||
)
|
||||
self.docker(
|
||||
"cp " + shlex.quote(self.snapshot_dir + "/index.html") + " " + shlex.quote(self.args.container + ":" + WEB_INDEX)
|
||||
)
|
||||
actual_hashes = self.hash_plugin_state()
|
||||
restored_index = self.snapshot_dir + "/restored-index.html"
|
||||
self.docker(
|
||||
"cp " + shlex.quote(self.args.container + ":" + WEB_INDEX) + " " + shlex.quote(restored_index)
|
||||
)
|
||||
actual_web_hash = self.run("sha256sum " + shlex.quote(restored_index)).stdout.split()[0]
|
||||
if actual_hashes != self.expected_hashes or actual_web_hash != self.expected_web_hash:
|
||||
raise RuntimeError(
|
||||
"State restoration hash mismatch. The remote snapshot was retained at " + self.snapshot_dir
|
||||
)
|
||||
finally:
|
||||
self.start()
|
||||
|
||||
def discard_snapshot(self) -> None:
|
||||
self.run("rm -rf " + shlex.quote(self.snapshot_dir))
|
||||
|
||||
|
||||
def login(base_url: str, username: str, password: str) -> tuple[JellyfinApi, str]:
|
||||
api = JellyfinApi(base_url)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": 'MediaBrowser Client="Multilang live tests", Device="Codex", DeviceId="multilang-live-tests", Version="1.0"',
|
||||
}
|
||||
response = api.request("POST", "/Users/AuthenticateByName", json.dumps({"Username": username, "Pw": password}).encode(), headers)
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"Authentication failed: HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
|
||||
payload = json.loads(response.body)
|
||||
return JellyfinApi(base_url, payload["AccessToken"]), payload["User"]["Id"]
|
||||
|
||||
|
||||
def wait_for_login(base_url: str, username: str, password: str) -> tuple[JellyfinApi, str]:
|
||||
for _ in range(60):
|
||||
try:
|
||||
return login(base_url, username, password)
|
||||
except (OSError, URLError):
|
||||
time.sleep(1)
|
||||
continue
|
||||
except RuntimeError as error:
|
||||
if "HTTP 5" not in str(error):
|
||||
raise
|
||||
time.sleep(1)
|
||||
raise RuntimeError("Jellyfin12 did not accept authentication within 60 seconds.")
|
||||
|
||||
|
||||
def assert_equal(actual: Any, expected: Any, name: str) -> None:
|
||||
if actual != expected:
|
||||
raise AssertionError(f"{name}: expected {expected!r}, got {actual!r}")
|
||||
|
||||
|
||||
def export_zip(api: JellyfinApi, query: dict[str, str]) -> bytes:
|
||||
response = api.request("GET", "/Multilang/Export?" + urlencode(query))
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"Export failed with HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
|
||||
with zipfile.ZipFile(BytesIO(response.body)) as archive:
|
||||
if "manifest.json" not in archive.namelist():
|
||||
raise AssertionError("Admin export did not contain manifest.json.")
|
||||
return response.body
|
||||
|
||||
|
||||
def choose_library(api: JellyfinApi, user_id: str) -> tuple[str, str]:
|
||||
views = api.json("GET", f"/Users/{user_id}/Views")
|
||||
for view in views.get("Items", []):
|
||||
collection_type = str(view.get("CollectionType", "")).lower()
|
||||
if collection_type in {"movies", "tvshows"}:
|
||||
return view["Id"], "Movie" if collection_type == "movies" else "Series"
|
||||
raise RuntimeError("No movie or TV-show library was available for the live proxy test.")
|
||||
|
||||
|
||||
def proxy_path(user_id: str, library_id: str, item_type: str) -> str:
|
||||
upstream = (
|
||||
f"/Users/{user_id}/Items?ParentId={library_id}&IncludeItemTypes={item_type}"
|
||||
"&Recursive=true&SortBy=SortName&SortOrder=Ascending&StartIndex=0&Limit=100"
|
||||
)
|
||||
return "/Multilang/ItemsProxy?" + urlencode({"url": upstream, "mlLocale": "en-US"})
|
||||
|
||||
|
||||
def run_checks(api: JellyfinApi, user_id: str, include_refresh: bool) -> None:
|
||||
print("[1/7] Checking configuration and user-rule persistence")
|
||||
config = api.json("GET", "/Multilang/AdminConfig")
|
||||
rules = api.json("GET", "/Multilang/UserRules/self")
|
||||
baseline_export = export_zip(api, {
|
||||
"pluginSettings": "true",
|
||||
"userSettings": "true",
|
||||
"translationsDatabase": "false",
|
||||
"downloadedAssets": "false",
|
||||
})
|
||||
|
||||
changed_config = dict(config)
|
||||
changed_config["ItemsProxyCacheThresholdMs"] = 0 if config.get("ItemsProxyCacheThresholdMs") else 1
|
||||
saved_config = api.json("POST", "/Multilang/AdminConfig", changed_config)
|
||||
assert_equal(saved_config["ItemsProxyCacheThresholdMs"], changed_config["ItemsProxyCacheThresholdMs"], "admin configuration save")
|
||||
|
||||
changed_rules = dict(rules)
|
||||
changed_rules["Enabled"] = True
|
||||
changed_rules["SortLocale"] = "fi-FI"
|
||||
saved_rules = api.json("POST", "/Multilang/UserRules/self", changed_rules)
|
||||
assert_equal(saved_rules["Enabled"], True, "user rule enable")
|
||||
assert_equal(saved_rules["SortLocale"], "fi-FI", "user sort locale save")
|
||||
|
||||
print("[2/7] Checking user and admin backup/import endpoints")
|
||||
user_export = api.request("GET", "/Multilang/UserRules/self/export")
|
||||
if user_export.status != 200:
|
||||
raise RuntimeError(f"User export failed with HTTP {user_export.status}")
|
||||
inspected = api.multipart("/Multilang/UserRules/self/import/inspect", {}, "user-rules.zip", user_export.body)
|
||||
if not inspected.get("ContainsCurrentUser"):
|
||||
raise AssertionError("User export inspection did not identify the exporting user.")
|
||||
imported_user = api.multipart("/Multilang/UserRules/self/import", {}, "user-rules.zip", user_export.body)
|
||||
if imported_user.get("UserSettingsImported") != 1:
|
||||
raise AssertionError("User export did not import exactly one rule set.")
|
||||
api.multipart(
|
||||
"/Multilang/Import",
|
||||
{"pluginSettings": "true", "userSettings": "true", "translationsDatabase": "false", "downloadedAssets": "false"},
|
||||
"admin-export.zip",
|
||||
baseline_export,
|
||||
)
|
||||
restored_config = api.json("GET", "/Multilang/AdminConfig")
|
||||
assert_equal(restored_config["ItemsProxyCacheThresholdMs"], config["ItemsProxyCacheThresholdMs"], "admin export/import round trip")
|
||||
|
||||
print("[3/7] Checking transformed proxy responses, cache entries, and pre-cache triggering")
|
||||
enabled_rules = dict(rules)
|
||||
enabled_rules["Enabled"] = True
|
||||
api.json("POST", "/Multilang/UserRules/self", enabled_rules)
|
||||
cached_config = dict(config)
|
||||
cached_config["ItemsProxyCacheThresholdMs"] = 0
|
||||
cached_config["ItemsProxyCacheTtlMinutes"] = max(1, int(cached_config.get("ItemsProxyCacheTtlMinutes", 1)))
|
||||
cached_config["ItemsProxyCacheMaxMiB"] = max(1, int(cached_config.get("ItemsProxyCacheMaxMiB", 1)))
|
||||
api.json("POST", "/Multilang/AdminConfig", cached_config)
|
||||
api.json("POST", "/Multilang/ClearCache")
|
||||
library_id, item_type = choose_library(api, user_id)
|
||||
path = proxy_path(user_id, library_id, item_type)
|
||||
first = api.request("GET", path)
|
||||
second = api.request("GET", path)
|
||||
if first.status != 200 or second.status != 200:
|
||||
raise RuntimeError(f"ItemsProxy returned {first.status} then {second.status}")
|
||||
first_payload = json.loads(first.body)
|
||||
if not isinstance(first_payload.get("Items"), list):
|
||||
raise AssertionError("ItemsProxy response did not contain an Items list.")
|
||||
if not first_payload["Items"]:
|
||||
raise RuntimeError("The selected library had no items for the cleanup probe.")
|
||||
probe_item_id = first_payload["Items"][0]["Id"]
|
||||
requests = api.json("GET", "/Multilang/ItemsProxyRequests")
|
||||
if not any(request.get("CacheHit") for request in requests):
|
||||
raise AssertionError("Second proxy request did not produce a cache hit.")
|
||||
entries = api.json("GET", "/Multilang/CacheEntries")
|
||||
if not entries:
|
||||
raise AssertionError("ItemsProxy did not store a cache entry at a zero-millisecond threshold.")
|
||||
|
||||
print("[4/7] Checking concurrent proxy requests remain valid")
|
||||
api.json("POST", "/Multilang/ClearCache")
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
responses = list(pool.map(lambda _: api.request("GET", path), range(2)))
|
||||
if any(response.status != 200 for response in responses):
|
||||
raise AssertionError("Concurrent proxy request did not return HTTP 200.")
|
||||
|
||||
if include_refresh:
|
||||
print("[5/7] Checking one-item refresh and debug data")
|
||||
refreshed = api.json("POST", f"/Multilang/RefreshItem/{probe_item_id}?includeChildren=false")
|
||||
assert_equal(refreshed["ItemId"].lower(), probe_item_id.replace("-", "").lower(), "single-item refresh")
|
||||
debug = api.json("GET", f"/Multilang/Debug/{probe_item_id}")
|
||||
assert_equal(debug["ItemId"].lower(), probe_item_id.replace("-", "").lower(), "debug item identity")
|
||||
else:
|
||||
print("[5/7] Skipping the provider-backed refresh probe (pass --include-refresh to run it)")
|
||||
|
||||
print("[6/7] Checking cleanup endpoint and default-state recovery")
|
||||
api.json("POST", "/Multilang/CleanupAll", {"Confirm": True})
|
||||
cleanup_info = api.json("GET", "/Multilang/ExportInfo")
|
||||
if cleanup_info["AssetsBytes"] != 0:
|
||||
raise AssertionError("CleanupAll did not remove local assets.")
|
||||
default_rules = api.json("GET", "/Multilang/UserRules/self")
|
||||
if default_rules.get("Enabled"):
|
||||
raise AssertionError("CleanupAll did not remove the user rule document.")
|
||||
if api.json("GET", f"/Multilang/Debug/{probe_item_id}").get("Facts") is not None:
|
||||
raise AssertionError("CleanupAll did not remove stored item facts.")
|
||||
|
||||
print("[7/7] Live checks passed; raw restoration will now run")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base-url", required=True, help="JF12 base URL, expected to use port 8097")
|
||||
parser.add_argument("--username", required=True)
|
||||
parser.add_argument("--password", required=True)
|
||||
parser.add_argument("--ssh-host", required=True)
|
||||
parser.add_argument("--ssh-user", default="root")
|
||||
parser.add_argument("--local-docker", action="store_true", help="Run Docker and filesystem commands on this host instead of through SSH")
|
||||
parser.add_argument("--container", default="jellyfin12")
|
||||
parser.add_argument("--state-root", required=True, help="JF12 persistent volume root")
|
||||
parser.add_argument("--confirm-destructive", action="store_true", help="Required: tests modify JF12 Multilang state before restoring it")
|
||||
parser.add_argument("--include-refresh", action="store_true", help="Also run a provider-backed single-item refresh; this can take longer than the core suite")
|
||||
parser.add_argument("--full-server-state", action="store_true", help="Snapshot and restore the JF12 config, data, and cache mounts instead of only Multilang-owned paths")
|
||||
args = parser.parse_args()
|
||||
parsed = urlparse(args.base_url)
|
||||
if not args.confirm_destructive:
|
||||
parser.error("--confirm-destructive is required")
|
||||
if args.container != "jellyfin12":
|
||||
parser.error("This runner only permits the jellyfin12 container")
|
||||
if parsed.port != 8097:
|
||||
parser.error("This runner only permits the JF12 test URL on port 8097")
|
||||
if parsed.hostname in {"127.0.0.1", "::1", "localhost"}:
|
||||
parser.error("Use a LAN host name or address reachable from inside the Jellyfin container, not loopback.")
|
||||
if not args.state_root.rstrip("/").endswith("/jellyfin12"):
|
||||
parser.error("This runner only permits a state root ending in /jellyfin12")
|
||||
return args
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
remote = RemoteState(args)
|
||||
success = False
|
||||
try:
|
||||
remote.verify_target()
|
||||
remote.snapshot()
|
||||
api, user_id = wait_for_login(args.base_url, args.username, args.password)
|
||||
run_checks(api, user_id, args.include_refresh)
|
||||
success = True
|
||||
finally:
|
||||
try:
|
||||
if remote.expected_web_hash:
|
||||
remote.restore_and_verify()
|
||||
scope = "JF12 config, data, cache, and injected web index" if args.full_server_state else "Multilang state and injected web index"
|
||||
print(f"Restoration hash check passed for {scope}.")
|
||||
remote.discard_snapshot()
|
||||
else:
|
||||
remote.discard_snapshot()
|
||||
except Exception as error:
|
||||
print(f"RESTORATION FAILED: {error}", file=sys.stderr)
|
||||
return 2
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user