feat(test): add Maestro end-to-end coverage
This commit is contained in:
@@ -86,6 +86,7 @@ if python3 scripts/check_build_workflow.py &&
|
||||
python3 scripts/check_update_packages_workflow.py &&
|
||||
python3 scripts/test_pubspec_version.py &&
|
||||
python3 scripts/test_clean_translations.py &&
|
||||
python3 scripts/test_run_maestro.py &&
|
||||
python3 scripts/test_check_icon_consistency.py; then
|
||||
ok "workflow and script guards passed"
|
||||
else
|
||||
|
||||
File diff suppressed because one or more lines are too long
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Forward to a real Jellyfin server with narrowly scoped one-shot faults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
_FAULT_PATHS = {
|
||||
"music-failure": lambda path: path.startswith("/Artists/AlbumArtists"),
|
||||
"recovery": lambda path: path.startswith("/Videos/") and "/stream" in path,
|
||||
}
|
||||
_FORWARD_HEADERS = {
|
||||
"accept",
|
||||
"authorization",
|
||||
"content-type",
|
||||
"if-modified-since",
|
||||
"if-none-match",
|
||||
"range",
|
||||
"user-agent",
|
||||
"x-emby-authorization",
|
||||
"x-emby-token",
|
||||
}
|
||||
_RESPONSE_HEADERS = {
|
||||
"accept-ranges",
|
||||
"cache-control",
|
||||
"content-disposition",
|
||||
"content-range",
|
||||
"content-type",
|
||||
"date",
|
||||
"etag",
|
||||
"last-modified",
|
||||
"location",
|
||||
}
|
||||
|
||||
|
||||
class ProxyState:
|
||||
def __init__(self, upstream: str, fault: str | None, journal: Path | None) -> None:
|
||||
self.upstream = upstream.rstrip("/")
|
||||
self.fault = fault
|
||||
self.journal = journal
|
||||
self._fault_injected = False
|
||||
self._sequence = 0
|
||||
self._lock = threading.Lock()
|
||||
if journal is not None:
|
||||
journal.parent.mkdir(parents=True, exist_ok=True)
|
||||
journal.write_text("", encoding="utf-8")
|
||||
|
||||
def should_fault(self, path: str) -> bool:
|
||||
predicate = _FAULT_PATHS.get(self.fault)
|
||||
if predicate is None or not predicate(path):
|
||||
return False
|
||||
with self._lock:
|
||||
if self._fault_injected:
|
||||
return False
|
||||
self._fault_injected = True
|
||||
return True
|
||||
|
||||
def record(self, *, method: str, path: str, status: int, kind: str) -> None:
|
||||
if self.journal is None:
|
||||
return
|
||||
with self._lock:
|
||||
self._sequence += 1
|
||||
event = {
|
||||
"sequence": self._sequence,
|
||||
"timestampMs": int(time.time() * 1000),
|
||||
"kind": kind,
|
||||
"method": method,
|
||||
"path": urllib.parse.urlsplit(path).path,
|
||||
"status": status,
|
||||
}
|
||||
with self.journal.open("a", encoding="utf-8") as output:
|
||||
output.write(json.dumps(event, separators=(",", ":"), sort_keys=True) + "\n")
|
||||
|
||||
|
||||
class JellyfinProxyHandler(BaseHTTPRequestHandler):
|
||||
server_version = "PlezyJellyfinProxy/1.0"
|
||||
|
||||
@property
|
||||
def state(self) -> ProxyState:
|
||||
return self.server.state # type: ignore[attr-defined]
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_PATCH(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_OPTIONS(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def _proxy(self) -> None:
|
||||
if self.state.should_fault(self.path):
|
||||
payload = json.dumps({"error": "temporary Maestro fault"}).encode("utf-8")
|
||||
self.send_response(HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(payload)
|
||||
self.state.record(method=self.command, path=self.path, status=503, kind="fault")
|
||||
return
|
||||
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(content_length) if content_length else None
|
||||
headers = {
|
||||
name: value
|
||||
for name, value in self.headers.items()
|
||||
if name.lower() in _FORWARD_HEADERS
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
self.state.upstream + self.path,
|
||||
data=body,
|
||||
method=self.command,
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
status = response.status
|
||||
response_headers = response.headers
|
||||
payload = response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
status = error.code
|
||||
response_headers = error.headers
|
||||
payload = error.read()
|
||||
except (OSError, urllib.error.URLError) as error:
|
||||
payload = json.dumps({"error": f"upstream unavailable: {error}"}).encode("utf-8")
|
||||
status = HTTPStatus.BAD_GATEWAY
|
||||
response_headers = {"Content-Type": "application/json"}
|
||||
|
||||
self.send_response(status)
|
||||
for name, value in response_headers.items():
|
||||
if name.lower() in _RESPONSE_HEADERS:
|
||||
self.send_header(name, value)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
try:
|
||||
self.wfile.write(payload)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
self.state.record(method=self.command, path=self.path, status=int(status), kind="request")
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
print(f"jellyfin-proxy: {format % args}")
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, required=True)
|
||||
parser.add_argument("--upstream", required=True)
|
||||
parser.add_argument("--fault", choices=sorted(_FAULT_PATHS))
|
||||
parser.add_argument("--journal", type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _build_parser().parse_args()
|
||||
server = ThreadingHTTPServer((args.host, args.port), JellyfinProxyHandler)
|
||||
server.daemon_threads = True
|
||||
server.state = ProxyState(args.upstream, args.fault, args.journal) # type: ignore[attr-defined]
|
||||
print(f"Jellyfin proxy listening on http://{args.host}:{args.port} -> {args.upstream}", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+687
@@ -0,0 +1,687 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare and bootstrap a disposable real Jellyfin server for Maestro."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from maestro_fixtures import MEDIA_FIXTURE_SPECS, _AUDIO, _VIDEO
|
||||
|
||||
USERNAME = "maestro"
|
||||
PASSWORD = "maestro"
|
||||
GUEST_USERNAME = "guest"
|
||||
GUEST_PASSWORD = "guest"
|
||||
SERVER_NAME = "Maestro Jellyfin"
|
||||
BASE_TITLE = "Maestro Movie"
|
||||
BASE_OVERVIEW = "A deterministic movie used to verify Plezy's end-to-end flows."
|
||||
GUEST_TITLE = "Guest Galaxy"
|
||||
SHOW_TITLE = "Maestro Show"
|
||||
EPISODE_TITLES = ("Maestro Episode 1", "Maestro Episode 2")
|
||||
MUSIC_ARTIST = "Maestro Artist"
|
||||
MUSIC_ALBUM = "Regression Album"
|
||||
MUSIC_TRACK = "Resilient Track"
|
||||
ALPHABET_TITLES = (
|
||||
"Alpha Archive",
|
||||
"Bravo Beacon",
|
||||
"Charlie Circuit",
|
||||
"Delta Drive",
|
||||
"Echo Engine",
|
||||
"Foxtrot Frame",
|
||||
"Gamma Garden",
|
||||
"Hotel Horizon",
|
||||
"India Index",
|
||||
"Juliet Junction",
|
||||
"Kilo Key",
|
||||
"Lima Loop",
|
||||
"Mike Matrix",
|
||||
"November Node",
|
||||
"Oscar Orbit",
|
||||
"Papa Pipeline",
|
||||
"Quebec Queue",
|
||||
"Romeo Relay",
|
||||
"Sierra Signal",
|
||||
"Tango Track",
|
||||
"Uniform Update",
|
||||
"Victor View",
|
||||
"Whiskey Widget",
|
||||
"Xray XML",
|
||||
"Yankee Yield",
|
||||
"Zulu Zone",
|
||||
)
|
||||
_MANAGED_MARKER = ".plezy-jellyfin-e2e-media"
|
||||
DEFAULT_CODEC_BASE_URL = "https://demo-files.plezy.app/media-samples/"
|
||||
_DOWNLOAD_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
|
||||
def _write_nfo(path: Path, *, item_id: str, title: str, overview: str, genre: str) -> None:
|
||||
movie = ET.Element("movie")
|
||||
values = {
|
||||
"title": title,
|
||||
"originaltitle": title,
|
||||
"sorttitle": title,
|
||||
"year": "2026",
|
||||
"premiered": "2026-01-01",
|
||||
"dateadded": "2026-01-01 00:00:00",
|
||||
"plot": overview,
|
||||
"outline": overview,
|
||||
"studio": "Plezy E2E",
|
||||
"genre": genre,
|
||||
"tag": "E2E",
|
||||
"mpaa": "E2E",
|
||||
"rating": "8.0",
|
||||
"lockdata": "true",
|
||||
}
|
||||
for key, value in values.items():
|
||||
ET.SubElement(movie, key).text = value
|
||||
unique_id = ET.SubElement(movie, "uniqueid", {"type": "plezy", "default": "true"})
|
||||
unique_id.text = item_id
|
||||
ET.indent(movie, space=" ")
|
||||
ET.ElementTree(movie).write(path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
def _write_show_nfo(path: Path) -> None:
|
||||
show = ET.Element("tvshow")
|
||||
for key, value in {
|
||||
"title": SHOW_TITLE,
|
||||
"sorttitle": SHOW_TITLE,
|
||||
"year": "2026",
|
||||
"premiered": "2026-01-01",
|
||||
"plot": "A deterministic show used to verify episode playback and queue behavior.",
|
||||
"studio": "Plezy E2E",
|
||||
"genre": "Test",
|
||||
"lockdata": "true",
|
||||
}.items():
|
||||
ET.SubElement(show, key).text = value
|
||||
unique_id = ET.SubElement(show, "uniqueid", {"type": "plezy", "default": "true"})
|
||||
unique_id.text = "maestro-show"
|
||||
ET.indent(show, space=" ")
|
||||
ET.ElementTree(show).write(path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _write_episode_nfo(path: Path, number: int) -> None:
|
||||
episode = ET.Element("episodedetails")
|
||||
title = EPISODE_TITLES[number - 1]
|
||||
for key, value in {
|
||||
"title": title,
|
||||
"showtitle": SHOW_TITLE,
|
||||
"season": "1",
|
||||
"episode": str(number),
|
||||
"aired": f"2026-01-0{number}",
|
||||
"plot": f"Deterministic episode {number} for player queue coverage.",
|
||||
"lockdata": "true",
|
||||
}.items():
|
||||
ET.SubElement(episode, key).text = value
|
||||
unique_id = ET.SubElement(episode, "uniqueid", {"type": "plezy", "default": "true"})
|
||||
unique_id.text = f"maestro-episode-{number}"
|
||||
ET.indent(episode, space=" ")
|
||||
ET.ElementTree(episode).write(path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _write_music_nfo(path: Path, root_name: str, values: dict[str, str]) -> None:
|
||||
root = ET.Element(root_name)
|
||||
for key, value in values.items():
|
||||
ET.SubElement(root, key).text = value
|
||||
ET.indent(root, space=" ")
|
||||
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _reset_managed_directory(path: Path) -> None:
|
||||
marker = path / _MANAGED_MARKER
|
||||
if not path.exists():
|
||||
path.mkdir(parents=True)
|
||||
marker.write_text("Managed by scripts/maestro_real_jellyfin.py\n", encoding="utf-8")
|
||||
return
|
||||
if not marker.is_file():
|
||||
if any(path.iterdir()):
|
||||
raise ValueError(f"Refusing to clear unmanaged media staging directory: {path}")
|
||||
marker.write_text("Managed by scripts/maestro_real_jellyfin.py\n", encoding="utf-8")
|
||||
return
|
||||
for child in path.iterdir():
|
||||
if child == marker:
|
||||
continue
|
||||
if child.is_dir() and not child.is_symlink():
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
child.unlink()
|
||||
|
||||
|
||||
def _hard_link(source: Path, destination: Path) -> None:
|
||||
try:
|
||||
os.link(source, destination)
|
||||
except OSError as error:
|
||||
raise ValueError(
|
||||
f"Could not hard-link {source} into the staging directory; keep both paths on the same filesystem"
|
||||
) from error
|
||||
|
||||
|
||||
def _codec_digest(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
while chunk := source.read(_DOWNLOAD_CHUNK_SIZE):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _valid_codec_file(path: Path, expected_size: int, expected_sha256: str) -> bool:
|
||||
return path.is_file() and path.stat().st_size == expected_size and _codec_digest(path) == expected_sha256
|
||||
|
||||
|
||||
def download_codec_media(output_dir: Path, base_url: str = DEFAULT_CODEC_BASE_URL) -> list[str]:
|
||||
output = output_dir.expanduser().resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
normalized_base_url = base_url.rstrip("/") + "/"
|
||||
filenames: list[str] = []
|
||||
|
||||
for spec in MEDIA_FIXTURE_SPECS:
|
||||
destination = output / spec.filename
|
||||
filenames.append(spec.filename)
|
||||
if _valid_codec_file(destination, spec.size_bytes, spec.sha256):
|
||||
continue
|
||||
|
||||
partial = destination.with_suffix(f"{destination.suffix}.part")
|
||||
partial.unlink(missing_ok=True)
|
||||
request = urllib.request.Request(
|
||||
urllib.parse.urljoin(normalized_base_url, urllib.parse.quote(spec.filename)),
|
||||
headers={"User-Agent": "plezy-jellyfin-demo-builder"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
raw_length = response.headers.get("Content-Length")
|
||||
if raw_length is not None and int(raw_length) != spec.size_bytes:
|
||||
raise ValueError(
|
||||
f"{spec.filename} download is {raw_length} bytes, expected {spec.size_bytes}"
|
||||
)
|
||||
total = 0
|
||||
digest = hashlib.sha256()
|
||||
with partial.open("wb") as target:
|
||||
while chunk := response.read(_DOWNLOAD_CHUNK_SIZE):
|
||||
total += len(chunk)
|
||||
if total > spec.size_bytes:
|
||||
raise ValueError(f"{spec.filename} download exceeds {spec.size_bytes} bytes")
|
||||
digest.update(chunk)
|
||||
target.write(chunk)
|
||||
if total != spec.size_bytes:
|
||||
raise ValueError(f"{spec.filename} download is {total} bytes, expected {spec.size_bytes}")
|
||||
if digest.hexdigest() != spec.sha256:
|
||||
raise ValueError(f"{spec.filename} download failed SHA-256 verification")
|
||||
partial.replace(destination)
|
||||
except (OSError, ValueError, urllib.error.URLError):
|
||||
partial.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return filenames
|
||||
|
||||
|
||||
def prepare_media(output_dir: Path, codec_source_dir: Path | None, include_codecs: bool) -> list[str]:
|
||||
output = output_dir.expanduser().resolve()
|
||||
_reset_managed_directory(output)
|
||||
|
||||
base_dir = output / "movies" / "maestro-movie"
|
||||
base_dir.mkdir(parents=True)
|
||||
(base_dir / f"{BASE_TITLE}.mp4").write_bytes(_VIDEO)
|
||||
_write_nfo(
|
||||
base_dir / f"{BASE_TITLE}.nfo",
|
||||
item_id="maestro-movie",
|
||||
title=BASE_TITLE,
|
||||
overview=BASE_OVERVIEW,
|
||||
genre="Test",
|
||||
)
|
||||
titles = [BASE_TITLE]
|
||||
|
||||
for alphabet_title in ALPHABET_TITLES:
|
||||
for copy in range(1, 5):
|
||||
title = alphabet_title if copy == 1 else f"{alphabet_title} {copy}"
|
||||
item_id = f"alpha-{title.lower().replace(' ', '-')}"
|
||||
item_dir = output / "movies" / item_id
|
||||
item_dir.mkdir()
|
||||
media_path = item_dir / f"{title}.mp4"
|
||||
media_path.write_bytes(_VIDEO)
|
||||
_write_nfo(
|
||||
media_path.with_suffix(".nfo"),
|
||||
item_id=item_id,
|
||||
title=title,
|
||||
overview="A deterministic title for TV alphabet focus coverage.",
|
||||
genre="E2E Alphabet",
|
||||
)
|
||||
titles.append(title)
|
||||
|
||||
guest_dir = output / "guest-movies" / "guest-galaxy"
|
||||
guest_dir.mkdir(parents=True)
|
||||
guest_media = guest_dir / f"{GUEST_TITLE}.mp4"
|
||||
guest_media.write_bytes(_VIDEO)
|
||||
_write_nfo(
|
||||
guest_media.with_suffix(".nfo"),
|
||||
item_id="guest-galaxy",
|
||||
title=GUEST_TITLE,
|
||||
overview="Content visible only to the Maestro Guest profile.",
|
||||
genre="E2E Guest",
|
||||
)
|
||||
|
||||
show_dir = output / "shows" / SHOW_TITLE
|
||||
season_dir = show_dir / "Season 01"
|
||||
season_dir.mkdir(parents=True)
|
||||
_write_show_nfo(show_dir / "tvshow.nfo")
|
||||
for number, title in enumerate(EPISODE_TITLES, start=1):
|
||||
episode_path = season_dir / f"{SHOW_TITLE} S01E{number:02d} - {title}.mp4"
|
||||
episode_path.write_bytes(_VIDEO)
|
||||
_write_episode_nfo(episode_path.with_suffix(".nfo"), number)
|
||||
|
||||
album_dir = output / "music" / MUSIC_ARTIST / MUSIC_ALBUM
|
||||
album_dir.mkdir(parents=True)
|
||||
(album_dir / f"{MUSIC_TRACK}.wav").write_bytes(_AUDIO)
|
||||
_write_music_nfo(
|
||||
album_dir.parent / "artist.nfo",
|
||||
"artist",
|
||||
{"name": MUSIC_ARTIST, "sortname": MUSIC_ARTIST, "overview": "Deterministic E2E music artist."},
|
||||
)
|
||||
_write_music_nfo(
|
||||
album_dir / "album.nfo",
|
||||
"album",
|
||||
{
|
||||
"title": MUSIC_ALBUM,
|
||||
"artist": MUSIC_ARTIST,
|
||||
"albumartist": MUSIC_ARTIST,
|
||||
"year": "2026",
|
||||
"review": "Deterministic E2E music album.",
|
||||
},
|
||||
)
|
||||
|
||||
if not include_codecs:
|
||||
return titles
|
||||
if codec_source_dir is None:
|
||||
raise ValueError("--codec-source-dir is required with --include-codecs")
|
||||
|
||||
source_dir = codec_source_dir.expanduser().resolve()
|
||||
missing = [spec.filename for spec in MEDIA_FIXTURE_SPECS if not (source_dir / spec.filename).is_file()]
|
||||
if missing:
|
||||
raise ValueError(f"Codec fixture directory is missing: {', '.join(missing)}")
|
||||
|
||||
for spec in MEDIA_FIXTURE_SPECS:
|
||||
item_dir = output / "movies" / spec.id
|
||||
item_dir.mkdir()
|
||||
media_path = item_dir / f"{spec.title}.mkv"
|
||||
_hard_link(source_dir / spec.filename, media_path)
|
||||
_write_nfo(
|
||||
media_path.with_suffix(".nfo"),
|
||||
item_id=spec.id,
|
||||
title=spec.title,
|
||||
overview=spec.overview,
|
||||
genre="E2E Codec",
|
||||
)
|
||||
titles.append(spec.title)
|
||||
return titles
|
||||
|
||||
|
||||
class JellyfinApi:
|
||||
def __init__(self, base_url: str) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
payload: Any | None = None,
|
||||
token: str | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: float = 30,
|
||||
) -> tuple[int, bytes]:
|
||||
request_headers = {"Accept": "application/json", **(headers or {})}
|
||||
data: bytes | None = None
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request_headers["Content-Type"] = "application/json"
|
||||
elif method == "POST":
|
||||
data = b""
|
||||
if token is not None:
|
||||
request_headers["X-Emby-Token"] = token
|
||||
request = urllib.request.Request(self.base_url + path, data=data, method=method, headers=request_headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.status, response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, error.read()
|
||||
|
||||
def json(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
status, body = self.request(method, path, **kwargs)
|
||||
if status < 200 or status >= 300:
|
||||
detail = body.decode("utf-8", errors="replace")[:500]
|
||||
raise RuntimeError(f"Jellyfin {method} {path} returned HTTP {status}: {detail}")
|
||||
return json.loads(body) if body else None
|
||||
|
||||
|
||||
def _wait_until(deadline: float, description: str, operation: Any) -> Any:
|
||||
last_error: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
result = operation()
|
||||
if result is not None:
|
||||
return result
|
||||
except (OSError, RuntimeError, urllib.error.URLError) as error:
|
||||
last_error = error
|
||||
time.sleep(1)
|
||||
suffix = f": {last_error}" if last_error is not None else ""
|
||||
raise TimeoutError(f"Timed out waiting for {description}{suffix}")
|
||||
|
||||
|
||||
def bootstrap_server(
|
||||
base_url: str,
|
||||
expected_titles: set[str],
|
||||
timeout_seconds: int,
|
||||
expected_music_titles: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
api = JellyfinApi(base_url)
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
|
||||
def public_info() -> dict[str, Any] | None:
|
||||
status, body = api.request("GET", "/System/Info/Public", timeout=3)
|
||||
if status != 200:
|
||||
return None
|
||||
return json.loads(body)
|
||||
|
||||
info = _wait_until(deadline, "Jellyfin startup", public_info)
|
||||
version = info.get("Version") or info.get("version")
|
||||
if version != "10.11.11":
|
||||
raise RuntimeError(f"Expected Jellyfin 10.11.11, got {version}")
|
||||
|
||||
startup_complete = info.get("StartupWizardCompleted", info.get("startupWizardCompleted", False))
|
||||
if not startup_complete:
|
||||
|
||||
def configure_startup() -> bool | None:
|
||||
status, body = api.request(
|
||||
"POST",
|
||||
"/Startup/Configuration",
|
||||
payload={
|
||||
"UICulture": "en-US",
|
||||
"MetadataCountryCode": "US",
|
||||
"PreferredMetadataLanguage": "en",
|
||||
},
|
||||
)
|
||||
if status == 204:
|
||||
return True
|
||||
if status == 503:
|
||||
return None
|
||||
detail = body.decode("utf-8", errors="replace")[:500]
|
||||
raise RuntimeError(f"Jellyfin startup configuration returned HTTP {status}: {detail}")
|
||||
|
||||
_wait_until(deadline, "Jellyfin startup API", configure_startup)
|
||||
|
||||
def startup_user() -> dict[str, Any] | None:
|
||||
status, body = api.request("GET", "/Startup/User", timeout=3)
|
||||
if status != 200:
|
||||
return None
|
||||
return json.loads(body)
|
||||
|
||||
_wait_until(deadline, "Jellyfin startup user", startup_user)
|
||||
api.json("POST", "/Startup/User", payload={"Name": USERNAME, "Password": PASSWORD})
|
||||
api.json("POST", "/Startup/Complete")
|
||||
|
||||
authorization = (
|
||||
'MediaBrowser Client="Plezy E2E Bootstrap", Device="Host", '
|
||||
'DeviceId="plezy-e2e-bootstrap", Version="1.0"'
|
||||
)
|
||||
|
||||
def authenticate(username: str, password: str) -> dict[str, Any] | None:
|
||||
status, body = api.request(
|
||||
"POST",
|
||||
"/Users/AuthenticateByName",
|
||||
payload={"Username": username, "Pw": password},
|
||||
headers={"Authorization": authorization},
|
||||
timeout=5,
|
||||
)
|
||||
if status != 200:
|
||||
return None
|
||||
return json.loads(body)
|
||||
|
||||
authentication = _wait_until(deadline, "Jellyfin authentication", lambda: authenticate(USERNAME, PASSWORD))
|
||||
token = authentication["AccessToken"]
|
||||
user_id = authentication["User"]["Id"]
|
||||
|
||||
configuration = api.json("GET", "/System/Configuration", token=token)
|
||||
if configuration.get("ServerName") != SERVER_NAME:
|
||||
configuration["ServerName"] = SERVER_NAME
|
||||
api.json("POST", "/System/Configuration", payload=configuration, token=token)
|
||||
|
||||
required_folders = (
|
||||
("Maestro Movies", "movies", "/media/movies"),
|
||||
("Guest Movies", "movies", "/media/guest-movies"),
|
||||
("Maestro Shows", "tvshows", "/media/shows"),
|
||||
("Maestro Music", "music", "/media/music"),
|
||||
)
|
||||
virtual_folders = api.json("GET", "/Library/VirtualFolders", token=token)
|
||||
existing_folder_names = {folder.get("Name") for folder in virtual_folders}
|
||||
for name, collection_type, path in required_folders:
|
||||
if name in existing_folder_names:
|
||||
continue
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"name": name,
|
||||
"collectionType": collection_type,
|
||||
"paths": path,
|
||||
"refreshLibrary": "false",
|
||||
}
|
||||
)
|
||||
api.json("POST", f"/Library/VirtualFolders?{query}", token=token)
|
||||
|
||||
users = api.json("GET", "/Users", token=token)
|
||||
main_user = next(user for user in users if user["Id"] == user_id)
|
||||
guest_user = next((user for user in users if user.get("Name") == GUEST_USERNAME), None)
|
||||
if guest_user is None:
|
||||
guest_user = api.json(
|
||||
"POST",
|
||||
"/Users/New",
|
||||
payload={"Name": GUEST_USERNAME, "Password": GUEST_PASSWORD},
|
||||
token=token,
|
||||
)
|
||||
if not guest_user.get("HasPassword", guest_user.get("HasConfiguredPassword", False)):
|
||||
api.json(
|
||||
"POST",
|
||||
f"/Users/{guest_user['Id']}/Password",
|
||||
payload={"CurrentPw": "", "NewPw": GUEST_PASSWORD},
|
||||
token=token,
|
||||
)
|
||||
|
||||
unrestricted_policy = dict(main_user["Policy"])
|
||||
unrestricted_policy["EnableAllFolders"] = True
|
||||
unrestricted_policy["EnabledFolders"] = []
|
||||
api.json("POST", f"/Users/{user_id}/Policy", payload=unrestricted_policy, token=token)
|
||||
|
||||
views = api.json("GET", f"/Users/{user_id}/Views", token=token).get("Items", [])
|
||||
views_by_name = {view.get("Name"): view.get("Id") for view in views}
|
||||
missing_views = [name for name, _, _ in required_folders if not views_by_name.get(name)]
|
||||
if missing_views:
|
||||
raise RuntimeError(f"Jellyfin did not create library views: {', '.join(missing_views)}")
|
||||
|
||||
main_policy = dict(main_user["Policy"])
|
||||
main_policy["EnableAllFolders"] = False
|
||||
main_policy["EnabledFolders"] = [
|
||||
views_by_name["Maestro Movies"],
|
||||
views_by_name["Maestro Shows"],
|
||||
views_by_name["Maestro Music"],
|
||||
]
|
||||
api.json("POST", f"/Users/{user_id}/Policy", payload=main_policy, token=token)
|
||||
|
||||
guest_policy = dict(guest_user["Policy"])
|
||||
guest_policy["EnableAllFolders"] = False
|
||||
guest_policy["EnabledFolders"] = [views_by_name["Guest Movies"]]
|
||||
api.json("POST", f"/Users/{guest_user['Id']}/Policy", payload=guest_policy, token=token)
|
||||
|
||||
api.json("POST", "/Library/Refresh", token=token)
|
||||
|
||||
def scanned_items() -> list[dict[str, Any]] | None:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"Recursive": "true",
|
||||
"IncludeItemTypes": "Movie",
|
||||
"Fields": "MediaSources,MediaStreams",
|
||||
"Limit": "500",
|
||||
}
|
||||
)
|
||||
result = api.json("GET", f"/Users/{user_id}/Items?{query}", token=token)
|
||||
items = result.get("Items", [])
|
||||
by_title = {item.get("Name"): item for item in items}
|
||||
if not expected_titles.issubset(by_title):
|
||||
return None
|
||||
if any(not by_title[title].get("MediaSources") for title in expected_titles):
|
||||
return None
|
||||
if GUEST_TITLE in by_title:
|
||||
raise RuntimeError("Main Jellyfin user can see the guest-only library")
|
||||
return items
|
||||
|
||||
items = _wait_until(deadline, "Jellyfin media scan", scanned_items)
|
||||
required_music = expected_music_titles or {MUSIC_TRACK}
|
||||
|
||||
def scanned_music() -> list[dict[str, Any]] | None:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"Recursive": "true",
|
||||
"IncludeItemTypes": "Audio",
|
||||
"Fields": "MediaSources,MediaStreams,Album,AlbumId,AlbumArtist,AlbumArtists",
|
||||
"Limit": "100",
|
||||
}
|
||||
)
|
||||
result = api.json("GET", f"/Users/{user_id}/Items?{query}", token=token)
|
||||
music_items = result.get("Items", [])
|
||||
by_title = {item.get("Name"): item for item in music_items}
|
||||
if not required_music.issubset(by_title):
|
||||
return None
|
||||
if any(not by_title[title].get("MediaSources") for title in required_music):
|
||||
return None
|
||||
return music_items
|
||||
|
||||
music_items = _wait_until(deadline, "Jellyfin music scan", scanned_music)
|
||||
|
||||
def scanned_artists() -> list[dict[str, Any]] | None:
|
||||
query = urllib.parse.urlencode({"UserId": user_id, "Limit": "100"})
|
||||
result = api.json("GET", f"/Artists/AlbumArtists?{query}", token=token)
|
||||
artists = result.get("Items", [])
|
||||
if MUSIC_ARTIST not in {artist.get("Name") for artist in artists}:
|
||||
return None
|
||||
return artists
|
||||
|
||||
artist_items = _wait_until(deadline, "Jellyfin music artist scan", scanned_artists)
|
||||
|
||||
def scanned_episodes() -> list[dict[str, Any]] | None:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"Recursive": "true",
|
||||
"IncludeItemTypes": "Episode",
|
||||
"Fields": "MediaSources,MediaStreams,SeriesId,ParentId,IndexNumber",
|
||||
"Limit": "100",
|
||||
}
|
||||
)
|
||||
result = api.json("GET", f"/Users/{user_id}/Items?{query}", token=token)
|
||||
episodes = result.get("Items", [])
|
||||
by_title = {item.get("Name"): item for item in episodes}
|
||||
if not set(EPISODE_TITLES).issubset(by_title):
|
||||
return None
|
||||
if any(not by_title[title].get("MediaSources") for title in EPISODE_TITLES):
|
||||
return None
|
||||
return episodes
|
||||
|
||||
episode_items = _wait_until(deadline, "Jellyfin episode scan", scanned_episodes)
|
||||
guest_authentication = _wait_until(
|
||||
deadline,
|
||||
"Jellyfin guest authentication",
|
||||
lambda: authenticate(GUEST_USERNAME, GUEST_PASSWORD),
|
||||
)
|
||||
guest_token = guest_authentication["AccessToken"]
|
||||
|
||||
def scanned_guest_items() -> list[dict[str, Any]] | None:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"Recursive": "true",
|
||||
"IncludeItemTypes": "Movie",
|
||||
"Fields": "MediaSources,MediaStreams",
|
||||
"Limit": "100",
|
||||
}
|
||||
)
|
||||
result = api.json("GET", f"/Users/{guest_user['Id']}/Items?{query}", token=guest_token)
|
||||
guest_items = result.get("Items", [])
|
||||
by_title = {item.get("Name"): item for item in guest_items}
|
||||
if GUEST_TITLE not in by_title or not by_title[GUEST_TITLE].get("MediaSources"):
|
||||
return None
|
||||
if BASE_TITLE in by_title:
|
||||
raise RuntimeError("Guest Jellyfin user can see the main library")
|
||||
return guest_items
|
||||
|
||||
guest_items = _wait_until(deadline, "Jellyfin guest media scan", scanned_guest_items)
|
||||
return {
|
||||
"server": SERVER_NAME,
|
||||
"version": "10.11.11",
|
||||
"userId": user_id,
|
||||
"guestUserId": guest_user["Id"],
|
||||
"titles": sorted(item["Name"] for item in items if item.get("Name") in expected_titles),
|
||||
"musicTitles": sorted(item["Name"] for item in music_items if item.get("Name") in required_music),
|
||||
"artistTitles": sorted(item["Name"] for item in artist_items if item.get("Name") == MUSIC_ARTIST),
|
||||
"episodeTitles": sorted(item["Name"] for item in episode_items if item.get("Name") in EPISODE_TITLES),
|
||||
"guestTitles": sorted(item["Name"] for item in guest_items if item.get("Name") == GUEST_TITLE),
|
||||
}
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
prepare = subparsers.add_parser("prepare", help="Create the deterministic media staging tree")
|
||||
prepare.add_argument("--output-dir", type=Path, required=True)
|
||||
prepare.add_argument("--codec-source-dir", type=Path)
|
||||
prepare.add_argument("--include-codecs", action="store_true")
|
||||
|
||||
download = subparsers.add_parser("download-codecs", help="Download and verify the hosted codec fixtures")
|
||||
download.add_argument("--output-dir", type=Path, required=True)
|
||||
download.add_argument(
|
||||
"--base-url",
|
||||
default=os.environ.get("PLEZY_DEMO_MEDIA_BASE_URL", DEFAULT_CODEC_BASE_URL),
|
||||
)
|
||||
|
||||
bootstrap = subparsers.add_parser("bootstrap", help="Configure and verify a fresh Jellyfin server")
|
||||
bootstrap.add_argument("--url", required=True)
|
||||
bootstrap.add_argument("--expected-title", action="append", default=[])
|
||||
bootstrap.add_argument("--expected-music-title", action="append", default=[])
|
||||
bootstrap.add_argument("--timeout", type=int, default=600)
|
||||
bootstrap.add_argument("--include-codecs", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _build_parser().parse_args()
|
||||
try:
|
||||
if args.command == "download-codecs":
|
||||
filenames = download_codec_media(args.output_dir, args.base_url)
|
||||
print(json.dumps({"codecDir": str(args.output_dir.resolve()), "files": filenames}, sort_keys=True))
|
||||
elif args.command == "prepare":
|
||||
titles = prepare_media(args.output_dir, args.codec_source_dir, args.include_codecs)
|
||||
print(json.dumps({"mediaDir": str(args.output_dir.resolve()), "titles": titles}, sort_keys=True))
|
||||
else:
|
||||
expected = {BASE_TITLE, *args.expected_title}
|
||||
expected.update(
|
||||
title if copy == 1 else f"{title} {copy}"
|
||||
for title in ALPHABET_TITLES
|
||||
for copy in range(1, 5)
|
||||
)
|
||||
if args.include_codecs:
|
||||
expected.update(spec.title for spec in MEDIA_FIXTURE_SPECS)
|
||||
expected_music = set(args.expected_music_title) or {MUSIC_TRACK}
|
||||
result = bootstrap_server(args.url, expected, args.timeout, expected_music)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except (OSError, RuntimeError, TimeoutError, ValueError) as error:
|
||||
print(f"Real Jellyfin E2E setup failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Remux local codec fixtures into long-running copies for interactive E2E flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from maestro_fixtures import MEDIA_FIXTURE_SPECS
|
||||
|
||||
|
||||
def _duration(path: Path) -> float:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
payload = json.loads(result.stdout)
|
||||
try:
|
||||
duration = float(payload["format"]["duration"])
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise ValueError(f"ffprobe returned no duration for {path}") from error
|
||||
if duration <= 0:
|
||||
raise ValueError(f"media duration must be positive: {path}")
|
||||
return duration
|
||||
|
||||
|
||||
def prepare_media(
|
||||
source_directory: Path,
|
||||
output_directory: Path,
|
||||
*,
|
||||
duration: float,
|
||||
extend_filenames: frozenset[str] | None = None,
|
||||
) -> None:
|
||||
if duration <= 0:
|
||||
raise ValueError("target duration must be positive")
|
||||
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
|
||||
raise RuntimeError("ffmpeg and ffprobe are required for the local codec suite")
|
||||
|
||||
source_directory = source_directory.expanduser().resolve()
|
||||
output_directory = output_directory.expanduser().resolve()
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for spec in MEDIA_FIXTURE_SPECS:
|
||||
source = source_directory / spec.filename
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"missing codec fixture: {source}")
|
||||
output = output_directory / spec.filename
|
||||
if extend_filenames is not None and spec.filename not in extend_filenames:
|
||||
shutil.copy2(source, output)
|
||||
continue
|
||||
if output.is_file() and output.stat().st_mtime_ns >= source.stat().st_mtime_ns:
|
||||
try:
|
||||
if _duration(output) >= duration:
|
||||
print(f"Reusing {output.name}")
|
||||
continue
|
||||
except (subprocess.CalledProcessError, ValueError):
|
||||
pass
|
||||
|
||||
source_duration = _duration(source)
|
||||
remux_duration = duration + 5
|
||||
repeat_count = max(0, math.ceil(remux_duration / source_duration) - 1)
|
||||
temporary = output.with_name(f"{output.stem}.tmp{output.suffix}")
|
||||
temporary.unlink(missing_ok=True)
|
||||
print(f"Preparing {output.name} ({source_duration:.1f}s -> {duration:.1f}s)")
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-v",
|
||||
"error",
|
||||
"-stream_loop",
|
||||
str(repeat_count),
|
||||
"-i",
|
||||
str(source),
|
||||
"-map",
|
||||
"0",
|
||||
"-c",
|
||||
"copy",
|
||||
"-t",
|
||||
str(remux_duration),
|
||||
str(temporary),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
if _duration(temporary) < duration:
|
||||
raise ValueError(f"prepared fixture is shorter than {duration}s: {temporary}")
|
||||
temporary.replace(output)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path, help="Directory containing the six untracked codec fixtures")
|
||||
parser.add_argument("output", type=Path, help="Directory for derived long-running fixtures")
|
||||
parser.add_argument("--duration", type=float, default=300, help="Minimum output duration in seconds")
|
||||
parser.add_argument(
|
||||
"--extend",
|
||||
action="append",
|
||||
choices=[spec.filename for spec in MEDIA_FIXTURE_SPECS],
|
||||
dest="extend_filenames",
|
||||
help="Only extend this fixture; may be repeated. By default every fixture is extended.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
||||
try:
|
||||
extend_filenames = frozenset(args.extend_filenames) if args.extend_filenames is not None else None
|
||||
prepare_media(
|
||||
args.source,
|
||||
args.output,
|
||||
duration=args.duration,
|
||||
extend_filenames=extend_filenames,
|
||||
)
|
||||
except (FileNotFoundError, RuntimeError, ValueError, subprocess.CalledProcessError) as error:
|
||||
print(f"Codec fixture preparation failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+739
@@ -0,0 +1,739 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the Jellyfin fixture and run Plezy's Android Maestro suites."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Mapping, Optional, Sequence, TextIO
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
APP_ID = "com.edde746.plezy"
|
||||
FAULTS = ("music-failure", "recovery")
|
||||
|
||||
|
||||
class RunnerError(RuntimeError):
|
||||
"""A user-actionable runner failure."""
|
||||
|
||||
class RunnerSignal(Exception):
|
||||
def __init__(self, signum: int) -> None:
|
||||
super().__init__(f"Interrupted by signal {signum}")
|
||||
self.exit_status = 128 + signum
|
||||
|
||||
|
||||
def _raise_signal(signum: int, _frame: object) -> None:
|
||||
raise RunnerSignal(signum)
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SuitePreset:
|
||||
flow_target: str
|
||||
maestro_config: Optional[str]
|
||||
jellyfin_log: str
|
||||
diagnostics_dir: str
|
||||
use_adb_reverse: bool = False
|
||||
uninstall_before_install: bool = False
|
||||
|
||||
|
||||
SUITES = {
|
||||
"basic": SuitePreset(
|
||||
flow_target=".maestro",
|
||||
maestro_config=None,
|
||||
jellyfin_log="build/maestro/jellyfin.log",
|
||||
diagnostics_dir="build/maestro/diagnostics",
|
||||
),
|
||||
"catalog": SuitePreset(
|
||||
flow_target=".maestro/real_flows",
|
||||
maestro_config=None,
|
||||
jellyfin_log="build/maestro-real-jellyfin/jellyfin.log",
|
||||
diagnostics_dir="build/maestro-real-jellyfin/diagnostics",
|
||||
uninstall_before_install=True,
|
||||
),
|
||||
"media": SuitePreset(
|
||||
flow_target=".maestro/media_flows",
|
||||
maestro_config=".maestro/media-config.yaml",
|
||||
jellyfin_log="build/maestro-media/jellyfin.log",
|
||||
diagnostics_dir="build/maestro/diagnostics",
|
||||
use_adb_reverse=True,
|
||||
uninstall_before_install=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunnerConfig:
|
||||
command: str
|
||||
jellyfin_host: str
|
||||
jellyfin_port: int
|
||||
proxy_port: int
|
||||
jellyfin_image: str
|
||||
skip_jellyfin: bool
|
||||
skip_jellyfin_build: bool
|
||||
skip_build: bool
|
||||
jellyfin_fault: Optional[str]
|
||||
use_adb_reverse: bool
|
||||
device_id: Optional[str]
|
||||
apk_path: Path
|
||||
flow_target: Path
|
||||
maestro_config: Optional[Path]
|
||||
uninstall_before_install: bool
|
||||
diagnostics_dir: Path
|
||||
jellyfin_log: Path
|
||||
proxy_log: Path
|
||||
proxy_journal: Path
|
||||
host_jellyfin_url: str
|
||||
jellyfin_url: Optional[str]
|
||||
jellyfin_build_attempts: int
|
||||
|
||||
|
||||
def _positive_int(value: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError as error:
|
||||
raise argparse.ArgumentTypeError("must be a positive integer") from error
|
||||
if parsed < 1:
|
||||
raise argparse.ArgumentTypeError("must be a positive integer")
|
||||
return parsed
|
||||
|
||||
|
||||
def _add_bool_argument(parser: argparse.ArgumentParser, name: str, *, destination: str) -> None:
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(f"--{name}", dest=destination, action="store_true")
|
||||
group.add_argument(f"--no-{name}", dest=destination, action="store_false")
|
||||
parser.set_defaults(**{destination: None})
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=(*SUITES, "build-image"),
|
||||
nargs="?",
|
||||
default="basic",
|
||||
help="suite to run, or build-image to only build the Jellyfin fixture",
|
||||
)
|
||||
parser.add_argument("--device", dest="device_id")
|
||||
parser.add_argument("--apk", dest="apk_path")
|
||||
parser.add_argument("--flow", dest="flow_target")
|
||||
parser.add_argument("--config", dest="maestro_config")
|
||||
parser.add_argument("--fault", choices=FAULTS, dest="jellyfin_fault")
|
||||
parser.add_argument("--jellyfin-host")
|
||||
parser.add_argument("--jellyfin-port", type=int)
|
||||
parser.add_argument("--proxy-port", type=int)
|
||||
parser.add_argument("--jellyfin-image")
|
||||
parser.add_argument("--diagnostics-dir")
|
||||
parser.add_argument("--jellyfin-log")
|
||||
parser.add_argument("--proxy-log")
|
||||
parser.add_argument("--proxy-journal")
|
||||
parser.add_argument("--host-jellyfin-url")
|
||||
parser.add_argument("--jellyfin-url")
|
||||
parser.add_argument("--jellyfin-build-attempts", type=_positive_int)
|
||||
_add_bool_argument(parser, "skip-jellyfin", destination="skip_jellyfin")
|
||||
_add_bool_argument(parser, "skip-jellyfin-build", destination="skip_jellyfin_build")
|
||||
_add_bool_argument(parser, "skip-build", destination="skip_build")
|
||||
_add_bool_argument(parser, "adb-reverse", destination="use_adb_reverse")
|
||||
_add_bool_argument(parser, "uninstall-before-install", destination="uninstall_before_install")
|
||||
return parser
|
||||
|
||||
|
||||
def _env_bool(environment: Mapping[str, str], name: str, default: bool) -> bool:
|
||||
value = environment.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
raise RunnerError(f"{name} must be one of 1, 0, true, false, yes, no, on, or off")
|
||||
|
||||
|
||||
def _option(cli_value: object, environment: Mapping[str, str], name: str, default: object) -> object:
|
||||
if cli_value is not None:
|
||||
return cli_value
|
||||
return environment.get(name, default)
|
||||
def _int_option(
|
||||
cli_value: Optional[int],
|
||||
environment: Mapping[str, str],
|
||||
name: str,
|
||||
default: int,
|
||||
) -> int:
|
||||
value = _option(cli_value, environment, name, default)
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise RunnerError(f"{name} must be a positive integer") from error
|
||||
if parsed < 1:
|
||||
raise RunnerError(f"{name} must be a positive integer")
|
||||
return parsed
|
||||
|
||||
|
||||
|
||||
|
||||
def _bool_option(
|
||||
cli_value: Optional[bool],
|
||||
environment: Mapping[str, str],
|
||||
name: str,
|
||||
default: bool,
|
||||
) -> bool:
|
||||
if cli_value is not None:
|
||||
return cli_value
|
||||
return _env_bool(environment, name, default)
|
||||
|
||||
|
||||
def _root_path(value: object) -> Path:
|
||||
path = Path(str(value))
|
||||
return path if path.is_absolute() else ROOT_DIR / path
|
||||
|
||||
|
||||
def parse_config(argv: Optional[Sequence[str]] = None, environment: Optional[Mapping[str, str]] = None) -> RunnerConfig:
|
||||
args = _parser().parse_args(argv)
|
||||
env = os.environ if environment is None else environment
|
||||
preset = SUITES.get(args.command, SUITES["basic"])
|
||||
|
||||
jellyfin_host = str(_option(args.jellyfin_host, env, "MAESTRO_JELLYFIN_HOST", "127.0.0.1"))
|
||||
jellyfin_port = _int_option(args.jellyfin_port, env, "MAESTRO_JELLYFIN_PORT", 8096)
|
||||
proxy_port = _int_option(args.proxy_port, env, "MAESTRO_JELLYFIN_PROXY_PORT", jellyfin_port + 1)
|
||||
config_value = _option(args.maestro_config, env, "MAESTRO_CONFIG", preset.maestro_config or "")
|
||||
fault_value = _option(args.jellyfin_fault, env, "MAESTRO_JELLYFIN_FAULT", "")
|
||||
fault = str(fault_value) or None
|
||||
if fault is not None and fault not in FAULTS:
|
||||
raise RunnerError(f"Unsupported MAESTRO_JELLYFIN_FAULT: {fault}")
|
||||
|
||||
build_attempts = _int_option(
|
||||
args.jellyfin_build_attempts,
|
||||
env,
|
||||
"MAESTRO_JELLYFIN_BUILD_ATTEMPTS",
|
||||
2,
|
||||
)
|
||||
|
||||
return RunnerConfig(
|
||||
command=args.command,
|
||||
jellyfin_host=jellyfin_host,
|
||||
jellyfin_port=jellyfin_port,
|
||||
proxy_port=proxy_port,
|
||||
jellyfin_image=str(
|
||||
_option(args.jellyfin_image, env, "MAESTRO_JELLYFIN_IMAGE", "plezy-jellyfin-demo:local")
|
||||
),
|
||||
skip_jellyfin=_bool_option(args.skip_jellyfin, env, "MAESTRO_SKIP_JELLYFIN", False),
|
||||
skip_jellyfin_build=_bool_option(
|
||||
args.skip_jellyfin_build,
|
||||
env,
|
||||
"MAESTRO_SKIP_JELLYFIN_BUILD",
|
||||
False,
|
||||
),
|
||||
skip_build=_bool_option(args.skip_build, env, "MAESTRO_SKIP_BUILD", False),
|
||||
jellyfin_fault=fault,
|
||||
use_adb_reverse=_bool_option(
|
||||
args.use_adb_reverse,
|
||||
env,
|
||||
"MAESTRO_USE_ADB_REVERSE",
|
||||
preset.use_adb_reverse,
|
||||
),
|
||||
device_id=str(_option(args.device_id, env, "MAESTRO_DEVICE_ID", "")) or None,
|
||||
apk_path=_root_path(
|
||||
_option(
|
||||
args.apk_path,
|
||||
env,
|
||||
"MAESTRO_APK_PATH",
|
||||
"build/app/outputs/flutter-apk/app-debug.apk",
|
||||
)
|
||||
),
|
||||
flow_target=_root_path(
|
||||
_option(args.flow_target, env, "MAESTRO_FLOW_TARGET", preset.flow_target)
|
||||
),
|
||||
maestro_config=_root_path(config_value) if config_value else None,
|
||||
uninstall_before_install=_bool_option(
|
||||
args.uninstall_before_install,
|
||||
env,
|
||||
"MAESTRO_UNINSTALL_BEFORE_INSTALL",
|
||||
preset.uninstall_before_install,
|
||||
),
|
||||
diagnostics_dir=_root_path(
|
||||
_option(args.diagnostics_dir, env, "MAESTRO_DIAGNOSTICS_DIR", preset.diagnostics_dir)
|
||||
),
|
||||
jellyfin_log=_root_path(
|
||||
_option(args.jellyfin_log, env, "MAESTRO_JELLYFIN_LOG", preset.jellyfin_log)
|
||||
),
|
||||
proxy_log=_root_path(
|
||||
_option(
|
||||
args.proxy_log,
|
||||
env,
|
||||
"MAESTRO_JELLYFIN_PROXY_LOG",
|
||||
"build/maestro/jellyfin-proxy.log",
|
||||
)
|
||||
),
|
||||
proxy_journal=_root_path(
|
||||
_option(
|
||||
args.proxy_journal,
|
||||
env,
|
||||
"MAESTRO_JELLYFIN_PROXY_JOURNAL",
|
||||
"build/maestro/jellyfin-proxy-journal.jsonl",
|
||||
)
|
||||
),
|
||||
host_jellyfin_url=str(
|
||||
_option(
|
||||
args.host_jellyfin_url,
|
||||
env,
|
||||
"MAESTRO_JELLYFIN_HOST_URL",
|
||||
f"http://{jellyfin_host}:{jellyfin_port}",
|
||||
)
|
||||
),
|
||||
jellyfin_url=str(_option(args.jellyfin_url, env, "MAESTRO_JELLYFIN_URL", "")) or None,
|
||||
jellyfin_build_attempts=build_attempts,
|
||||
)
|
||||
|
||||
|
||||
def _format_command(command: Sequence[object]) -> str:
|
||||
values = [str(value) for value in command]
|
||||
if os.name == "nt":
|
||||
return subprocess.list2cmdline(values)
|
||||
return shlex.join(values)
|
||||
|
||||
|
||||
def _run_checked(command: Sequence[object], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
values = [str(value) for value in command]
|
||||
print(f"+ {_format_command(values)}", flush=True)
|
||||
return subprocess.run(values, cwd=ROOT_DIR, check=True, text=True, **kwargs)
|
||||
|
||||
|
||||
def _require_commands(names: Sequence[str]) -> None:
|
||||
missing = [name for name in names if shutil.which(name) is None]
|
||||
if missing:
|
||||
raise RunnerError(f"Required command not found: {', '.join(missing)}")
|
||||
|
||||
|
||||
def build_jellyfin_image(config: RunnerConfig) -> None:
|
||||
_require_commands(("docker",))
|
||||
command = (
|
||||
"docker",
|
||||
"build",
|
||||
"--file",
|
||||
ROOT_DIR / ".maestro/jellyfin-demo/Dockerfile",
|
||||
"--tag",
|
||||
config.jellyfin_image,
|
||||
ROOT_DIR,
|
||||
)
|
||||
for attempt in range(1, config.jellyfin_build_attempts + 1):
|
||||
try:
|
||||
_run_checked(command)
|
||||
return
|
||||
except subprocess.CalledProcessError:
|
||||
if attempt == config.jellyfin_build_attempts:
|
||||
raise RunnerError(
|
||||
f"Jellyfin image build failed after {config.jellyfin_build_attempts} attempts"
|
||||
)
|
||||
print(f"Jellyfin image build attempt {attempt} failed; retrying", file=sys.stderr)
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
class MaestroRunner:
|
||||
def __init__(self, config: RunnerConfig) -> None:
|
||||
self.config = config
|
||||
self.device_id = config.device_id
|
||||
self.container_name: Optional[str] = None
|
||||
self.proxy_process: Optional[subprocess.Popen[str]] = None
|
||||
self.proxy_output: Optional[TextIO] = None
|
||||
self.reverse_configured = False
|
||||
self.device_service_port = config.jellyfin_port
|
||||
self.host_jellyfin_url = config.host_jellyfin_url
|
||||
self.device_settings: dict[tuple[str, str], str] = {}
|
||||
|
||||
@property
|
||||
def adb_prefix(self) -> list[str]:
|
||||
prefix = ["adb"]
|
||||
if self.device_id:
|
||||
prefix.extend(("-s", self.device_id))
|
||||
return prefix
|
||||
|
||||
def run(self) -> None:
|
||||
required = ["adb", "maestro"]
|
||||
if not self.config.skip_build:
|
||||
required.append("flutter")
|
||||
if not self.config.skip_jellyfin:
|
||||
required.append("docker")
|
||||
_require_commands(required)
|
||||
self._select_device()
|
||||
self._prepare_output_directories()
|
||||
|
||||
if not self.config.skip_jellyfin:
|
||||
if not self.config.skip_jellyfin_build:
|
||||
build_jellyfin_image(self.config)
|
||||
self._start_jellyfin()
|
||||
self._wait_for_health(self.host_jellyfin_url, attempts=120, interval=0.25, service="Jellyfin")
|
||||
|
||||
if self.config.jellyfin_fault:
|
||||
self._start_proxy()
|
||||
|
||||
if not self.config.skip_build:
|
||||
_run_checked(("flutter", "pub", "get"))
|
||||
_run_checked(("flutter", "build", "apk", "--debug"))
|
||||
|
||||
self._prepare_device()
|
||||
_run_checked((*self.adb_prefix, "install", "-r", self.config.apk_path))
|
||||
_run_checked(self.maestro_command())
|
||||
|
||||
def maestro_command(self) -> list[str]:
|
||||
default_url = f"http://10.0.2.2:{self.device_service_port}"
|
||||
if self.config.use_adb_reverse:
|
||||
default_url = f"http://127.0.0.1:{self.device_service_port}"
|
||||
jellyfin_url = self.config.jellyfin_url or default_url
|
||||
command = ["maestro", "test", "-e", f"JELLYFIN_URL={jellyfin_url}"]
|
||||
if self.device_id:
|
||||
command.extend(("--device", self.device_id))
|
||||
if self.config.maestro_config:
|
||||
command.extend(("--config", str(self.config.maestro_config)))
|
||||
command.append(str(self.config.flow_target))
|
||||
return command
|
||||
|
||||
def _prepare_output_directories(self) -> None:
|
||||
for path in (
|
||||
self.config.diagnostics_dir,
|
||||
self.config.jellyfin_log.parent,
|
||||
self.config.proxy_log.parent,
|
||||
self.config.proxy_journal.parent,
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _select_device(self) -> None:
|
||||
if not self.config.use_adb_reverse or self.device_id:
|
||||
return
|
||||
result = subprocess.run(
|
||||
("adb", "devices"),
|
||||
cwd=ROOT_DIR,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
devices = []
|
||||
for line in result.stdout.splitlines()[1:]:
|
||||
fields = line.split()
|
||||
if len(fields) >= 2 and fields[1] == "device":
|
||||
devices.append(fields[0])
|
||||
if len(devices) != 1:
|
||||
raise RunnerError("Set --device to the Android device serial when using --adb-reverse")
|
||||
self.device_id = devices[0]
|
||||
|
||||
def _start_jellyfin(self) -> None:
|
||||
name = f"plezy-maestro-jellyfin-{self.config.jellyfin_port}-{os.getpid()}"
|
||||
result = _run_checked(
|
||||
(
|
||||
"docker",
|
||||
"run",
|
||||
"--detach",
|
||||
"--rm",
|
||||
"--name",
|
||||
name,
|
||||
"--publish",
|
||||
f"{self.config.jellyfin_host}:{self.config.jellyfin_port}:8096",
|
||||
self.config.jellyfin_image,
|
||||
),
|
||||
capture_output=True,
|
||||
)
|
||||
self.container_name = result.stdout.strip() or name
|
||||
|
||||
def _wait_for_health(self, base_url: str, *, attempts: int, interval: float, service: str) -> None:
|
||||
health_url = f"{base_url.rstrip('/')}/health"
|
||||
last_error: Optional[Exception] = None
|
||||
for _ in range(attempts):
|
||||
try:
|
||||
with urllib.request.urlopen(health_url, timeout=1) as response:
|
||||
response.read()
|
||||
return
|
||||
except (OSError, urllib.error.URLError) as error:
|
||||
last_error = error
|
||||
time.sleep(interval)
|
||||
raise RunnerError(f"{service} did not become ready at {base_url}: {last_error}")
|
||||
|
||||
def _start_proxy(self) -> None:
|
||||
self.proxy_output = self.config.proxy_log.open("w", encoding="utf-8")
|
||||
self.proxy_process = subprocess.Popen(
|
||||
(
|
||||
sys.executable,
|
||||
str(ROOT_DIR / "scripts/maestro_jellyfin_proxy.py"),
|
||||
"--host",
|
||||
self.config.jellyfin_host,
|
||||
"--port",
|
||||
str(self.config.proxy_port),
|
||||
"--upstream",
|
||||
self.host_jellyfin_url,
|
||||
"--fault",
|
||||
self.config.jellyfin_fault or "",
|
||||
"--journal",
|
||||
str(self.config.proxy_journal),
|
||||
),
|
||||
cwd=ROOT_DIR,
|
||||
stdout=self.proxy_output,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
proxy_url = f"http://{self.config.jellyfin_host}:{self.config.proxy_port}"
|
||||
for _ in range(50):
|
||||
if self.proxy_process.poll() is not None:
|
||||
raise RunnerError(f"Jellyfin fault proxy exited early; see {self.config.proxy_log}")
|
||||
try:
|
||||
self._wait_for_health(proxy_url, attempts=1, interval=0, service="Jellyfin fault proxy")
|
||||
self.host_jellyfin_url = proxy_url
|
||||
self.device_service_port = self.config.proxy_port
|
||||
return
|
||||
except RunnerError:
|
||||
time.sleep(0.1)
|
||||
raise RunnerError(f"Jellyfin fault proxy did not become ready; see {self.config.proxy_log}")
|
||||
|
||||
def _adb_run(
|
||||
self,
|
||||
*arguments: object,
|
||||
check: bool = True,
|
||||
quiet: bool = False,
|
||||
timeout: int = 30,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
kwargs: dict[str, object] = {}
|
||||
if quiet:
|
||||
kwargs.update(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return subprocess.run(
|
||||
[*self.adb_prefix, *(str(argument) for argument in arguments)],
|
||||
cwd=ROOT_DIR,
|
||||
check=check,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _adb_capture(self, *arguments: object) -> Optional[str]:
|
||||
command = [*self.adb_prefix, *(str(argument) for argument in arguments)]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=ROOT_DIR,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise RunnerError(f"ADB command timed out: {_format_command(command)}") from error
|
||||
return result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
def _prepare_device(self) -> None:
|
||||
_run_checked((*self.adb_prefix, "wait-for-device"), timeout=60)
|
||||
for namespace, key in (
|
||||
("global", "stay_on_while_plugged_in"),
|
||||
("secure", "immersive_mode_confirmations"),
|
||||
("global", "hide_error_dialogs"),
|
||||
):
|
||||
value = self._adb_capture("shell", "settings", "get", namespace, key)
|
||||
if value is not None:
|
||||
self.device_settings[(namespace, key)] = value
|
||||
|
||||
self._adb_run(
|
||||
"shell",
|
||||
"settings",
|
||||
"put",
|
||||
"secure",
|
||||
"immersive_mode_confirmations",
|
||||
"confirmed",
|
||||
check=False,
|
||||
quiet=True,
|
||||
)
|
||||
self._adb_run(
|
||||
"shell",
|
||||
"settings",
|
||||
"put",
|
||||
"global",
|
||||
"hide_error_dialogs",
|
||||
"1",
|
||||
check=False,
|
||||
quiet=True,
|
||||
)
|
||||
self._adb_run("shell", "input", "keyevent", "KEYCODE_BACK", check=False, quiet=True)
|
||||
_run_checked((*self.adb_prefix, "shell", "svc", "power", "stayon", "true"))
|
||||
_run_checked((*self.adb_prefix, "shell", "input", "keyevent", "KEYCODE_WAKEUP"))
|
||||
_run_checked((*self.adb_prefix, "shell", "wm", "dismiss-keyguard"))
|
||||
|
||||
if self.config.use_adb_reverse:
|
||||
_run_checked(
|
||||
(
|
||||
*self.adb_prefix,
|
||||
"reverse",
|
||||
f"tcp:{self.device_service_port}",
|
||||
f"tcp:{self.device_service_port}",
|
||||
)
|
||||
)
|
||||
self.reverse_configured = True
|
||||
if self.config.uninstall_before_install:
|
||||
self._adb_run("uninstall", APP_ID, check=False, quiet=True)
|
||||
|
||||
def collect_failure_diagnostics(self, exit_status: int) -> None:
|
||||
try:
|
||||
self.config.diagnostics_dir.mkdir(parents=True, exist_ok=True)
|
||||
state_path = self.config.diagnostics_dir / "run-state.txt"
|
||||
with state_path.open("w", encoding="utf-8") as output:
|
||||
output.write(f"exit_status={exit_status}\n")
|
||||
output.write(f"jellyfin_host_url={self.host_jellyfin_url}\n")
|
||||
output.write(f"jellyfin_container={self.container_name or ''}\n")
|
||||
output.write(f"jellyfin_fault={self.config.jellyfin_fault or ''}\n")
|
||||
output.write(f"proxy_pid={self.proxy_process.pid if self.proxy_process else ''}\n")
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"{self.host_jellyfin_url.rstrip('/')}/health",
|
||||
timeout=2,
|
||||
) as response:
|
||||
output.write(response.read().decode(errors="replace"))
|
||||
output.write("\n")
|
||||
except Exception as error: # Diagnostics must not hide the original failure.
|
||||
output.write(f"health_error={error}\n")
|
||||
|
||||
if self.container_name:
|
||||
self._write_command_output(
|
||||
("docker", "logs", self.container_name),
|
||||
self.config.jellyfin_log,
|
||||
)
|
||||
self._write_command_output(("adb", "devices", "-l"), self.config.diagnostics_dir / "adb-devices.txt")
|
||||
for filename, arguments in (
|
||||
("device-properties.txt", ("shell", "getprop")),
|
||||
("device-processes.txt", ("shell", "ps", "-A")),
|
||||
("device-activities.txt", ("shell", "dumpsys", "activity", "activities")),
|
||||
("device-windows.txt", ("shell", "dumpsys", "window", "windows")),
|
||||
("device-logcat.txt", ("logcat", "-d", "-v", "threadtime")),
|
||||
):
|
||||
self._write_command_output(
|
||||
(*self.adb_prefix, *arguments),
|
||||
self.config.diagnostics_dir / filename,
|
||||
)
|
||||
if os.name == "nt" and shutil.which("tasklist"):
|
||||
self._write_command_output(("tasklist",), self.config.diagnostics_dir / "host-processes.txt")
|
||||
elif shutil.which("ps"):
|
||||
self._write_command_output(("ps", "-ef"), self.config.diagnostics_dir / "host-processes.txt")
|
||||
if shutil.which("lsof"):
|
||||
self._write_command_output(
|
||||
("lsof", "-nP", f"-iTCP:{self.device_service_port}"),
|
||||
self.config.diagnostics_dir / "jellyfin-listeners.txt",
|
||||
)
|
||||
except Exception as error: # Diagnostics are best effort.
|
||||
print(f"Failed to collect Maestro diagnostics: {error}", file=sys.stderr)
|
||||
|
||||
def _write_command_output(self, command: Sequence[object], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as output:
|
||||
subprocess.run(
|
||||
[str(value) for value in command],
|
||||
cwd=ROOT_DIR,
|
||||
check=False,
|
||||
stdout=output,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
def _adb_best_effort(self, *arguments: object) -> None:
|
||||
try:
|
||||
self._adb_run(*arguments, check=False, quiet=True, timeout=5)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
for (namespace, key), value in self.device_settings.items():
|
||||
operation = "delete" if value == "null" else "put"
|
||||
arguments = ["shell", "settings", operation, namespace, key]
|
||||
if operation == "put":
|
||||
arguments.append(value)
|
||||
self._adb_best_effort(*arguments)
|
||||
|
||||
if self.reverse_configured:
|
||||
self._adb_best_effort(
|
||||
"reverse",
|
||||
"--remove",
|
||||
f"tcp:{self.device_service_port}",
|
||||
)
|
||||
try:
|
||||
if self.proxy_process:
|
||||
self.proxy_process.terminate()
|
||||
try:
|
||||
self.proxy_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proxy_process.kill()
|
||||
self.proxy_process.wait()
|
||||
finally:
|
||||
if self.proxy_output:
|
||||
self.proxy_output.close()
|
||||
|
||||
if self.container_name:
|
||||
try:
|
||||
self._write_command_output(
|
||||
("docker", "logs", self.container_name),
|
||||
self.config.jellyfin_log,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
try:
|
||||
subprocess.run(
|
||||
("docker", "stop", "--time", "15", self.container_name),
|
||||
cwd=ROOT_DIR,
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
|
||||
def run(config: RunnerConfig) -> int:
|
||||
if config.command == "build-image":
|
||||
build_jellyfin_image(config)
|
||||
return 0
|
||||
|
||||
runner = MaestroRunner(config)
|
||||
exit_status = 0
|
||||
try:
|
||||
runner.run()
|
||||
except KeyboardInterrupt:
|
||||
exit_status = 130
|
||||
print("Maestro run interrupted", file=sys.stderr)
|
||||
except RunnerSignal as error:
|
||||
exit_status = error.exit_status
|
||||
print(str(error), file=sys.stderr)
|
||||
except subprocess.CalledProcessError as error:
|
||||
exit_status = error.returncode or 1
|
||||
print(f"Command failed ({exit_status}): {_format_command(error.cmd)}", file=sys.stderr)
|
||||
except (OSError, RunnerError) as error:
|
||||
exit_status = 1
|
||||
print(error, file=sys.stderr)
|
||||
finally:
|
||||
if exit_status:
|
||||
runner.collect_failure_diagnostics(exit_status)
|
||||
runner.cleanup()
|
||||
return exit_status
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
signal.signal(signal.SIGINT, _raise_signal)
|
||||
signal.signal(signal.SIGTERM, _raise_signal)
|
||||
try:
|
||||
config = parse_config(argv)
|
||||
return run(config)
|
||||
except RunnerSignal as error:
|
||||
return error.exit_status
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
except (OSError, RunnerError, subprocess.CalledProcessError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from maestro_jellyfin_proxy import JellyfinProxyHandler, ProxyState # noqa: E402
|
||||
|
||||
|
||||
class _UpstreamHandler(BaseHTTPRequestHandler):
|
||||
requests: list[tuple[str, str, bytes, str | None, str | None]] = []
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._respond()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._respond()
|
||||
|
||||
def _respond(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length) if length else b""
|
||||
self.requests.append(
|
||||
(
|
||||
self.command,
|
||||
self.path,
|
||||
body,
|
||||
self.headers.get("X-Emby-Token"),
|
||||
self.headers.get("Accept-Encoding"),
|
||||
)
|
||||
)
|
||||
payload = b"real jellyfin response"
|
||||
self.send_response(206 if self.headers.get("Range") else 200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class JellyfinProxyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_UpstreamHandler.requests = []
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.upstream = ThreadingHTTPServer(("127.0.0.1", 0), _UpstreamHandler)
|
||||
self.upstream_thread = threading.Thread(target=self.upstream.serve_forever, daemon=True)
|
||||
self.upstream_thread.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.upstream.shutdown()
|
||||
self.upstream.server_close()
|
||||
self.upstream_thread.join(timeout=5)
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _start_proxy(self, fault: str | None) -> tuple[ThreadingHTTPServer, threading.Thread, str, Path]:
|
||||
journal = Path(self.temp_dir.name) / "journal.jsonl"
|
||||
proxy = ThreadingHTTPServer(("127.0.0.1", 0), JellyfinProxyHandler)
|
||||
proxy.daemon_threads = True
|
||||
upstream_url = f"http://127.0.0.1:{self.upstream.server_port}"
|
||||
proxy.state = ProxyState(upstream_url, fault, journal) # type: ignore[attr-defined]
|
||||
thread = threading.Thread(target=proxy.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return proxy, thread, f"http://127.0.0.1:{proxy.server_port}", journal
|
||||
|
||||
def _stop_proxy(self, proxy: ThreadingHTTPServer, thread: threading.Thread) -> None:
|
||||
proxy.shutdown()
|
||||
proxy.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
def test_forwards_methods_bodies_tokens_and_range_responses(self) -> None:
|
||||
proxy, thread, base_url, _ = self._start_proxy(None)
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
base_url + "/Items?id=movie",
|
||||
data=b'{"played":true}',
|
||||
method="POST",
|
||||
headers={
|
||||
"X-Emby-Token": "token",
|
||||
"Range": "bytes=0-9",
|
||||
"Content-Type": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request) as response:
|
||||
self.assertEqual(response.status, 206)
|
||||
self.assertEqual(response.headers["Accept-Ranges"], "bytes")
|
||||
self.assertEqual(response.read(), b"real jellyfin response")
|
||||
self.assertEqual(
|
||||
_UpstreamHandler.requests,
|
||||
[("POST", "/Items?id=movie", b'{"played":true}', "token", "identity")],
|
||||
)
|
||||
finally:
|
||||
self._stop_proxy(proxy, thread)
|
||||
|
||||
def test_recovery_faults_only_the_first_video_stream_request(self) -> None:
|
||||
proxy, thread, base_url, journal = self._start_proxy("recovery")
|
||||
try:
|
||||
with self.assertRaises(urllib.error.HTTPError) as first:
|
||||
urllib.request.urlopen(base_url + "/Videos/movie/stream.mp4?Static=true")
|
||||
self.assertEqual(first.exception.code, 503)
|
||||
first.exception.close()
|
||||
with urllib.request.urlopen(base_url + "/Videos/movie/stream.mp4?Static=true") as second:
|
||||
self.assertEqual(second.status, 200)
|
||||
self.assertEqual(len(_UpstreamHandler.requests), 1)
|
||||
events = [json.loads(line) for line in journal.read_text(encoding="utf-8").splitlines()]
|
||||
self.assertEqual([event["kind"] for event in events], ["fault", "request"])
|
||||
self.assertEqual(
|
||||
[event["path"] for event in events],
|
||||
["/Videos/movie/stream.mp4", "/Videos/movie/stream.mp4"],
|
||||
)
|
||||
finally:
|
||||
self._stop_proxy(proxy, thread)
|
||||
|
||||
def test_music_fault_does_not_affect_other_requests(self) -> None:
|
||||
proxy, thread, base_url, _ = self._start_proxy("music-failure")
|
||||
try:
|
||||
with urllib.request.urlopen(base_url + "/Items") as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
with self.assertRaises(urllib.error.HTTPError) as failure:
|
||||
urllib.request.urlopen(base_url + "/Artists/AlbumArtists?UserId=user")
|
||||
self.assertEqual(failure.exception.code, 503)
|
||||
failure.exception.close()
|
||||
with urllib.request.urlopen(base_url + "/Artists/AlbumArtists?UserId=user") as recovered:
|
||||
self.assertEqual(recovered.status, 200)
|
||||
finally:
|
||||
self._stop_proxy(proxy, thread)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from maestro_fixtures import MEDIA_FIXTURE_SPECS, MediaFixtureSpec # noqa: E402
|
||||
import maestro_real_jellyfin as real_jellyfin # noqa: E402
|
||||
from maestro_real_jellyfin import ( # noqa: E402
|
||||
ALPHABET_TITLES,
|
||||
BASE_TITLE,
|
||||
EPISODE_TITLES,
|
||||
GUEST_TITLE,
|
||||
download_codec_media,
|
||||
prepare_media,
|
||||
)
|
||||
|
||||
|
||||
class PrepareMediaTests(unittest.TestCase):
|
||||
def test_base_media_is_deterministic_and_repeatable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output = Path(temp_dir) / "media"
|
||||
|
||||
titles = prepare_media(output, None, False)
|
||||
self.assertEqual(titles[0], BASE_TITLE)
|
||||
self.assertEqual(len(titles), 1 + len(ALPHABET_TITLES) * 4)
|
||||
movie = output / "movies" / "maestro-movie" / f"{BASE_TITLE}.mp4"
|
||||
nfo = movie.with_suffix(".nfo")
|
||||
first_payload = movie.read_bytes()
|
||||
self.assertGreater(len(first_payload), 0)
|
||||
self.assertEqual(ET.parse(nfo).findtext("title"), BASE_TITLE)
|
||||
self.assertEqual(
|
||||
ET.parse(output / "guest-movies" / "guest-galaxy" / f"{GUEST_TITLE}.nfo").findtext("title"),
|
||||
GUEST_TITLE,
|
||||
)
|
||||
episode_nfo = output / "shows" / "Maestro Show" / "Season 01" / (
|
||||
f"Maestro Show S01E01 - {EPISODE_TITLES[0]}.nfo"
|
||||
)
|
||||
self.assertEqual(ET.parse(episode_nfo).findtext("title"), EPISODE_TITLES[0])
|
||||
|
||||
(output / "obsolete").mkdir()
|
||||
self.assertEqual(prepare_media(output, None, False), titles)
|
||||
self.assertEqual(movie.read_bytes(), first_payload)
|
||||
self.assertFalse((output / "obsolete").exists())
|
||||
|
||||
def test_codec_media_uses_hard_links_and_exact_titles(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
source = root / "source"
|
||||
source.mkdir()
|
||||
for spec in MEDIA_FIXTURE_SPECS:
|
||||
(source / spec.filename).write_bytes(spec.id.encode("utf-8"))
|
||||
|
||||
output = root / "media"
|
||||
titles = prepare_media(output, source, True)
|
||||
|
||||
self.assertEqual(titles[0], BASE_TITLE)
|
||||
self.assertEqual(titles[-len(MEDIA_FIXTURE_SPECS) :], [spec.title for spec in MEDIA_FIXTURE_SPECS])
|
||||
for spec in MEDIA_FIXTURE_SPECS:
|
||||
staged = output / "movies" / spec.id / f"{spec.title}.mkv"
|
||||
self.assertTrue(staged.samefile(source / spec.filename))
|
||||
self.assertEqual(ET.parse(staged.with_suffix(".nfo")).findtext("title"), spec.title)
|
||||
|
||||
def test_codec_download_verifies_size_and_sha256_then_reuses_file(self) -> None:
|
||||
payload = b"deterministic codec payload"
|
||||
spec = MediaFixtureSpec(
|
||||
id="codec-test",
|
||||
title="Codec Test",
|
||||
filename="codec-test.mkv",
|
||||
overview="Test fixture.",
|
||||
video_codec="h264",
|
||||
width=320,
|
||||
height=180,
|
||||
size_bytes=len(payload),
|
||||
sha256=hashlib.sha256(payload).hexdigest(),
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output = Path(temp_dir)
|
||||
response = io.BytesIO(payload)
|
||||
response.headers = {"Content-Length": str(len(payload))}
|
||||
with (
|
||||
patch.object(real_jellyfin, "MEDIA_FIXTURE_SPECS", (spec,)),
|
||||
patch.object(real_jellyfin.urllib.request, "urlopen", return_value=response) as urlopen,
|
||||
):
|
||||
self.assertEqual(download_codec_media(output, "https://media.example/"), [spec.filename])
|
||||
self.assertEqual((output / spec.filename).read_bytes(), payload)
|
||||
self.assertEqual(urlopen.call_count, 1)
|
||||
|
||||
with (
|
||||
patch.object(real_jellyfin, "MEDIA_FIXTURE_SPECS", (spec,)),
|
||||
patch.object(
|
||||
real_jellyfin.urllib.request,
|
||||
"urlopen",
|
||||
side_effect=AssertionError("valid cached fixture must not be downloaded"),
|
||||
),
|
||||
):
|
||||
self.assertEqual(download_codec_media(output, "https://media.example/"), [spec.filename])
|
||||
|
||||
def test_codec_download_rejects_corrupt_payload_without_leaving_partial_file(self) -> None:
|
||||
payload = b"corrupt"
|
||||
spec = MediaFixtureSpec(
|
||||
id="codec-test",
|
||||
title="Codec Test",
|
||||
filename="codec-test.mkv",
|
||||
overview="Test fixture.",
|
||||
video_codec="h264",
|
||||
width=320,
|
||||
height=180,
|
||||
size_bytes=len(payload),
|
||||
sha256=hashlib.sha256(b"expected").hexdigest(),
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output = Path(temp_dir)
|
||||
response = io.BytesIO(payload)
|
||||
response.headers = {"Content-Length": str(len(payload))}
|
||||
with (
|
||||
patch.object(real_jellyfin, "MEDIA_FIXTURE_SPECS", (spec,)),
|
||||
patch.object(real_jellyfin.urllib.request, "urlopen", return_value=response),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "failed SHA-256 verification"):
|
||||
download_codec_media(output, "https://media.example/")
|
||||
|
||||
self.assertFalse((output / spec.filename).exists())
|
||||
self.assertFalse((output / f"{spec.filename}.part").exists())
|
||||
|
||||
|
||||
def test_existing_empty_directory_can_become_managed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output = Path(temp_dir) / "media"
|
||||
output.mkdir()
|
||||
|
||||
self.assertEqual(prepare_media(output, None, False)[0], BASE_TITLE)
|
||||
self.assertTrue((output / "movies" / "maestro-movie" / f"{BASE_TITLE}.mp4").is_file())
|
||||
|
||||
def test_existing_unmanaged_directory_is_never_cleared(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output = Path(temp_dir) / "media"
|
||||
output.mkdir()
|
||||
sentinel = output / "keep.txt"
|
||||
sentinel.write_text("user data", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "unmanaged media staging directory"):
|
||||
prepare_media(output, None, False)
|
||||
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "user data")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import redirect_stderr
|
||||
from dataclasses import replace
|
||||
import io
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import run_maestro # noqa: E402
|
||||
|
||||
|
||||
class ParseConfigTests(unittest.TestCase):
|
||||
def test_basic_defaults(self) -> None:
|
||||
config = run_maestro.parse_config([], {})
|
||||
|
||||
self.assertEqual(config.command, "basic")
|
||||
self.assertEqual(config.flow_target, run_maestro.ROOT_DIR / ".maestro")
|
||||
self.assertIsNone(config.maestro_config)
|
||||
self.assertFalse(config.use_adb_reverse)
|
||||
self.assertFalse(config.uninstall_before_install)
|
||||
|
||||
def test_suite_presets_replace_shell_wrappers(self) -> None:
|
||||
catalog = run_maestro.parse_config(["catalog"], {})
|
||||
media = run_maestro.parse_config(["media"], {})
|
||||
|
||||
self.assertEqual(catalog.flow_target, run_maestro.ROOT_DIR / ".maestro/real_flows")
|
||||
self.assertEqual(
|
||||
catalog.diagnostics_dir,
|
||||
run_maestro.ROOT_DIR / "build/maestro-real-jellyfin/diagnostics",
|
||||
)
|
||||
self.assertTrue(catalog.uninstall_before_install)
|
||||
self.assertEqual(media.flow_target, run_maestro.ROOT_DIR / ".maestro/media_flows")
|
||||
self.assertEqual(media.maestro_config, run_maestro.ROOT_DIR / ".maestro/media-config.yaml")
|
||||
self.assertTrue(media.use_adb_reverse)
|
||||
self.assertTrue(media.uninstall_before_install)
|
||||
|
||||
def test_cli_options_override_compatible_environment_values(self) -> None:
|
||||
config = run_maestro.parse_config(
|
||||
[
|
||||
"media",
|
||||
"--no-adb-reverse",
|
||||
"--flow",
|
||||
"custom/flow.yaml",
|
||||
"--device",
|
||||
"cli-device",
|
||||
],
|
||||
{
|
||||
"MAESTRO_USE_ADB_REVERSE": "1",
|
||||
"MAESTRO_FLOW_TARGET": "environment/flow.yaml",
|
||||
"MAESTRO_DEVICE_ID": "environment-device",
|
||||
"MAESTRO_SKIP_BUILD": "true",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(config.use_adb_reverse)
|
||||
self.assertEqual(config.flow_target, run_maestro.ROOT_DIR / "custom/flow.yaml")
|
||||
self.assertEqual(config.device_id, "cli-device")
|
||||
self.assertTrue(config.skip_build)
|
||||
|
||||
def test_invalid_environment_values_fail_early(self) -> None:
|
||||
with self.assertRaisesRegex(run_maestro.RunnerError, "MAESTRO_SKIP_BUILD"):
|
||||
run_maestro.parse_config([], {"MAESTRO_SKIP_BUILD": "sometimes"})
|
||||
with self.assertRaisesRegex(run_maestro.RunnerError, "MAESTRO_JELLYFIN_PORT"):
|
||||
run_maestro.parse_config([], {"MAESTRO_JELLYFIN_PORT": "invalid"})
|
||||
with self.assertRaisesRegex(run_maestro.RunnerError, "MAESTRO_JELLYFIN_BUILD_ATTEMPTS"):
|
||||
run_maestro.parse_config([], {"MAESTRO_JELLYFIN_BUILD_ATTEMPTS": "0"})
|
||||
|
||||
|
||||
class CommandTests(unittest.TestCase):
|
||||
def test_media_command_contains_resolved_preset_and_device_url(self) -> None:
|
||||
config = run_maestro.parse_config(["media", "--device", "emulator-5554"], {})
|
||||
command = run_maestro.MaestroRunner(config).maestro_command()
|
||||
|
||||
self.assertEqual(
|
||||
command,
|
||||
[
|
||||
"maestro",
|
||||
"test",
|
||||
"-e",
|
||||
"JELLYFIN_URL=http://127.0.0.1:8096",
|
||||
"--device",
|
||||
"emulator-5554",
|
||||
"--config",
|
||||
str(run_maestro.ROOT_DIR / ".maestro/media-config.yaml"),
|
||||
str(run_maestro.ROOT_DIR / ".maestro/media_flows"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_explicit_device_url_wins_over_network_mode(self) -> None:
|
||||
config = run_maestro.parse_config(
|
||||
["basic", "--adb-reverse", "--jellyfin-url", "http://device.test:9000"],
|
||||
{},
|
||||
)
|
||||
|
||||
command = run_maestro.MaestroRunner(config).maestro_command()
|
||||
|
||||
self.assertIn("JELLYFIN_URL=http://device.test:9000", command)
|
||||
|
||||
|
||||
class LifecycleTests(unittest.TestCase):
|
||||
def test_runner_failure_collects_diagnostics_and_cleans_up(self) -> None:
|
||||
config = run_maestro.parse_config([], {})
|
||||
with patch.object(run_maestro, "MaestroRunner") as runner_type:
|
||||
runner = runner_type.return_value
|
||||
runner.run.side_effect = run_maestro.RunnerError("failed")
|
||||
with redirect_stderr(io.StringIO()):
|
||||
exit_status = run_maestro.run(config)
|
||||
|
||||
self.assertEqual(exit_status, 1)
|
||||
runner.collect_failure_diagnostics.assert_called_once_with(1)
|
||||
runner.cleanup.assert_called_once_with()
|
||||
|
||||
def test_image_build_retries_once(self) -> None:
|
||||
config = replace(run_maestro.parse_config(["build-image"], {}), jellyfin_build_attempts=2)
|
||||
failure = subprocess.CalledProcessError(1, ["docker", "build"])
|
||||
success = subprocess.CompletedProcess(["docker", "build"], 0)
|
||||
|
||||
with (
|
||||
patch.object(run_maestro, "_require_commands"),
|
||||
patch.object(run_maestro, "_run_checked", side_effect=[failure, success]) as run_command,
|
||||
patch.object(run_maestro.time, "sleep") as sleep,
|
||||
redirect_stderr(io.StringIO()),
|
||||
):
|
||||
run_maestro.build_jellyfin_image(config)
|
||||
|
||||
self.assertEqual(run_command.call_count, 2)
|
||||
sleep.assert_called_once_with(5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user