268 lines
12 KiB
Python
268 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()))
|
|
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())
|