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)
|
||||
|
||||
@@ -216,6 +216,10 @@ private enum CLIInstanceCheck {
|
||||
"hasAudio": model.state.has_audio,
|
||||
"hasVideo": model.state.has_video,
|
||||
"error": model.errorText ?? "",
|
||||
"subtitleLabels": model.subtitleTracks.map(\.label),
|
||||
"selectedSubtitle": model.selectedSubtitle ?? -1,
|
||||
"subtitleText": model.subtitleText,
|
||||
"subtitleError": model.subtitleError ?? "",
|
||||
"showBufferInfo": model.showsBufferInfo,
|
||||
"automaticQuality": model.automaticYouTubeQuality,
|
||||
"snapToEdges": model.compactWindow.snappingEnabled,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
@main
|
||||
struct ExternalSubtitleChecks {
|
||||
struct Failure: Error, CustomStringConvertible { let description: String }
|
||||
|
||||
static func require(_ condition: @autoclosure () throws -> Bool, _ message: String) throws {
|
||||
guard try condition() else { throw Failure(description: "FAIL: \(message)") }
|
||||
}
|
||||
|
||||
static func reject(_ operation: () throws -> Void) throws {
|
||||
do { try operation() }
|
||||
catch is Failure { throw Failure(description: "FAIL: rejection assertion") }
|
||||
catch { return }
|
||||
throw Failure(description: "FAIL: malformed input accepted")
|
||||
}
|
||||
|
||||
static let fixture = """
|
||||
WEBVTT example
|
||||
|
||||
NOTE ignored comment
|
||||
not a cue
|
||||
|
||||
STYLE
|
||||
::cue { color: white; }
|
||||
|
||||
REGION
|
||||
id:bottom
|
||||
width:80%
|
||||
|
||||
cue-one
|
||||
00:01.000 --> 00:04.000 line:90% position:50%,center align:center size:80% region:bottom
|
||||
<v Speaker><b>Hello & goodbye</b></v>
|
||||
second <c.yellow>line</c> <3 !
|
||||
|
||||
cue-two
|
||||
00:02.000 --> 00:03.000 vertical:rl
|
||||
Overlap <00:02.500><i>text</i>
|
||||
|
||||
00:04.000 --> 00:05.000
|
||||
Next
|
||||
|
||||
"""
|
||||
|
||||
static func main() async throws {
|
||||
let document = try WebVTTDocument.parse("\u{FEFF}" + fixture.replacingOccurrences(of: "\n", with: "\r\n"))
|
||||
let first = "Hello & goodbye\nsecond line <3 !"
|
||||
try require(document.cues.count == 3, "BOM, CRLF, identifiers and metadata blocks")
|
||||
try require(document.text(at: 0.999).isEmpty, "before first cue")
|
||||
try require(document.text(at: 1) == first, "inclusive start and plain-text markup/entities")
|
||||
try require(document.text(at: 2.5) == first + "\nOverlap text", "simultaneous overlapping cues")
|
||||
try require(document.text(at: 3) == first, "exclusive overlap end")
|
||||
try require(document.text(at: 4) == "Next", "adjacent exclusive end/inclusive start")
|
||||
try require(document.text(at: 10).isEmpty, "after final cue")
|
||||
try require(document.text(at: 2) == first + "\nOverlap text", "backward seek after final cue")
|
||||
try require(document.text(at: .nan).isEmpty && document.text(at: -1).isEmpty, "invalid acknowledged positions")
|
||||
let nested = try WebVTTDocument.parse("WEBVTT\n\n00:00.000 --> 01:00.000\nlong\n\n00:02.000 --> 00:03.000\nshort\n")
|
||||
try require(nested.text(at: 4) == "long", "earlier long cue survives later cue ending")
|
||||
try require(try WebVTTDocument.parse("WEBVTT\n\n01:02:03.004 --> 01:02:04.000\nhour\n").text(at: 3723.004) == "hour", "hour timestamps")
|
||||
let zeroDuration = try WebVTTDocument.parse("WEBVTT\n\n00:01.000 --> 00:01.000\nnever visible\n\n00:02.000 --> 00:03.000\nvalid later cue\n")
|
||||
try require(zeroDuration.text(at: 0.999).isEmpty && zeroDuration.text(at: 1).isEmpty && zeroDuration.text(at: 1.001).isEmpty, "zero-duration cue is never active")
|
||||
try require(zeroDuration.text(at: 2) == "valid later cue", "zero-duration cue does not invalidate later subtitles")
|
||||
try require(zeroDuration.text(at: 4).isEmpty && zeroDuration.text(at: 1).isEmpty, "zero-duration cue stays inactive after backward seek")
|
||||
for malformed in [
|
||||
"not VTT", "WEBVTT\n00:01.000 --> 00:02.000\nmissing separator",
|
||||
"WEBVTT\n\n00:61.000 --> 00:62.000\nbad time",
|
||||
"WEBVTT\n\n00:02.000 --> 00:01.000\nreversed interval",
|
||||
"WEBVTT\n\n00:01.000 --> 00:02.000 size:101%\nbad setting",
|
||||
"WEBVTT\n\n00:01.000 --> 00:02.000\n<b unfinished",
|
||||
"WEBVTT\n\norphan identifier", "WEBVTT\n\n00:01.000 --> 00:02.000\ncue\n\nSTYLE\nlate style"
|
||||
] { try reject { _ = try WebVTTDocument.parse(malformed) } }
|
||||
try reject { _ = try WebVTTDocument.parse(String(repeating: "x", count: WebVTTDocument.byteLimit + 1)) }
|
||||
|
||||
let headers = "Authorization: Bearer fixture-secret\r\nReferer: https://provider.example/\r\nX-Track: english\r\n"
|
||||
let request = try WebVTTDocument.request(url: "https://provider.example/track.jpeg", headers: headers)
|
||||
try require(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-secret", "resource authorization header")
|
||||
try require(request.value(forHTTPHeaderField: "X-Track") == "english", "resource-specific custom header")
|
||||
let whitespace = try WebVTTDocument.request(url: "https://provider.example/track", headers: "X-Exact: padded \t \r\n")
|
||||
try require(whitespace.value(forHTTPHeaderField: "X-Exact") == " padded \t ", "preserve header value whitespace after generated separator")
|
||||
for malformed in ["X: a\nInjected: value\r\n", "X: a\rInjected: value\r\n", "X: a\r\nX: b\r\n", "X: a", ": a\r\n"] {
|
||||
try reject { _ = try WebVTTDocument.request(url: "https://provider.example/track", headers: malformed) }
|
||||
}
|
||||
for url in ["file:///tmp/track", "https://user:secret@provider.example/track", "https://provider.example/track#fragment"] {
|
||||
try reject { _ = try WebVTTDocument.request(url: url, headers: "") }
|
||||
}
|
||||
let source = URL(string: "https://provider.example/track.jpeg")!
|
||||
try require(WebVTTDocument.permitsRedirect(from: source, to: URL(string: "https://PROVIDER.example:443/other")!), "same origin includes default port")
|
||||
for target in ["https://cdn.example/track", "http://provider.example/track", "https://provider.example:8443/track", "https://user:secret@provider.example/track"] {
|
||||
try require(!WebVTTDocument.permitsRedirect(from: source, to: URL(string: target)!), "cross-origin or credentialed redirect rejection")
|
||||
}
|
||||
|
||||
let server = try SubtitleFixtureServer(body: fixture)
|
||||
defer { server.stop() }
|
||||
let loaded = try await WebVTTDocument.load(url: server.url("/same.jpeg"), headers: headers)
|
||||
try require(loaded.cues == document.cues, "load JPEG-named, image/jpeg VTT via same-origin redirect")
|
||||
let requests = server.requests
|
||||
try require(requests.count == 2 && requests.allSatisfy { $0.contains("Bearer fixture-secret") && $0.contains("english") }, "resource headers preserved only on same origin")
|
||||
do {
|
||||
_ = try await WebVTTDocument.load(url: server.url("/cross.jpeg"), headers: headers)
|
||||
throw Failure(description: "FAIL: cross-origin redirect accepted")
|
||||
} catch is Failure {
|
||||
throw Failure(description: "FAIL: cross-origin redirect accepted")
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
try require(!message.contains("fixture-secret") && !message.contains("127.0.0.1"), "sanitized visible transport error")
|
||||
}
|
||||
try require(server.requests.count == 3, "cross-origin redirect never sends credential-bearing request")
|
||||
do {
|
||||
_ = try await WebVTTDocument.load(url: server.url("/oversized.jpeg"), headers: "")
|
||||
throw Failure(description: "FAIL: oversized response accepted")
|
||||
} catch is Failure { throw Failure(description: "FAIL: oversized response accepted") }
|
||||
catch { }
|
||||
print("External subtitle checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
/// A loopback-only HTTP fixture; no external services, dates, credentials or frameworks.
|
||||
private final class SubtitleFixtureServer: @unchecked Sendable {
|
||||
private let descriptor: Int32
|
||||
private let port: UInt16
|
||||
private let body: String
|
||||
private let lock = NSLock()
|
||||
private var recorded: [String] = []
|
||||
private let ended = DispatchSemaphore(value: 0)
|
||||
|
||||
var requests: [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return recorded
|
||||
}
|
||||
|
||||
init(body: String) throws {
|
||||
self.body = body
|
||||
let descriptor = socket(AF_INET, SOCK_STREAM, 0)
|
||||
self.descriptor = descriptor
|
||||
guard descriptor >= 0 else { throw ExternalSubtitleChecks.Failure(description: "FAIL: fixture socket") }
|
||||
var address = sockaddr_in()
|
||||
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
address.sin_family = sa_family_t(AF_INET)
|
||||
address.sin_addr.s_addr = inet_addr("127.0.0.1")
|
||||
let bound = withUnsafePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(descriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) }
|
||||
}
|
||||
var size = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let named = withUnsafeMutablePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(descriptor, $0, &size) }
|
||||
}
|
||||
guard bound == 0, named == 0, listen(descriptor, 4) == 0 else {
|
||||
close(descriptor)
|
||||
throw ExternalSubtitleChecks.Failure(description: "FAIL: fixture listener")
|
||||
}
|
||||
port = UInt16(bigEndian: address.sin_port)
|
||||
DispatchQueue.global().async { self.serve() }
|
||||
}
|
||||
|
||||
func url(_ path: String) -> String { "http://127.0.0.1:\(port)\(path)" }
|
||||
|
||||
func stop() {
|
||||
shutdown(descriptor, SHUT_RDWR)
|
||||
close(descriptor)
|
||||
ended.wait()
|
||||
}
|
||||
|
||||
private func serve() {
|
||||
defer { ended.signal() }
|
||||
while true {
|
||||
let client = accept(descriptor, nil, nil)
|
||||
guard client >= 0 else { return }
|
||||
var noSignal: Int32 = 1
|
||||
_ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout<Int32>.size))
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
var data = Data()
|
||||
while data.count < 65536 {
|
||||
let count = recv(client, &buffer, buffer.count, 0)
|
||||
if count <= 0 { break }
|
||||
data.append(contentsOf: buffer.prefix(count))
|
||||
if String(decoding: data, as: UTF8.self).contains("\r\n\r\n") { break }
|
||||
}
|
||||
let request = String(decoding: data, as: UTF8.self)
|
||||
lock.lock()
|
||||
recorded.append(request)
|
||||
lock.unlock()
|
||||
let path = request.split(separator: " ").dropFirst().first ?? ""
|
||||
let response: String
|
||||
switch path {
|
||||
case "/same.jpeg": response = "HTTP/1.1 302 Found\r\nLocation: /track.jpeg\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
case "/cross.jpeg": response = "HTTP/1.1 302 Found\r\nLocation: http://localhost:\(port)/track.jpeg\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
case "/oversized.jpeg": response = "HTTP/1.1 200 OK\r\nContent-Length: \(WebVTTDocument.byteLimit + 1)\r\nConnection: close\r\n\r\n"
|
||||
default: response = "HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)"
|
||||
}
|
||||
let bytes = Array(response.utf8)
|
||||
bytes.withUnsafeBytes { raw in
|
||||
var offset = 0
|
||||
while offset < raw.count {
|
||||
let count = send(client, raw.baseAddress!.advanced(by: offset), raw.count - offset, 0)
|
||||
if count <= 0 { break }
|
||||
offset += count
|
||||
}
|
||||
}
|
||||
close(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
#include "../Sources/Core/player_macos.h"
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <MetalKit/MetalKit.h>
|
||||
extern "C" {
|
||||
#include <libavutil/log.h>
|
||||
}
|
||||
#import <dispatch/dispatch.h>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
@@ -135,7 +138,9 @@ static bool openNetwork(Player *player, const std::string &base, const std::stri
|
||||
}
|
||||
|
||||
static void closed(Player *player) {
|
||||
auto state = until(player, [](auto s) { return s.status == PLAYER_EMPTY; }, "close network source");
|
||||
// A close submitted from PLAYER_ERROR may still publish the previous error
|
||||
// until the worker acknowledges it; require the complete clean state below.
|
||||
auto state = until(player, [](auto s) { return s.status == PLAYER_EMPTY; }, "close network source", true);
|
||||
require(state.position == 0 && state.duration == 0 && !state.has_audio && !state.has_video &&
|
||||
!state.can_seek && !state.buffering && !state.seek_pending && !state.error[0],
|
||||
"close clears all network/media/seek/error state");
|
||||
@@ -287,6 +292,94 @@ static void nativeHLS(Player *player, MTKView *view, const std::string &base) {
|
||||
std::puts("PASS native HLS: selected headers/recorded seekability/paused seek/audio-video resume");
|
||||
}
|
||||
|
||||
static bool openResolved(Player *player, const std::string &base, const std::string &scenario,
|
||||
bool credentials = true) {
|
||||
const auto url = base + "/resolved/" + scenario + "/master.m3u8?token=part%2Fone+two%26tail";
|
||||
const std::string headers = credentials ?
|
||||
"X-Terebi-Fixture: combined\r\nAuthorization: Bearer resolved-header-secret\r\n" :
|
||||
"X-Terebi-Fixture: combined\r\n";
|
||||
PlayerNetworkSource source{url.c_str(), nullptr, headers.c_str(), nullptr,
|
||||
4, false, "miruro:fixture_opaque-ID:1", 0};
|
||||
return player_open_network(player, &source);
|
||||
}
|
||||
|
||||
static void resolvedHLS(Player *player, MTKView *view, const std::string &base) {
|
||||
for (const std::string scenario : {"encrypted", "fmp4", "image-encrypted", "image-fmp4"}) {
|
||||
require(openResolved(player, base, scenario), "resolved HLS asynchronous open accepted");
|
||||
require(player_play(player), "resolved HLS play accepted");
|
||||
auto state = until(player, [](auto s) {
|
||||
return s.status == PLAYER_PLAYING && !s.buffering && s.position > .2;
|
||||
}, "resolved HLS audio/video decode");
|
||||
require(state.has_audio && state.has_video && state.can_seek,
|
||||
"encrypted/fMP4 HLS provides decoded audio, video and seekability");
|
||||
require(player_pause(player), "pause resolved HLS");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "resolved HLS pause");
|
||||
seekCompleted(player, 2.5, PLAYER_PAUSED);
|
||||
auto revision = player_get_state(player).revision;
|
||||
require(player_step_frame(player, 1), "step an actually decoded HLS video frame after seek");
|
||||
state = until(player, [revision](auto s) {
|
||||
return s.status == PLAYER_PAUSED && !s.seek_pending && s.revision != revision && s.position > 2.5;
|
||||
}, "resolved HLS decoded frame acknowledgement");
|
||||
require(state.position < 2.6, "HLS seek selects the adjacent 24fps decoded frame");
|
||||
[view draw];
|
||||
require(player_play(player), "resume resolved HLS");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.65; },
|
||||
"resolved HLS audio clock resumes after frame step");
|
||||
seekCompleted(player, 1.25, PLAYER_PLAYING);
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.4; },
|
||||
"resolved HLS playing backward seek");
|
||||
require(player_close(player), "close resolved HLS");
|
||||
closed(player);
|
||||
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"]
|
||||
[[NSString stringWithUTF8String:scenario.c_str()]];
|
||||
for (NSString *kind in @[@"master", @"variant", @"segment",
|
||||
scenario.ends_with("encrypted") ? @"key" : @"init"]) {
|
||||
require([source[kind][@"requests"] unsignedLongLongValue] > 0 &&
|
||||
[source[kind][@"denied"] unsignedLongLongValue] == 0,
|
||||
"required headers and unmodified query token reach every HLS resource class");
|
||||
}
|
||||
}
|
||||
|
||||
for (const std::string scenario : {"redirect-master", "redirect-variant", "redirect-segment",
|
||||
"redirect-key", "redirect-init", "redirect-same-master",
|
||||
"external-variant", "external-segment", "external-key", "external-init",
|
||||
"missing"}) {
|
||||
require(openResolved(player, base, scenario, scenario != "missing"), "unsafe HLS request accepted asynchronously");
|
||||
auto state = until(player, [](auto s) { return s.status == PLAYER_ERROR; },
|
||||
"unsafe HLS transport rejected", true);
|
||||
require(state.error[0] && std::string(state.error).find("http") == std::string::npos &&
|
||||
std::string(state.error).find("resolved-header-secret") == std::string::npos,
|
||||
"application retains meaningful transport error without credentials or URLs");
|
||||
if (scenario.starts_with("redirect-")) {
|
||||
const auto kind = scenario == "redirect-same-master" ? "master" : scenario.substr(9);
|
||||
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"]
|
||||
[[NSString stringWithUTF8String:scenario.c_str()]];
|
||||
require([source[[NSString stringWithUTF8String:kind.c_str()]][@"redirects"] unsignedLongLongValue] > 0,
|
||||
"redirect isolation actually exercises each HLS redirect resource");
|
||||
}
|
||||
if (scenario == "missing") {
|
||||
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"][@"missing"];
|
||||
require([source[@"master"][@"denied"] unsignedLongLongValue] > 0,
|
||||
"fixture explicitly fails a request missing a required credential");
|
||||
}
|
||||
require(player_close(player), "close rejected resolved source");
|
||||
closed(player);
|
||||
}
|
||||
NSDictionary *sink = control(base, "/control/resolved-stats")[@"sink"];
|
||||
require([sink[@"requests"] unsignedLongLongValue] == 0 &&
|
||||
[sink[@"credentials"] unsignedLongLongValue] == 0,
|
||||
"neither redirects nor explicit foreign HLS URLs contact the unrelated origin");
|
||||
require(openNetwork(player, base, "/youtube-redirect/av.mp4", "", 4, false, true,
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAAA"),
|
||||
"YouTube selected source retains ordinary HTTP redirect behavior");
|
||||
require(player_play(player), "redirected YouTube fixture play");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .2 && s.has_video && s.has_audio; },
|
||||
"YouTube transport unchanged by resolved source restrictions");
|
||||
require(player_close(player), "close redirected YouTube fixture");
|
||||
closed(player);
|
||||
std::puts("PASS resolved HLS: AES-128/fMP4 audio+video decode/paused+playing seek/frame step/all resource credentials/token preservation/redirect and foreign-origin isolation/redacted errors/YouTube redirects unchanged");
|
||||
}
|
||||
|
||||
static void liveAndReplacement(Player *player, const std::string &base, const std::string &fixtures) {
|
||||
require(openNetwork(player, base, "/media/av.mp4", "", 4, true), "live-flag network open");
|
||||
auto live = until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
|
||||
@@ -798,6 +891,7 @@ int main(int argc, char **argv) {
|
||||
require(argc == 3, "loopback base URL and local fixture directory required");
|
||||
std::string base = argv[1], fixtures = argv[2];
|
||||
require(base.rfind("http://127.0.0.1:", 0) == 0, "network fixture server is loopback-only");
|
||||
av_log_set_level(AV_LOG_TRACE); // Diagnostics must be safe even at maximal FFmpeg verbosity.
|
||||
[NSApplication sharedApplication];
|
||||
reporting(base, fixtures);
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
||||
@@ -819,6 +913,7 @@ int main(int argc, char **argv) {
|
||||
transport(player.get(), view, base, true);
|
||||
frameSteps(player.get(), view, base);
|
||||
nativeHLS(player.get(), view, base);
|
||||
resolvedHLS(player.get(), view, base);
|
||||
liveAndReplacement(player.get(), base, fixtures);
|
||||
cachedWindow(player.get(), base);
|
||||
bufferCoverage(player.get(), base);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
@main
|
||||
struct PlayerCommandChecks {
|
||||
@@ -72,6 +73,64 @@ struct PlayerCommandChecks {
|
||||
require(try opened(["--", "--progress-file"]) == reportFlagFile,
|
||||
"escaped reporting option remains a filename")
|
||||
|
||||
let sourceFile = directory.appendingPathComponent("source with spaces.json")
|
||||
let sourceURL = "https://cdn.example/video/master.m3u8?token=secret%2Bvalue"
|
||||
var source: [String: Any] = [
|
||||
"version": 1, "url": sourceURL, "headers": ["Authorization": "Bearer secret "],
|
||||
"identity": ["provider": "miruro", "media_id": "opaque_A-1", "episode": 12],
|
||||
"subtitles": [["url": "https://cdn.example/sub.vtt?token=secret", "format": "vtt",
|
||||
"language": "en", "label": "English", "default": true, "headers": ["X-Token": "secret"]]]
|
||||
]
|
||||
func saveSource() throws {
|
||||
try JSONSerialization.data(withJSONObject: source).write(to: sourceFile)
|
||||
}
|
||||
try saveSource()
|
||||
let resolved = try ResolvedSource.load(from: sourceFile)
|
||||
require(resolved.url == sourceURL && resolved.headers == "Authorization: Bearer secret \r\n",
|
||||
"resolved URLs and header values remain byte-for-byte intact")
|
||||
require(resolved.identity == "miruro:opaque_A-1:12" && resolved.title == "Miruro episode 12",
|
||||
"stable identity excludes CDN tokens and omitted title has a useful fallback")
|
||||
require(resolved.subtitles.count == 1 && resolved.subtitles[0].isDefault &&
|
||||
resolved.subtitles[0].headers == "X-Token: secret\r\n", "subtitle metadata retains independent headers")
|
||||
guard case .launchSource(let delivered, let independent, let bypass, let output) =
|
||||
try PlayerCommand.parse(["--new-instance", "--no-config", "--source-file", sourceFile.lastPathComponent,
|
||||
"--progress-file", report.lastPathComponent], directory: directory) else {
|
||||
fatalError("FAIL: expected resolved source launch")
|
||||
}
|
||||
require(delivered == sourceFile && independent && bypass && output == report,
|
||||
"CLI forwards only an absolute source-file path with existing launch options")
|
||||
for arguments in [["--source-file", sourceFile.path], ["--new-instance", "--source-file"],
|
||||
["--new-instance", "--source-file", sourceFile.path, movie.path]] {
|
||||
do { _ = try PlayerCommand.parse(arguments, directory: directory); fatalError("FAIL: invalid source launch accepted") }
|
||||
catch {}
|
||||
}
|
||||
let original = source
|
||||
let invalidChanges: [[String: Any]] = [
|
||||
["version": 2], ["url": "https://user:secret@cdn.example/video"],
|
||||
["url": "file:///secret"], ["url": "https://cdn.example/video\nsecret"],
|
||||
["headers": ["X-Token": "secret\r\nInjected: yes"]], ["headers": ["X-Token": "secret\t"]],
|
||||
["headers": ["Bad Name": "secret"]], ["headers": ["X-Token": "one", "x-token": "two"]],
|
||||
["identity": ["provider": "miruro", "media_id": "bad:id", "episode": 1]],
|
||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 0]],
|
||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 1.5]],
|
||||
["subtitles": Array(repeating: (original["subtitles"] as! [[String: Any]])[0], count: 2)]
|
||||
]
|
||||
for change in invalidChanges {
|
||||
source = original.merging(change) { _, replacement in replacement }
|
||||
try saveSource()
|
||||
do { _ = try ResolvedSource.load(from: sourceFile); fatalError("FAIL: invalid source accepted") }
|
||||
catch { require(!error.localizedDescription.contains("secret"), "validation never reveals credentials") }
|
||||
}
|
||||
try Data(repeating: 32, count: 1_048_577).write(to: sourceFile)
|
||||
do { _ = try ResolvedSource.load(from: sourceFile); fatalError("FAIL: oversized source accepted") }
|
||||
catch {}
|
||||
let fifo = directory.appendingPathComponent("source.fifo")
|
||||
require(mkfifo(fifo.path, 0o600) == 0, "create unread FIFO source boundary")
|
||||
do {
|
||||
_ = try PlayerCommand.parse(["--new-instance", "--source-file", fifo.path], directory: directory)
|
||||
fatalError("FAIL: FIFO source accepted")
|
||||
} catch {}
|
||||
|
||||
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
|
||||
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
|
||||
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"],
|
||||
|
||||
@@ -248,6 +248,47 @@ int main(int argc, char **argv) {
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAA%", "https://www.youtube.com/watch?v=AAAAAAAAAA\n"})
|
||||
require(!ProgressReporter::validMediaURL(invalid), "noncanonical URLs are rejected");
|
||||
|
||||
require(ProgressReporter::validMediaIdentity("miruro:opaque_A-1:12") &&
|
||||
ProgressReporter::validMediaIdentity("miruro:a:2147483647"), "strict Miruro identities accepted");
|
||||
for (const char *invalid : {"miruro::1", "miruro:a:0", "miruro:a:01", "miruro:a:-1",
|
||||
"miruro:a:1.5", "miruro:a:2147483648", "miruro:a:1?token=secret",
|
||||
"miruro:a:b:1", "miruro:a:1\n", "miruro:bad/id:1", "miruro:é:1"})
|
||||
require(!ProgressReporter::validMediaIdentity(invalid), "ambiguous or token-bearing identities rejected");
|
||||
const auto miruroPath = root + "/miruro.jsonl";
|
||||
{
|
||||
ProgressReporter reporter(miruroPath, [] { return 0.; });
|
||||
reporter.begin("miruro:opaque_A-1:12");
|
||||
reporter.observe(sample(PLAYER_PLAYING, 95));
|
||||
reporter.begin("miruro:opaque_A-1:13");
|
||||
reporter.observe(sample(PLAYER_ENDED, 100));
|
||||
reporter.finish();
|
||||
require(reporter.error().empty(), "Miruro progress drains successfully");
|
||||
}
|
||||
NSString *miruroText = [NSString stringWithContentsOfFile:nativePath(miruroPath)
|
||||
encoding:NSUTF8StringEncoding error:nullptr];
|
||||
NSArray<NSString *> *miruroLines = [miruroText componentsSeparatedByString:@"\n"];
|
||||
require(miruroLines.count == 5, "Miruro replacement emits progress, close, ended, close");
|
||||
NSSet *miruroKeys = [NSSet setWithArray:@[@"version", @"event", @"media_identity",
|
||||
@"position_seconds", @"duration_seconds", @"completion_eligible"]];
|
||||
for (NSUInteger i = 0; i < 4; ++i) {
|
||||
NSDictionary *row = [NSJSONSerialization JSONObjectWithData:[miruroLines[i]
|
||||
dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nullptr];
|
||||
require([row isKindOfClass:[NSDictionary class]] &&
|
||||
[[NSSet setWithArray:row.allKeys] isEqual:miruroKeys] &&
|
||||
[row[@"version"] intValue] == 2, "Miruro schema is v2 without media_url");
|
||||
NSDictionary *identity = row[@"media_identity"];
|
||||
require([identity isKindOfClass:[NSDictionary class]] &&
|
||||
[[NSSet setWithArray:identity.allKeys] isEqual:
|
||||
[NSSet setWithArray:@[@"provider", @"media_id", @"episode"]]] &&
|
||||
[identity[@"provider"] isEqual:@"miruro"] &&
|
||||
[identity[@"media_id"] isEqual:@"opaque_A-1"] &&
|
||||
[identity[@"episode"] intValue] == (i < 2 ? 12 : 13),
|
||||
"structured stable episode identity contains no CDN token");
|
||||
require([row[@"event"] isEqual:(i == 0 ? @"progress" : i == 2 ? @"ended" : @"closed")] &&
|
||||
[row[@"position_seconds"] doubleValue] == (i < 2 ? 95 : 100) &&
|
||||
[row[@"completion_eligible"] boolValue], "Miruro retains terminal snapshot semantics");
|
||||
}
|
||||
|
||||
const auto clockFailurePath = root + "/clock-failure.jsonl";
|
||||
{
|
||||
ProgressReporter reporter(clockFailurePath, []() -> double { throw std::runtime_error("clock failure"); });
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Local-only resolved HLS/VTT + native model smoke; no live requests."""
|
||||
import http.server
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
binary = str(Path(sys.argv[1]).resolve())
|
||||
fixtures = Path(sys.argv[2]).resolve()
|
||||
seen = []
|
||||
errors = []
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
path = urlsplit(self.path).path
|
||||
seen.append(path)
|
||||
expected = "subtitle-private" if path == "/english.jpeg" 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":
|
||||
if urlsplit(self.path).query != "token=fixture":
|
||||
errors.append("master token lost")
|
||||
data = b'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\nvariant.m3u8?token=variant\n'
|
||||
elif path == "/variant.m3u8":
|
||||
if urlsplit(self.path).query != "token=variant":
|
||||
errors.append("variant token lost")
|
||||
data = (fixtures / "resolved-vod.m3u8").read_bytes()
|
||||
elif path == "/english.jpeg":
|
||||
data = b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n'
|
||||
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), str(progress)], check=True, timeout=100)
|
||||
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", "/english.jpeg"}.issubset(seen), "master, nested playlist and external VTT fetched"
|
||||
assert any(path.endswith(".ts") for path in seen), "media segment fetched"
|
||||
print("PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
@@ -0,0 +1,66 @@
|
||||
import AppKit
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct ResolvedPlaybackCheck {
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError("FAIL: " + message) }
|
||||
}
|
||||
static func until(_ message: String, model: PlayerModel, _ predicate: () -> Bool) {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(45))
|
||||
while !predicate(), ContinuousClock.now < deadline {
|
||||
if model.state.status == PLAYER_ERROR || model.commandError != nil {
|
||||
fatalError("FAIL: " + (model.errorText ?? "Playback failed"))
|
||||
}
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01))
|
||||
}
|
||||
require(predicate(), message)
|
||||
}
|
||||
static func main() async {
|
||||
do { try await run() }
|
||||
catch { fatalError("FAIL: " + error.localizedDescription) }
|
||||
}
|
||||
|
||||
static func run() async throws {
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
let file = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let source = try ResolvedSource.load(from: file)
|
||||
let suite = "terebi.resolved-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil)
|
||||
defer { model.shutdown() }
|
||||
model.openResolvedSource(file)
|
||||
until("video/audio decode and advancing playback", model: model) {
|
||||
model.state.status == PLAYER_PLAYING && model.state.has_video && model.state.has_audio && model.state.position > 0.2
|
||||
}
|
||||
print("PASS: resolved HLS decodes video/audio and advances")
|
||||
fflush(stdout)
|
||||
require(model.setPlayback(playing: false), "pause accepted")
|
||||
until("pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
require(model.state.can_seek && model.state.duration > 0, "seekable measured duration")
|
||||
guard let index = source.subtitles.firstIndex(where: \.isDefault) else {
|
||||
fatalError("FAIL: smoke requires a default subtitle")
|
||||
}
|
||||
let track = source.subtitles[index]
|
||||
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
||||
let playableCues = document.cues.filter { !$0.text.isEmpty && $0.start + 0.1 < model.state.duration }
|
||||
require(playableCues.count >= 2, "smoke requires two visible subtitle cues")
|
||||
let target = playableCues[1].start + min(0.1, (playableCues[1].end - playableCues[1].start) / 2)
|
||||
require(!document.text(at: target).isEmpty, "forward seek targets a visible English cue")
|
||||
require(model.seekRelative(target - model.state.position), "seek accepted")
|
||||
until("seek acknowledged and subtitle synchronized", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - target) < 0.2 &&
|
||||
model.subtitleText == document.text(at: model.state.position) && model.subtitleError == nil
|
||||
}
|
||||
require(model.selectedSubtitle == index && model.subtitleTracks[index].label == track.label, "default selection and label retained")
|
||||
let backward = playableCues[0].start + min(0.1, (playableCues[0].end - playableCues[0].start) / 2)
|
||||
require(model.seekRelative(backward - model.state.position), "backward seek accepted")
|
||||
until("backward seek subtitle synchronized", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - backward) < 0.2 &&
|
||||
model.subtitleText == document.text(at: model.state.position)
|
||||
}
|
||||
print("PASS: default VTT selected, forward/backward seek and acknowledged cue timing")
|
||||
}
|
||||
}
|
||||
+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