Files
plezy/scripts/maestro_jellyfin_proxy.py
T

195 lines
6.2 KiB
Python
Executable File

#!/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()