feat: play resolved Miruro HLS with external VTT and stable episode progress
This commit is contained in:
+101
-6
@@ -11,7 +11,29 @@ import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from streaming import StreamingServer
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from streaming import StreamingServer, resolved_fixtures
|
||||
|
||||
|
||||
class StartupStreamingServer(StreamingServer):
|
||||
def __init__(self, fixtures):
|
||||
super().__init__(fixtures)
|
||||
self.subtitle_requests = [0, 0]
|
||||
|
||||
def _get(self, request):
|
||||
parsed = urlsplit(request.path)
|
||||
if parsed.path in ("/startup-0.vtt", "/startup-1.vtt"):
|
||||
index = int(parsed.path[len("/startup-")])
|
||||
if parsed.query != self.resolved_token or request.headers.get("X-Subtitle") != f"track-{index}":
|
||||
self._reply(request, 403, b"Missing subtitle credentials")
|
||||
return
|
||||
with self.lock:
|
||||
self.subtitle_requests[index] += 1
|
||||
body = f"WEBVTT\n\n00:00:00.000 --> 00:01:00.000\nIndependent subtitle {index}\n".encode()
|
||||
self._reply(request, 200, body, "text/vtt")
|
||||
return
|
||||
super()._get(request)
|
||||
|
||||
|
||||
def check_cli_instances(root, app, fixtures):
|
||||
@@ -87,13 +109,15 @@ def check_cli_instances(root, app, fixtures):
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
records = [json.loads(line) for line in lines if line]
|
||||
fields = {"version", "event", "media_url", "position_seconds", "duration_seconds", "completion_eligible"}
|
||||
miruro = isinstance(allowed, list)
|
||||
identity_field = "media_identity" if miruro else "media_url"
|
||||
fields = {"version", "event", identity_field, "position_seconds", "duration_seconds", "completion_eligible"}
|
||||
for record in records:
|
||||
duration = record.get("duration_seconds")
|
||||
position = record.get("position_seconds")
|
||||
valid = (set(record) == fields and record["version"] == 1
|
||||
valid = (set(record) == fields and record["version"] == (2 if miruro else 1)
|
||||
and record["event"] in ("progress", "ended", "closed")
|
||||
and record["media_url"] in allowed
|
||||
and record[identity_field] in allowed
|
||||
and type(position) in (int, float) and math.isfinite(position) and position >= 0
|
||||
and (duration is None or (type(duration) in (int, float)
|
||||
and math.isfinite(duration) and duration > 0))
|
||||
@@ -134,14 +158,81 @@ def check_cli_instances(root, app, fixtures):
|
||||
while time.monotonic() < deadline:
|
||||
survivors = [json.loads(path.read_text()) for path in root.glob("state-*.json")
|
||||
if path.stem != f"state-{pid}"]
|
||||
if survivors and all(pid not in state["pids"] for state in survivors):
|
||||
exited = False
|
||||
if count == 1:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
exited = True
|
||||
if exited or (survivors and all(pid not in state["pids"] for state in survivors)):
|
||||
(root / f"state-{pid}.json").unlink()
|
||||
(root / f"keys-{pid}.json").unlink()
|
||||
return wait_states(count - 1, lambda _: True, "normal quit unregisters only the targeted GUI")
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError(f"normal quit did not unregister GUI PID {pid}")
|
||||
|
||||
with StreamingServer(fixtures) as server:
|
||||
def check_resolved_instances(server, count):
|
||||
originals = wait_states(count, lambda _: True, "existing players are ready before resolved launches")
|
||||
states = originals
|
||||
resolved_pids = []
|
||||
resolved_reports = []
|
||||
identities = []
|
||||
for index in range(2):
|
||||
source = root / f"resolved source {index}.json"
|
||||
report = root / f"resolved progress {index}.jsonl"
|
||||
identity = {"provider": "miruro", "media_id": "startup_opaque", "episode": index + 1}
|
||||
source.write_text(json.dumps({
|
||||
"version": 1,
|
||||
"url": server.base_url + "/resolved/encrypted/master.m3u8?" + server.resolved_token,
|
||||
"headers": {"X-Terebi-Fixture": "combined", "Authorization": "Bearer " + server.resolved_secret},
|
||||
"identity": identity, "title": f"Independent resolved episode {index + 1}",
|
||||
"subtitles": [{"url": server.base_url + f"/startup-{index}.vtt?" + server.resolved_token,
|
||||
"format": "vtt", "language": "en", "label": f"Track {index}",
|
||||
"default": True, "headers": {"X-Subtitle": f"track-{index}"}}]
|
||||
}))
|
||||
source.chmod(0o600)
|
||||
previous = set(states)
|
||||
invoke(["--new-instance", "--source-file", str(source), "--progress-file", str(report)])
|
||||
states = wait_states(count + index + 1, lambda snapshots: any(
|
||||
pid not in previous and state["media"] == f"Independent resolved episode {index + 1}"
|
||||
and state["status"] == 3 and state["hasVideo"] and state["hasAudio"]
|
||||
and state["canSeek"] and not state["busy"] and not state["seekPending"]
|
||||
and state["subtitleLabels"] == [f"Track {index}"] and state["selectedSubtitle"] == 0
|
||||
and state["subtitleText"] == f"Independent subtitle {index}" and not state["subtitleError"]
|
||||
for pid, state in snapshots.items()), "actual source-file CLI reaches registered GUI HLS and VTT playback")
|
||||
pid = next(pid for pid in states if pid not in previous)
|
||||
states = input_request(pid, count + index + 1, lambda state: state["status"] == 2,
|
||||
"resolved GUI pauses independently after decoded startup", action="pause")
|
||||
wait_report(report, [identity], lambda rows: any(
|
||||
row["event"] == "progress" and row["completion_eligible"] for row in rows),
|
||||
"resolved GUI owns v2 stable-identity reporting after CLI exit")
|
||||
resolved_pids.append(pid)
|
||||
resolved_reports.append(report)
|
||||
identities.append(identity)
|
||||
for pid, original in originals.items():
|
||||
if states[pid]["media"] != original["media"] or settings(states[pid]) != settings(original):
|
||||
raise RuntimeError("resolved launches disturbed an existing player")
|
||||
with server.lock:
|
||||
stats = server.resolved_stats.get("encrypted", {})
|
||||
if any(server.subtitle_requests[index] == 0 for index in range(2)) or any(
|
||||
stats.get(kind, {}).get("requests", 0) == 0 or stats[kind]["denied"]
|
||||
for kind in ("master", "variant", "key", "segment")):
|
||||
raise RuntimeError("actual CLI playback lost complete HLS URLs, credentials, or independent VTT headers")
|
||||
for index in reversed(range(2)):
|
||||
pid = resolved_pids[index]
|
||||
quit_instance(pid, count + index + 1)
|
||||
wait_report(resolved_reports[index], [identities[index]],
|
||||
lambda rows: bool(rows) and rows[-1]["event"] == "closed",
|
||||
"resolved GUI quit drains only its stable episode identity")
|
||||
report_bytes = resolved_reports[index].read_bytes()
|
||||
if not report_bytes.endswith(b"\n") or any(secret in report_bytes for secret in (
|
||||
server.resolved_token.encode(), server.resolved_secret.encode(), server.base_url.encode())):
|
||||
raise RuntimeError("resolved report is incomplete or exposes transport secrets")
|
||||
wait_states(count, lambda _: True, "original players survive independent resolved GUI quits")
|
||||
print("PASS actual resolved CLI handoff: two isolated GUI episodes, authenticated encrypted HLS, independent default VTT, v2 progress and quit")
|
||||
|
||||
resolved_fixtures(fixtures)
|
||||
with StartupStreamingServer(fixtures) as server:
|
||||
if extractor and runtime:
|
||||
choices = []
|
||||
for index, url in enumerate([canonical, *reported_urls[:3]]):
|
||||
@@ -174,6 +265,9 @@ def check_cli_instances(root, app, fixtures):
|
||||
runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
|
||||
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings))
|
||||
subprocess.run(["codesign", "--force", "--sign", "-", str(app)], check=True)
|
||||
if os.environ.get("TEREBI_CHECK_RESOLVED_ONLY") == "1":
|
||||
check_resolved_instances(server, 0)
|
||||
return
|
||||
movie = fixtures / "cache.mp4"
|
||||
audio = root / "audio with spaces.wav"
|
||||
shutil.copy2(fixtures / "audio.wav", audio)
|
||||
@@ -283,6 +377,7 @@ def check_cli_instances(root, app, fixtures):
|
||||
else:
|
||||
print("SKIP independent YouTube playback: yt-dlp and a supported JS runtime are required")
|
||||
|
||||
check_resolved_instances(server, count)
|
||||
existing_report = root / "existing progress.jsonl"
|
||||
sentinel = b"existing destination must remain byte-for-byte unchanged\n"
|
||||
existing_report.write_bytes(sentinel)
|
||||
|
||||
Reference in New Issue
Block a user