195 lines
6.2 KiB
Python
Executable File
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()
|