Harden proxy permissions and cache behavior, streamline metadata fetching, remove obsolete rules, and validate the stable SDK with expanded regression coverage. Document backup format changes.
276 lines
12 KiB
Python
276 lines
12 KiB
Python
#!/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()))
|
|
relative = row["item_id"] + "/fixture.png"
|
|
destination = os.path.join(os.path.dirname(database), "assets", relative)
|
|
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
|
with open(destination, "wb") as image:
|
|
image.write(base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jZ1kAAAAASUVORK5CYII="))
|
|
path = "/Multilang/Assets/" + relative
|
|
connection.execute("INSERT OR REPLACE INTO assets(item_id,lang,kind,path,path_low,updated_at) VALUES (?, 'fi', 'poster', ?, ?, ?)",
|
|
(row["item_id"], path, path.lower(), now))
|
|
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())
|