scripts/ had ~80 flat files. Entry points (ci_*.sh, codegen.sh, run_tests.sh, format_native.sh, setup_hooks.sh, upload-symbols.*) and the shared pubspec_version.py stay at the root; checkers, generators, maestro tooling and release tooling move into subdirectories with their tests. Updated every reference: workflow steps, guard-test glob, Docker COPY paths and .dockerignore whitelist, website audit path, dart test imports, and regenerated the five outputs whose headers embed generator paths.
206 lines
8.5 KiB
Python
Executable File
206 lines
8.5 KiB
Python
Executable File
#!/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 _ResponseFailureHandler:
|
|
def __init__(self, state: ProxyState, path: str) -> None:
|
|
self.state = state
|
|
self.path = path
|
|
self.command = "GET"
|
|
self.headers: dict[str, str] = {}
|
|
|
|
def send_response(self, status: int) -> None:
|
|
raise BrokenPipeError(f"client disconnected before status {status}")
|
|
|
|
|
|
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_offline_control_blocks_requests_until_reenabled(self) -> None:
|
|
proxy, thread, base_url, journal = self._start_proxy("offline")
|
|
try:
|
|
enable = urllib.request.Request(
|
|
base_url + "/__maestro/offline",
|
|
data=b'{"enabled":true}',
|
|
method="POST",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(enable) as response:
|
|
self.assertEqual(response.status, 204)
|
|
|
|
with self.assertRaises(urllib.error.HTTPError) as failure:
|
|
urllib.request.urlopen(base_url + "/Items")
|
|
self.assertEqual(failure.exception.code, 503)
|
|
failure.exception.close()
|
|
self.assertEqual(_UpstreamHandler.requests, [])
|
|
|
|
disable = urllib.request.Request(
|
|
base_url + "/__maestro/offline",
|
|
data=b'{"enabled":false}',
|
|
method="POST",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(disable) as response:
|
|
self.assertEqual(response.status, 204)
|
|
with urllib.request.urlopen(base_url + "/Items") as response:
|
|
self.assertEqual(response.status, 200)
|
|
|
|
events = [json.loads(line) for line in journal.read_text(encoding="utf-8").splitlines()]
|
|
self.assertEqual([event["kind"] for event in events], ["control", "offline", "control", "request"])
|
|
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)
|
|
|
|
def test_records_events_before_response_write(self) -> None:
|
|
upstream_url = f"http://127.0.0.1:{self.upstream.server_port}"
|
|
cases = [
|
|
("fault", "recovery", "/Videos/movie/stream.mp4?Static=true", "fault", 503),
|
|
("request", None, "/Items", "request", 200),
|
|
]
|
|
|
|
for name, fault, path, expected_kind, expected_status in cases:
|
|
with self.subTest(name=name):
|
|
journal = Path(self.temp_dir.name) / f"{name}.jsonl"
|
|
state = ProxyState(upstream_url, fault, journal)
|
|
handler = _ResponseFailureHandler(state, path)
|
|
|
|
with self.assertRaises(BrokenPipeError):
|
|
JellyfinProxyHandler._proxy(handler)
|
|
|
|
events = [json.loads(line) for line in journal.read_text(encoding="utf-8").splitlines()]
|
|
self.assertEqual(len(events), 1)
|
|
self.assertEqual(events[0]["kind"], expected_kind)
|
|
self.assertEqual(events[0]["status"], expected_status)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|