145 lines
7.8 KiB
Python
145 lines
7.8 KiB
Python
"""Local-only resolved HLS/VTT + native model smoke; no live requests."""
|
|
import base64
|
|
import http.server
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
binary = str(Path(sys.argv[1]).resolve())
|
|
fixtures = Path(sys.argv[2]).resolve()
|
|
seen = []
|
|
errors = []
|
|
zoko_only = "--zoko-intervals" in sys.argv[3:]
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def log_message(self, *_):
|
|
pass
|
|
|
|
def transport(self):
|
|
path = urlsplit(self.path).path
|
|
try:
|
|
encoded, suffix = path.lstrip("/").split("/", 1)
|
|
target, referer = encoded.split("~")
|
|
def decode(value):
|
|
raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
return bytes(byte ^ [1, 2, 3][index % 3] for index, byte in enumerate(raw)).decode()
|
|
upstream = urlsplit(decode(target))
|
|
if not upstream.path.startswith("/transport-"):
|
|
return False
|
|
assert decode(referer) == "https://transport.example/"
|
|
assert self.headers.get("X-Secret") is None and self.headers.get("Referer") is None, "private headers leaked to proxy"
|
|
except (ValueError, UnicodeError):
|
|
return False
|
|
query = parse_qs(upstream.query)
|
|
if upstream.path.endswith(".m3u8"):
|
|
assert query == {"skip": ["fixture"]}, "provider query or token-removal rule lost"
|
|
port = self.server.server_port
|
|
if upstream.path == "/transport-master.m3u8":
|
|
def encode(value):
|
|
raw = value.encode()
|
|
return base64.urlsafe_b64encode(bytes(byte ^ [1, 2, 3][index % 3] for index, byte in enumerate(raw))).decode().rstrip("=")
|
|
nested = (f"http://localhost:{port}/" + encode(f"http://127.0.0.1:{port}/transport-variant.m3u8?token=private") +
|
|
"~" + encode("https://transport.example/") + "/pl.m3u8")
|
|
data = f"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\n{nested}\n".encode()
|
|
elif upstream.path == "/transport-variant.m3u8":
|
|
data = (f'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="http://2130706433:{port}/transport-private"\n'
|
|
'#EXTINF:1,\nhttps://foreign.invalid/transport-segment.bin\n#EXT-X-ENDLIST\n').encode()
|
|
elif upstream.path == "/transport-segment.bin":
|
|
assert upstream.hostname == "127.0.0.1", "segment origin rewrite lost"
|
|
data = bytes(range(256))
|
|
else:
|
|
errors.append("unexpected transport request")
|
|
self.send_error(403)
|
|
return True
|
|
self.send_response(200)
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
return True
|
|
|
|
def do_GET(self):
|
|
if self.transport():
|
|
return
|
|
path = urlsplit(self.path).path
|
|
seen.append(path)
|
|
alternate = path.startswith("/alternate/")
|
|
captions = path.startswith("/captions/")
|
|
if alternate or captions:
|
|
path = "/" + path.split("/", 2)[2]
|
|
expected = "alternate-private" if alternate else "subtitle-private" if captions or path in ("/english.jpeg", "/denied.vtt") else "stream-private"
|
|
if self.headers.get("X-Resource") != expected:
|
|
errors.append("wrong resource headers: " + path)
|
|
self.send_error(403)
|
|
return
|
|
if path == "/master.m3u8":
|
|
query = parse_qs(urlsplit(self.path).query)
|
|
if query.get("token") != ["fixture"] or (alternate and query.get("rewrite") != ["1"]):
|
|
errors.append("master token or provider query lost")
|
|
data = b'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\nvariant.m3u8?token=variant\n'
|
|
elif path == "/variant.m3u8":
|
|
query = parse_qs(urlsplit(self.path).query)
|
|
if query.get("token") != ["variant"] or (alternate and query.get("rewrite") != ["1"]):
|
|
errors.append("variant token or provider query lost")
|
|
data = (fixtures / "resolved-vod.m3u8").read_bytes()
|
|
if alternate:
|
|
data = data.replace(b"resolved-vod-", b"https://invalid.example/alternate/resolved-vod-")
|
|
elif path == "/english.jpeg":
|
|
data = (b'WEBVTT\n\n00:00.000 --> 00:10.000\nAlternate English cue\n' if alternate else
|
|
b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n')
|
|
if parse_qs(urlsplit(self.path).query).get("auto") == ["1"]:
|
|
data = data.replace(b"First English cue", b"First automatic cue").replace(b"Second English cue", b"Second automatic cue")
|
|
elif path == "/denied.vtt":
|
|
self.send_error(403)
|
|
return
|
|
elif path.startswith("/resolved-vod-") and path.endswith(".ts"):
|
|
data = (fixtures / path[1:]).read_bytes()
|
|
else:
|
|
self.send_error(404)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.send_header("Content-Type", "text/vtt" if path.endswith(".jpeg") else "application/octet-stream")
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(fixtures / "av.mp4"),
|
|
"-c:v", "libx264", "-g", "24", "-keyint_min", "24", "-sc_threshold", "0", "-c:a", "aac",
|
|
"-hls_time", "1", "-hls_playlist_type", "vod", "-hls_segment_filename", str(fixtures / "resolved-vod-%d.ts"),
|
|
str(fixtures / "resolved-vod.m3u8")], check=True)
|
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
base = f"http://127.0.0.1:{server.server_port}"
|
|
with tempfile.TemporaryDirectory(prefix="terebi-resolved-") as temp:
|
|
source = Path(temp) / "source.json"
|
|
progress = Path(temp) / "progress.jsonl"
|
|
source.write_text(json.dumps({"version": 1, "url": base + "/master.m3u8?token=fixture",
|
|
"headers": {"X-Resource": "stream-private"}, "identity": {"provider": "miruro", "media_id": "fixture-ID", "episode": 1},
|
|
"title": "Local resolved fixture", "subtitles": [{"url": base + "/english.jpeg", "format": "vtt", "language": "en",
|
|
"label": "English", "default": True, "headers": {"X-Resource": "subtitle-private"}}]}))
|
|
source.chmod(0o600)
|
|
subprocess.run([binary, str(source), "--zoko-intervals"] if zoko_only else
|
|
[binary, str(source), str(progress)], check=True, timeout=100)
|
|
if not zoko_only:
|
|
records = [json.loads(line) for line in progress.read_text().splitlines()]
|
|
assert records and all(record["version"] == 2 and record["media_identity"] ==
|
|
{"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity"
|
|
assert any(record["completion_eligible"] for record in records), "acknowledged video progress eligible"
|
|
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
|
|
assert not errors, errors
|
|
assert {"/master.m3u8", "/variant.m3u8"}.issubset(seen), "master and nested playlist fetched"
|
|
if not zoko_only:
|
|
assert {"/english.jpeg", "/alternate/master.m3u8", "/alternate/english.jpeg"}.issubset(seen), "external VTT, alternate host and independent captions fetched"
|
|
assert any(path.endswith(".ts") for path in seen), "media segment fetched"
|
|
print("PASS: local Zoko metadata native playback and source preparation" if zoko_only else
|
|
"PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join()
|