feat: play resolved Miruro HLS with external VTT and stable episode progress
This commit is contained in:
+137
-1
@@ -7,6 +7,7 @@ import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
@@ -44,6 +45,8 @@ class StreamingServer:
|
||||
"network-vod.m3u8": "combined",
|
||||
**{f"network-vod-{index}.ts": "combined" for index in range(4)},
|
||||
}
|
||||
resolved_token = "token=part%2Fone+two%26tail"
|
||||
resolved_secret = "resolved-header-secret"
|
||||
|
||||
def __init__(self, fixtures: str | Path):
|
||||
self.fixtures = Path(fixtures).resolve()
|
||||
@@ -51,6 +54,8 @@ class StreamingServer:
|
||||
self.gates: dict[str, _Gate] = {}
|
||||
self.stats: dict[str, dict[str, int]] = {}
|
||||
self.prefixes: dict[tuple[str, int], dict[str, float]] = {}
|
||||
self.resolved_stats: dict[str, dict[str, dict[str, int]]] = {}
|
||||
self.sink_stats = {"requests": 0, "credentials": 0}
|
||||
self.stopping = threading.Event()
|
||||
owner = self
|
||||
|
||||
@@ -70,6 +75,19 @@ class StreamingServer:
|
||||
# Cancellation can close a body read or an idle keep-alive connection.
|
||||
self.close_connection = True
|
||||
|
||||
class SinkHandler(Handler):
|
||||
def do_GET(self):
|
||||
with owner.lock:
|
||||
owner.sink_stats["requests"] += 1
|
||||
owner.sink_stats["credentials"] += int(
|
||||
self.headers.get("Authorization") is not None or
|
||||
self.headers.get("X-Terebi-Fixture") is not None)
|
||||
owner._reply(self, 403, b"Unrelated origin")
|
||||
|
||||
self.sink = ThreadingHTTPServer(("127.0.0.1", 0), SinkHandler)
|
||||
self.sink.daemon_threads = True
|
||||
self.sink_thread = threading.Thread(target=self.sink.serve_forever, daemon=True)
|
||||
self.sink_url = f"http://127.0.0.1:{self.sink.server_port}"
|
||||
self.http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.http.daemon_threads = True
|
||||
self.thread = threading.Thread(target=self.http.serve_forever, daemon=True)
|
||||
@@ -77,6 +95,7 @@ class StreamingServer:
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
self.sink_thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
@@ -87,6 +106,9 @@ class StreamingServer:
|
||||
self.http.shutdown()
|
||||
self.http.server_close()
|
||||
self.thread.join()
|
||||
self.sink.shutdown()
|
||||
self.sink.server_close()
|
||||
self.sink_thread.join()
|
||||
|
||||
@staticmethod
|
||||
def _reply(request, status: int, body: bytes, content_type="application/json"):
|
||||
@@ -155,9 +177,92 @@ class StreamingServer:
|
||||
if "pos" in packet and "pts_time" in packet
|
||||
and float(packet["pts_time"]) - origin >= seconds)
|
||||
|
||||
def _resolved(self, request, parsed, parts):
|
||||
scenario = parts[1]
|
||||
relative = "/".join(parts[2:])
|
||||
kind = ("master" if relative == "master.m3u8" else
|
||||
"variant" if relative == "nested/media.m3u8" else
|
||||
"key" if relative == "keys/key.bin" else
|
||||
"init" if relative in ("init.mp4", "init.jpeg") else "segment")
|
||||
with self.lock:
|
||||
stats = self.resolved_stats.setdefault(scenario, {}).setdefault(
|
||||
kind, {"requests": 0, "denied": 0, "redirects": 0})
|
||||
stats["requests"] += 1
|
||||
denied = (parsed.query != self.resolved_token or
|
||||
request.headers.get("X-Terebi-Fixture") != "combined" or
|
||||
request.headers.get("Authorization") != "Bearer " + self.resolved_secret)
|
||||
stats["denied"] += int(denied)
|
||||
if denied:
|
||||
self._reply(request, 403, b"Required source credentials or complete token missing")
|
||||
return
|
||||
if scenario == "redirect-" + kind or scenario == "redirect-same-master" and kind == "master":
|
||||
with self.lock:
|
||||
stats["redirects"] += 1
|
||||
request.send_response(302)
|
||||
request.send_header("Location", self.base_url + "/media/network-vod.m3u8"
|
||||
if scenario == "redirect-same-master" else self.sink_url + "/leak")
|
||||
request.send_header("Content-Length", "0")
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.close_connection = True
|
||||
return
|
||||
fragmented = scenario in ("fmp4", "image-fmp4", "redirect-init", "external-init")
|
||||
image_named = scenario.startswith("image-")
|
||||
directory = self.fixtures / "resolved-hls" / ("fmp4" if fragmented else "encrypted")
|
||||
|
||||
def uri(path, resource_kind):
|
||||
if image_named and resource_kind in ("segment", "init"):
|
||||
path = path.rsplit(".", 1)[0] + (".jpeg" if fragmented else ".jpg")
|
||||
address = path + "?" + self.resolved_token
|
||||
if scenario == "external-" + resource_kind:
|
||||
return self.sink_url + "/leak/" + address
|
||||
return address
|
||||
|
||||
if kind == "master":
|
||||
body = ("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\n" +
|
||||
uri("nested/media.m3u8", "variant") + "\n").encode()
|
||||
elif kind == "variant":
|
||||
playlist = (directory / "media.m3u8").read_text()
|
||||
playlist = re.sub(r'URI="[^"]+"', lambda match: 'URI="' + uri(
|
||||
"../init.mp4" if fragmented else "../keys/key.bin",
|
||||
"init" if fragmented else "key") + '"', playlist)
|
||||
playlist = re.sub(r"(?m)^(seg-[^\n]+)$",
|
||||
lambda match: uri("../segments/" + match[1], "segment"), playlist)
|
||||
body = playlist.encode()
|
||||
else:
|
||||
name = relative.removeprefix("segments/").removeprefix("keys/")
|
||||
# Resource routing is deliberately finite; never serve arbitrary paths.
|
||||
if not re.fullmatch(r"key\.bin|init\.(?:mp4|jpeg)|seg-\d+\.(?:ts|m4s|jpg|jpeg)", name):
|
||||
self._reply(request, 404, b"Unknown HLS resource")
|
||||
return
|
||||
if image_named:
|
||||
name = (name.rsplit(".", 1)[0] + ".mp4" if kind == "init" else
|
||||
name.rsplit(".", 1)[0] + (".m4s" if fragmented else ".ts")
|
||||
if kind == "segment" else name)
|
||||
body = (directory / name).read_bytes()
|
||||
self._reply(request, 200, body, "application/vnd.apple.mpegurl"
|
||||
if kind in ("master", "variant") else
|
||||
"image/jpeg" if image_named and kind in ("segment", "init") else "application/octet-stream")
|
||||
|
||||
def _get(self, request):
|
||||
parsed = urlsplit(request.path)
|
||||
parts = unquote(parsed.path).strip("/").split("/")
|
||||
if parts == ["control", "resolved-stats"]:
|
||||
with self.lock:
|
||||
body = json.dumps({"sources": self.resolved_stats, "sink": self.sink_stats}).encode()
|
||||
self._reply(request, 200, body)
|
||||
return
|
||||
if len(parts) >= 3 and parts[0] == "resolved":
|
||||
self._resolved(request, parsed, parts)
|
||||
return
|
||||
if parts == ["youtube-redirect", "av.mp4"]:
|
||||
request.send_response(302)
|
||||
request.send_header("Location", self.base_url + "/media/av.mp4")
|
||||
request.send_header("Content-Length", "0")
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.close_connection = True
|
||||
return
|
||||
if parts == ["control", "stats"]:
|
||||
with self.lock:
|
||||
body = json.dumps(self.stats).encode()
|
||||
@@ -300,6 +405,27 @@ class StreamingServer:
|
||||
gate.bytes += len(chunk)
|
||||
|
||||
|
||||
def resolved_fixtures(fixtures: Path):
|
||||
"""Generate deterministic AES-128 TS and fMP4 fixtures without external services."""
|
||||
for fragmented in (False, True):
|
||||
directory = fixtures.resolve() / "resolved-hls" / ("fmp4" if fragmented else "encrypted")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", str(fixtures.resolve() / "av.mp4"),
|
||||
"-c:v", "libx264", "-g", "24", "-keyint_min", "24", "-sc_threshold", "0",
|
||||
"-c:a", "aac", "-hls_time", "1", "-hls_playlist_type", "vod"]
|
||||
if fragmented:
|
||||
command += ["-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4"]
|
||||
else:
|
||||
(directory / "key.bin").write_bytes(bytes(range(16)))
|
||||
(directory / "keyinfo").write_text("key.bin\n" + str(directory / "key.bin") +
|
||||
"\n000102030405060708090a0b0c0d0e0f\n")
|
||||
command += ["-hls_key_info_file", str(directory / "keyinfo")]
|
||||
command += ["-hls_segment_filename", str(directory / ("seg-%d.m4s" if fragmented else "seg-%d.ts")),
|
||||
str(directory / "media.m3u8")]
|
||||
subprocess.run(command, check=True, timeout=30)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
@@ -310,12 +436,22 @@ def main():
|
||||
missing = [name for name in required if not (args.fixtures / name).is_file()]
|
||||
if missing:
|
||||
parser.error("Missing streaming fixtures: " + ", ".join(missing))
|
||||
resolved_fixtures(args.fixtures)
|
||||
with StreamingServer(args.fixtures) as server:
|
||||
try:
|
||||
result = subprocess.run([str(args.binary.resolve()), server.base_url,
|
||||
str(args.fixtures.resolve())], timeout=180, check=False)
|
||||
str(args.fixtures.resolve())], timeout=180, check=False,
|
||||
capture_output=True)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SystemExit("FAIL: native network check exceeded its infrastructure deadline")
|
||||
private = (StreamingServer.resolved_token.encode(), StreamingServer.resolved_secret.encode(),
|
||||
b"Bearer ", server.base_url.encode(), server.sink_url.encode())
|
||||
if any(secret in result.stdout or secret in result.stderr for secret in private):
|
||||
raise SystemExit("FAIL: native diagnostics exposed source URL or credentials")
|
||||
print(result.stdout.decode(), end="")
|
||||
print(result.stderr.decode(), end="", file=sys.stderr)
|
||||
if server.sink_stats["requests"] or server.sink_stats["credentials"]:
|
||||
raise SystemExit("FAIL: resolved playback contacted an unrelated origin")
|
||||
raise SystemExit(result.returncode)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user