From 470d8feec202c6edf34372f8caf13ccb85a54911 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 12 Oct 2026 19:17:22 +0200 Subject: [PATCH] feat: play resolved Miruro HLS with external VTT and stable episode progress --- CONTEXT.md | 4 +- Checks/app-startup.py | 107 ++++++++++- Checks/app-startup.swift | 4 + Checks/external-subtitles.swift | 204 +++++++++++++++++++++ Checks/network-playback.mm | 97 +++++++++- Checks/player-command.swift | 59 ++++++ Checks/progress-reporting.mm | 41 +++++ Checks/resolved-playback.py | 79 ++++++++ Checks/resolved-playback.swift | 66 +++++++ Checks/streaming.py | 138 +++++++++++++- Makefile | 25 ++- README.md | 64 ++++++- Sources/App/ExternalSubtitles.swift | 275 ++++++++++++++++++++++++++++ Sources/App/ResolvedSource.swift | 122 ++++++++++++ Sources/App/TerebiApp.swift | 114 ++++++++++++ Sources/CLI/PlayerCommand.swift | 20 +- Sources/CLI/TerebiCLI.swift | 11 +- Sources/Core/network_input.hpp | 4 + Sources/Core/player.cpp | 84 ++++++++- Sources/Core/player.h | 3 +- Sources/Core/progress_reporter.cpp | 42 ++++- Sources/Core/progress_reporter.hpp | 1 + TODO.md | 2 + 23 files changed, 1528 insertions(+), 38 deletions(-) create mode 100644 Checks/external-subtitles.swift create mode 100644 Checks/resolved-playback.py create mode 100644 Checks/resolved-playback.swift create mode 100644 Sources/App/ExternalSubtitles.swift create mode 100644 Sources/App/ResolvedSource.swift create mode 100644 TODO.md diff --git a/CONTEXT.md b/CONTEXT.md index 9ee0c5f..8098e64 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # Terebi playback -Terebi plays local media and YouTube videos. These terms distinguish a playback request from the state the player has actually reached. +Terebi plays local media, YouTube videos and resolved Miruro episodes. These terms distinguish a playback request from the state the player has actually reached. ## Language @@ -17,4 +17,4 @@ The playback position confirmed by the player after any pending seek or restorat Resuming a video's saved position, or preserving position and playing or paused state while changing its quality. **Media identity**: -The identity of the video being played, independent of its selected quality or extracted stream address. For a YouTube video, this is its canonical video webpage URL. +The identity of the video being played, independent of its selected quality or extracted stream address. For a YouTube video, this is its canonical video webpage URL. For a Miruro episode, this is its opaque catalog media ID and episode number, independent of provider and expiring source URL. diff --git a/Checks/app-startup.py b/Checks/app-startup.py index 0ea58dc..9eea0a0 100644 --- a/Checks/app-startup.py +++ b/Checks/app-startup.py @@ -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) diff --git a/Checks/app-startup.swift b/Checks/app-startup.swift index 2b915b4..9e773c4 100644 --- a/Checks/app-startup.swift +++ b/Checks/app-startup.swift @@ -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, diff --git a/Checks/external-subtitles.swift b/Checks/external-subtitles.swift new file mode 100644 index 0000000..15b13a7 --- /dev/null +++ b/Checks/external-subtitles.swift @@ -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 + Hello & goodbye + second line <3 ! + + cue-two + 00:02.000 --> 00:03.000 vertical:rl + Overlap <00:02.500>text + + 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 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.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.size)) } + } + var size = socklen_t(MemoryLayout.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.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) + } + } +} diff --git a/Checks/network-playback.mm b/Checks/network-playback.mm index 28dbfc1..e933c3c 100644 --- a/Checks/network-playback.mm +++ b/Checks/network-playback.mm @@ -1,6 +1,9 @@ #include "../Sources/Core/player_macos.h" #import #import +extern "C" { +#include +} #import #include #include @@ -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(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); diff --git a/Checks/player-command.swift b/Checks/player-command.swift index 605a77a..200596d 100644 --- a/Checks/player-command.swift +++ b/Checks/player-command.swift @@ -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"], diff --git a/Checks/progress-reporting.mm b/Checks/progress-reporting.mm index fc086be..777210c 100644 --- a/Checks/progress-reporting.mm +++ b/Checks/progress-reporting.mm @@ -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 *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"); }); diff --git a/Checks/resolved-playback.py b/Checks/resolved-playback.py new file mode 100644 index 0000000..dab0567 --- /dev/null +++ b/Checks/resolved-playback.py @@ -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() diff --git a/Checks/resolved-playback.swift b/Checks/resolved-playback.swift new file mode 100644 index 0000000..fc586ba --- /dev/null +++ b/Checks/resolved-playback.swift @@ -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") + } +} diff --git a/Checks/streaming.py b/Checks/streaming.py index 0f03c49..1a8b2a5 100755 --- a/Checks/streaming.py +++ b/Checks/streaming.py @@ -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) diff --git a/Makefile b/Makefile index 3290b43..29cf43d 100644 --- a/Makefile +++ b/Makefile @@ -13,9 +13,9 @@ PREFIX ?= $(HOME)/.local CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS) FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o -SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift +SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift -CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE) +CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE) CONFIG_BUILD := .build/configuration CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path) CONFIG_LIBRARY := $(CONFIG_BIN)/libTerebiConfiguration.a @@ -27,7 +27,7 @@ CLI := $(APP)/Contents/MacOS/terebi-cli BUNDLE_STAMP := $(BUILD_DIR)/bundle.stamp SIGNING_IDENTITY ?= - -.PHONY: all release install check-dependencies run check check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle +.PHONY: all release install check-dependencies run check check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved all: $(BUILD_DIR)/terebi $(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json @@ -102,7 +102,7 @@ run: all $(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies $(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@ -check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle +check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved $(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures check-fixtures: | check-dependencies @@ -191,12 +191,25 @@ $(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core check-reporting: $(BUILD_DIR)/progress-reporting-check $(BUILD_DIR)/progress-reporting-check -$(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Makefile | $(BUILD_DIR) check-dependencies - $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@ +$(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Makefile | $(BUILD_DIR) check-dependencies + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift -framework Foundation -o $@ check-cli: $(BUILD_DIR)/player-command-check $(BUILD_DIR)/player-command-check +$(BUILD_DIR)/external-subtitles-check: Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift Makefile | $(BUILD_DIR) + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift -framework Foundation -o $@ + +check-subtitles: $(BUILD_DIR)/external-subtitles-check + $(BUILD_DIR)/external-subtitles-check + +$(BUILD_DIR)/resolved-playback-check: Checks/resolved-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ + +check-resolved: $(BUILD_DIR)/resolved-playback-check check-fixtures + python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures + + $(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) Makefile | $(BUILD_DIR) $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) -framework Foundation -o $@ diff --git a/README.md b/README.md index 1a5d660..d8945b2 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Omitted settings/actions retain their defaults. A shortcut string or string arra Bindings name explicit actions, not toggles. The same chord may be shared only by inverse pairs shown above: play/pause, mute/unmute, enter/exit fullscreen or compact, enable/disable always-on-top, show/hide buffer info, and enable/disable snapping or automatic quality. A press performs **one currently applicable action**, including pending playback/window intent; already-satisfied actions do nothing. Give each side different chords for dedicated enable/disable keys. Sharing a chord between unrelated actions, duplicate normalized chords, unknown actions, and malformed shortcuts are errors. -For YouTube, bind `quality_p` (for example, `quality_2160p = "ctrl+4"`) to select an available resolution, or `quality_default` to restore the capped default. Resolution actions are unavailable for local media and absent resolutions; selecting manual/default quality disables automatic quality. Captions are not implemented, so caption and `toggle_*` action names are rejected. +For YouTube, bind `quality_p` (for example, `quality_2160p = "ctrl+4"`) to select an available resolution, or `quality_default` to restore the capped default. Resolution actions are unavailable for local media and absent resolutions; selecting manual/default quality disables automatic quality. YouTube captions and caption key actions are not implemented; resolved-source VTT selection uses the captions menu. Caption and `toggle_*` action names are rejected. Behavior/UI switches must be TOML booleans; `[ui].compact_title_progress` is a string: `"time"` (default), `"percentage"`, or `"none"`. Unknown keys, unsupported values, malformed TOML, wrong types, unreadable files, and a directory in place of the file are errors, not silent defaults. Top-level `automatic_quality` is no longer accepted: move it under `[playback]`. The old flat `show_buffer_info` and `snap_to_edges` settings are also rejected: move them to `[ui]` as `buffer_info` and `compact_snap_to_edges`. GUI startup errors show a native alert. Settings are read once at startup; control/menu changes are **session-only** and never write the file. Edit it yourself and start a fresh player to apply changes. @@ -147,9 +147,65 @@ While playing YouTube, choose **gear → Quality** in either normal or compact m The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, update the pinned helper versions/checksums and rebuild/reinstall terebi; upgrading an external Homebrew yt-dlp does not change the bundled extractor. +## Resolved HLS sources (Miruro / Tubular) + +Tubular owns discovery, episode/provider selection and fresh source resolution. Terebi accepts a resolved source document; it does not scrape Miruro, embed `strm.cx`, refresh expired sources or bypass protection. + +```sh +terebi --new-instance --source-file '/private/session/source.json' \ + --progress-file '/private/session/progress.jsonl' +``` + +`--source-file` requires `--new-instance` and accepts a filesystem path (relative paths resolve against the shell's directory). The CLI validates the document, then forwards its absolute path to the GUI; only paths, not stream URLs or header values, enter launch arguments. Create the session directory with mode `0700` and the source file with mode `0600`. Keep the file unchanged and present for the entire player session; launcher success does not acknowledge that the GUI has read it. The caller owns cleanup. + +Source document syntax (replace example URLs with freshly resolved resources): + +```json +{ + "version": 1, + "url": "https://cdn.example/master.m3u8?token=fresh", + "headers": {"Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/"}, + "identity": {"provider": "miruro", "media_id": "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw", "episode": 1}, + "title": "The Apothecary Diaries — episode 1", + "subtitles": [{ + "url": "https://cdn.example/english.jpeg?token=fresh", + "format": "vtt", + "language": "en", + "label": "English", + "default": true, + "headers": {"Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/"} + }] +} +``` + +All fields except `title` are required; `subtitles: []` and `headers: {}` are valid. Subtitle headers never inherit stream headers: Tubular must explicitly supply the appropriate map for each resource. Values are preserved without trimming; invalid HTTP names, duplicate case-insensitive names and control characters are rejected. Documents are limited to 1 MiB, 32 subtitle tracks and 64 headers per resource. Miruro IDs are nonempty ASCII letters/digits/`-`/`_` (up to 512 bytes); episode numbers are integers from 1 through 2147483647. + +Native FFmpeg plays the master and nested playlists, media segments, AES-128 keys and fMP4 init resources with the stream's headers. Relative URLs use native HLS resolution; supplied URLs and query tokens are retained, not synthesized onto unrelated references. Resolved media resources must remain on the initial stream's origin (scheme, host and effective port). All media redirects, including same-origin redirects, are rejected before a second request; this deliberately prevents FFmpeg from forwarding private headers to an unrelated destination. YouTube keeps its existing transport policy. + +Sun can serve MPEG-TS segments as `.jpg`/`image/jpeg`. Resolved HLS uses content probing instead of FFmpeg's filename-extension gate; URLs are not rewritten. The HTTP(S)-only, same-origin and no-redirect restrictions still apply. + +External subtitles use explicit `format: "vtt"` plus WebVTT content, never the filename extension or MIME type. The captions menu shows labels/languages, selects the declared default and permits Off/another track. Plain-text cues follow acknowledged playback position, including backward/forward seeks; WebVTT positioning, CSS and typographic styling are not applied. Subtitle loading is bounded to 4 MiB with 15-second request/20-second resource timeouts. Same-origin subtitle redirects retain that resource's headers; cross-origin redirects are rejected. Load errors appear beside the captions menu, independently of media errors. + +FFmpeg diagnostics omit private detail strings; playback failures still expose the operation and native error. Source/subtitle validation and transport errors do not interpolate supplied URLs or header values. + +Miruro's progress identity is independent of its source address. YouTube records stay version 1; Miruro records use version 2: + +```json +{"version":2,"event":"progress","media_identity":{"provider":"miruro","media_id":"OERe_a0YS4h-DlsBDOeXwX7HOO6316tw","episode":1},"position_seconds":123.5,"duration_seconds":1500.0,"completion_eligible":true} +``` + +Events, acknowledged-position reporting, nullable duration and completion eligibility retain the semantics below. Miruro bookmarks use the same stable episode identity. **Tubular coordination required:** its current `src/video_state.rs` accepts only canonical YouTube/version-1 records; it must adopt this version-2 identity before Miruro watched tracking works. This is the implemented Terebi contract, not a claim that Tubular has already adopted it. + +`make check-subtitles`, `make check-resolved` and `make check-network` use deterministic local VTT/HLS fixtures; `make check-startup` exercises the actual CLI-to-GUI handoff. Live playback is never part of the default suite. With a private, freshly resolved source document, explicitly run `.build/resolved-playback-check /absolute/source.json` for native video/audio and subtitle/seek smoke. Visual rendering and perceived synchronization still require a visible-app check. + +Verification recorded for the direct `ssub`/`sun` source: a freshly resolved native stream decoded video/audio and advanced, selected English VTT, and matched the model's subtitle text to the expected cues after forward/backward acknowledged seeks. Local encrypted MPEG-TS and fMP4 fixtures also verified disguised image suffixes, headers on master/variant/segment/key/init requests, query preservation, foreign-origin/redirect isolation and redacted errors. `TEREBI_CHECK_RESOLVED_ONLY=1 make check-startup` passed actual CLI launch into two independent GUI episodes, resource-specific subtitle headers and version-2 reports. CLI, subtitle, progress, reporting, YouTube resolver and system-playback checks passed. + +The full startup suite is **not green**: runs failed its existing held-Space assertion (expected paused, observed playing) and, in another run, its YouTube end-transition deadline. Their causes are not established; the targeted Miruro launch check does not replace them. Visual subtitle appearance and perceived A/V synchronization remain human verification, and Tubular's version-2 consumer change remains external coordination. + + ## Resume playback -Reopening a local video or YouTube video automatically resumes its saved position, including after quitting and relaunching the app. Local files use their resolved file paths; YouTube uses the canonical video webpage URL, so short links and newly extracted media URLs share progress. The app does not automatically reopen the last video on launch. +Reopening a local video, YouTube video or resolved Miruro episode automatically resumes its saved position, including after quitting and relaunching the app. Local files use their resolved file paths; YouTube uses the canonical video webpage URL; Miruro uses its opaque media ID and episode number. Newly resolved addresses therefore share progress without saving CDN tokens as identity. The app does not automatically reopen the last video on launch. Progress is stored locally in UserDefaults, checkpointed every five seconds of playback progress, and saved on pause, close, replacement, and normal quit. Only authoritative, acknowledged positions are saved—not an unfinished seek or slider drag. Finished videos clear their bookmark and reopen from the beginning; a bookmark beyond the current video's duration also restarts at the beginning. Failed opens preserve existing bookmarks. Live/non-seekable inputs and audio-only media do not record progress. @@ -169,7 +225,7 @@ terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw' \ The shared C++ backend owns reporting inside the GUI process, independent of the launcher and UserDefaults. It exclusively creates a regular file with owner-only read/write permissions (`0600`), rejecting existing destinations and symlinks. Parent directories must already exist; the caller owns directory/file cleanup. Concurrent players cannot claim the same destination. -Each complete line is a version-1 JSON record: +Each complete YouTube line is a version-1 JSON record (resolved Miruro sources use the version-2 identity documented above): ```json {"version":1,"event":"progress","media_url":"https://www.youtube.com/watch?v=jNQXAC9IVRw","position_seconds":123.5,"duration_seconds":200.0,"completion_eligible":true} @@ -301,7 +357,7 @@ Visual correctness, perceived A/V synchronization, and control usability still n ## Current limits -Local regular files and YouTube video URLs, selectable YouTube resolutions, automatically selected audio/video tracks within the chosen quality, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. No subtitles, explicit codec/audio-track selection, VideoToolbox decoding, or rotation/deinterlacing. Mid-track audio format changes report an error. The URL UI accepts YouTube videos rather than arbitrary web pages or streaming services. +Local regular files, YouTube video URLs and resolved Miruro HTTP(S) sources, selectable YouTube resolutions, automatically selected audio/video tracks within the chosen quality, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. External resolved-source WebVTT is supported; embedded/YouTube captions, explicit codec/audio-track selection, VideoToolbox decoding and rotation/deinterlacing are not. Mid-track audio format changes report an error. The URL UI accepts YouTube videos; other resolved media uses `--source-file`. Forward frame stepping retains the live video decoder cursor instead of seeking and re-decoding the same keyframe group for each repeat. Backward stepping still scans from an earlier keyframe; long-GOP or unindexed files can therefore step backward slowly, without blanking the image or allocating a command per key repeat. Audio is skipped while stepping and rebuilt once on resume from the verified preroll anchor; resuming after a long forward run can also require a longer preroll. Intermediate frames needed by inter-frame dependencies are decoded and discarded; frames are not cached across the entire file. Every accepted step counts. Stepping requires usable decoded presentation timestamps and reports an error rather than inventing frame times when they are absent. diff --git a/Sources/App/ExternalSubtitles.swift b/Sources/App/ExternalSubtitles.swift new file mode 100644 index 0000000..5b2de45 --- /dev/null +++ b/Sources/App/ExternalSubtitles.swift @@ -0,0 +1,275 @@ +import Foundation + +/// Immutable, reusable subtitles; times are acknowledged player positions, not wall-clock time. +struct WebVTTDocument: Sendable { + struct Cue: Sendable, Equatable { + let start: Double + let end: Double + let text: String + } + + let cues: [Cue] + private let maximumEnds: [Double] + static let byteLimit = 4 * 1024 * 1024 + + private enum SubtitleError: LocalizedError { + case request, transport, content + var errorDescription: String? { + switch self { + case .request: "Could not load subtitles. The subtitle URL or headers are invalid." + case .transport: "Could not load subtitles. Check the connection or choose another subtitle track." + case .content: "The subtitle resource is not valid WebVTT." + } + } + } + + static func load(url: String, headers: String) async throws -> WebVTTDocument { + let request = try request(url: url, headers: headers) + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 15 + configuration.timeoutIntervalForResource = 20 + configuration.httpCookieStorage = nil + configuration.urlCredentialStorage = nil + configuration.urlCache = nil + let session = URLSession(configuration: configuration, delegate: SubtitleRedirects(request: request), delegateQueue: nil) + defer { session.invalidateAndCancel() } + let data: Data + do { + let (bytes, response) = try await session.bytes(for: request) + guard let response = response as? HTTPURLResponse, + (200...299).contains(response.statusCode), response.expectedContentLength <= Int64(byteLimit) else { + throw SubtitleError.transport + } + var buffer = Data() + if response.expectedContentLength > 0 { buffer.reserveCapacity(Int(response.expectedContentLength)) } + for try await byte in bytes { + guard buffer.count < byteLimit else { throw SubtitleError.transport } + buffer.append(byte) + } + data = buffer + } catch { + if Task.isCancelled { throw CancellationError() } + throw SubtitleError.transport + } + // Providers sometimes disguise VTT resources as images: trust the explicit format and content, + // never the path extension or Content-Type. + guard let text = String(data: data, encoding: .utf8) else { throw SubtitleError.content } + return try parse(text) + } + + static func parse(_ text: String) throws -> WebVTTDocument { + guard text.utf8.count <= byteLimit, !text.contains("\0") else { throw SubtitleError.content } + var normalized = text + if normalized.first == "\u{FEFF}" { normalized.removeFirst() } + normalized = normalized.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") + let lines = normalized.components(separatedBy: "\n") + guard let header = lines.first, + header == "WEBVTT" || header.hasPrefix("WEBVTT ") || header.hasPrefix("WEBVTT\t"), + !header.contains("-->") else { throw SubtitleError.content } + var index = 1 + // Header metadata ends at the first blank line. A cue without this separator is malformed. + while index < lines.count && !lines[index].isEmpty { + guard !lines[index].contains("-->") else { throw SubtitleError.content } + index += 1 + } + var cues: [Cue] = [] + var maximumEnds: [Double] = [] + while index < lines.count { + while index < lines.count && lines[index].isEmpty { index += 1 } + let beginning = index + while index < lines.count && !lines[index].isEmpty { index += 1 } + guard beginning < index else { break } + let block = lines[beginning..") }) else { throw SubtitleError.content } + continue // ponytail: plain-text cues; add layout/styling when positional subtitles required. + } + var timingIndex = beginning + if !first.contains("-->") { timingIndex += 1 } // Optional cue identifier. + guard timingIndex < index else { throw SubtitleError.content } + let timing = lines[timingIndex].split(whereSeparator: { $0 == " " || $0 == "\t" }) + guard timing.count >= 3, timing[1] == "-->", + let start = timestamp(String(timing[0])), let end = timestamp(String(timing[2])), + end >= start, start >= (cues.last?.start ?? 0) else { throw SubtitleError.content } + // Equal timestamps produce an inactive cue, not a failure of the entire track. + try validateSettings(timing.dropFirst(3)) + let payload = lines[(timingIndex + 1)..") else { throw SubtitleError.content } + let cue = Cue(start: start, end: end, text: try plainText(payload)) + cues.append(cue) + maximumEnds.append(max(end, maximumEnds.last ?? 0)) + } + return WebVTTDocument(cues: cues, maximumEnds: maximumEnds) + } + + func text(at acknowledgedSeconds: Double) -> String { + guard acknowledgedSeconds.isFinite, acknowledgedSeconds >= 0 else { return "" } + // Binary-search starts, then inspect only the potentially overlapping interval. No cursor: + // backward seeks and switching to an already-loaded track need no reset. + var low = 0 + var high = cues.count + while low < high { + let middle = low + (high - low) / 2 + if cues[middle].start <= acknowledgedSeconds { low = middle + 1 } else { high = middle } + } + var active: [String] = [] + var index = low + while index > 0 { + index -= 1 + if maximumEnds[index] <= acknowledgedSeconds { break } + if cues[index].end > acknowledgedSeconds && !cues[index].text.isEmpty { active.append(cues[index].text) } + } + return active.reversed().joined(separator: "\n") + } + + // Shared with the focused check: these are the exact request and redirect policies used above. + static func request(url text: String, headers: String) throws -> URLRequest { + guard text.utf8.count <= 32768, + !text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }), + let components = URLComponents(string: text), + ["http", "https"].contains(components.scheme?.lowercased() ?? ""), + let host = components.host, !host.isEmpty, + components.user == nil, components.password == nil, components.fragment == nil, + components.port == nil || (1...65535).contains(components.port!), + let url = components.url, headers.utf8.count <= 65536, + headers.isEmpty || headers.hasSuffix("\r\n") else { throw SubtitleError.request } + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) + request.httpShouldHandleCookies = false + var names = Set() + let lines = headers.isEmpty ? [] : headers.components(separatedBy: "\r\n").dropLast().map { $0 } + guard lines.count <= 64 else { throw SubtitleError.request } + for line in lines { + guard let colon = line.firstIndex(of: ":") else { throw SubtitleError.request } + let name = String(line[.. Bool { + guard let a = URLComponents(url: source, resolvingAgainstBaseURL: false), + let b = URLComponents(url: destination, resolvingAgainstBaseURL: false), + let scheme = a.scheme?.lowercased(), ["http", "https"].contains(scheme), + b.scheme?.lowercased() == scheme, a.host?.lowercased() == b.host?.lowercased(), + (a.port ?? (scheme == "https" ? 443 : 80)) == (b.port ?? (scheme == "https" ? 443 : 80)), + b.user == nil, b.password == nil, b.fragment == nil else { return false } + // Reject rather than guess which resource-specific headers contain credentials. + return true + } + + private static func timestamp(_ text: String) -> Double? { + let fields = text.split(separator: ":", omittingEmptySubsequences: false) + guard fields.count == 2 || fields.count == 3 else { return nil } + let seconds = fields.last!.split(separator: ".", omittingEmptySubsequences: false) + guard seconds.count == 2, seconds[0].utf8.count == 2, seconds[1].utf8.count == 3, + fields.dropLast().allSatisfy({ $0.utf8.count >= 2 }), + (fields.dropLast().map(String.init) + seconds.map(String.init)).allSatisfy({ $0.utf8.allSatisfy { (48...57).contains($0) } }), + let second = Double(seconds[0]), second < 60, + let milliseconds = Double(seconds[1]), let minute = Double(fields[fields.count - 2]), + minute < 60, fields[fields.count - 2].utf8.count == 2 else { return nil } + let hours = fields.count == 3 ? Double(fields[0]) : 0 + guard let hours else { return nil } + let result = hours * 3600 + minute * 60 + second + milliseconds / 1000 + return result.isFinite ? result : nil + } + + private static func validateSettings(_ settings: ArraySlice) throws { + func percent(_ text: Substring) -> Bool { + guard text.last == "%" else { return false } + let number = text.dropLast() + guard !number.isEmpty, number.utf8.allSatisfy({ (48...57).contains($0) || $0 == 46 }), + let value = Double(number) else { return false } + return (0...100).contains(value) + } + var names = Set() + for setting in settings { + let pair = setting.split(separator: ":", omittingEmptySubsequences: false) + guard pair.count == 2, !pair[1].isEmpty, names.insert(pair[0]).inserted else { throw SubtitleError.content } + let values = pair[1].split(separator: ",", omittingEmptySubsequences: false) + let valid: Bool + switch pair[0] { + case "vertical": valid = values.count == 1 && ["rl", "lr"].contains(pair[1]) + case "align": valid = values.count == 1 && ["start", "center", "end", "left", "right"].contains(pair[1]) + case "size": valid = values.count == 1 && percent(pair[1]) + case "region": valid = values.count == 1 + case "position": + valid = (values.count == 1 || values.count == 2) && percent(values[0]) && (values.count == 1 || ["line-left", "center", "line-right", "auto"].contains(values[1])) + case "line": + let number = values[0].first == "-" ? values[0].dropFirst() : values[0][...] + let integer = !number.isEmpty && number.utf8.allSatisfy { (48...57).contains($0) } + valid = (values.count == 1 || values.count == 2) && (values[0] == "auto" || percent(values[0]) || integer) && (values.count == 1 || ["start", "center", "end"].contains(values[1])) + default: valid = false + } + guard valid else { throw SubtitleError.content } + } + } + + private static func plainText(_ payload: String) throws -> String { + var result = "" + var index = payload.startIndex + let entities: [String: String] = ["amp": "&", "lt": "<", "gt": ">", "nbsp": "\u{A0}", "lrm": "\u{200E}", "rlm": "\u{200F}", "quot": "\"", "apos": "'"] + while index < payload.endIndex { + if payload[index] == "<" { + guard let end = payload[index...].firstIndex(of: ">") else { throw SubtitleError.content } + var tag = String(payload[payload.index(after: index).. Void) { + lock.lock() + count += 1 + let withinLimit = count <= 5 + lock.unlock() + guard withinLimit, let source = request.url, let destination = newRequest.url, + WebVTTDocument.permitsRedirect(from: source, to: destination) else { + completionHandler(nil) + return + } + var redirected = newRequest + redirected.httpShouldHandleCookies = false + for (name, value) in request.allHTTPHeaderFields ?? [:] { redirected.setValue(value, forHTTPHeaderField: name) } + completionHandler(redirected) + } +} diff --git a/Sources/App/ResolvedSource.swift b/Sources/App/ResolvedSource.swift new file mode 100644 index 0000000..fcc3e0b --- /dev/null +++ b/Sources/App/ResolvedSource.swift @@ -0,0 +1,122 @@ +import Foundation +import Darwin + +struct ResolvedSubtitle: Decodable, Sendable { + let url: String + let format: String + let language: String + let label: String + let isDefault: Bool + private let headerFields: [String: String] + var headers: String { + headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined() + } + fileprivate var validatedHeaders: [String: String] { headerFields } + private enum CodingKeys: String, CodingKey { + case url, format, language, label + case isDefault = "default" + case headerFields = "headers" + } +} + +struct ResolvedSource: Sendable { + let url: String + let headers: String + let identity: String + let title: String + let subtitles: [ResolvedSubtitle] + + private struct Document: Decodable { + struct Identity: Decodable { + let provider: String + let media_id: String + let episode: Int + } + let version: Int + let url: String + let headers: [String: String] + let identity: Identity + let title: String? + let subtitles: [ResolvedSubtitle] + } + + private struct InvalidSource: LocalizedError { + var errorDescription: String? { "Invalid or unreadable resolved source file." } + } + + static func load(from file: URL) throws -> ResolvedSource { + do { + guard file.isFileURL else { throw InvalidSource() } + let descriptor: Int32 = file.withUnsafeFileSystemRepresentation { path in + guard let path else { return -1 } + return Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC) + } + guard descriptor >= 0 else { throw InvalidSource() } + var info = stat() + guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { + Darwin.close(descriptor) + throw InvalidSource() + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + defer { try? handle.close() } + let data = try handle.read(upToCount: 1_048_577) ?? Data() + guard data.count <= 1_048_576 else { throw InvalidSource() } + let document = try JSONDecoder().decode(Document.self, from: data) + let id = document.identity + guard document.version == 1, id.provider == "miruro", + !id.media_id.isEmpty, id.media_id.utf8.count <= 512, + id.media_id.utf8.allSatisfy({ isIDByte($0) }), + id.episode > 0, id.episode <= Int(Int32.max), + validURL(document.url), validHeaders(document.headers), + validText(document.title ?? "", limit: 4096), + document.subtitles.count <= 32, + document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() } + for subtitle in document.subtitles { + guard validURL(subtitle.url), subtitle.format == "vtt", + validText(subtitle.language, limit: 128), !subtitle.language.isEmpty, + validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty, + validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() } + } + let headers = document.headers.sorted { $0.key < $1.key } + .map { "\($0.key): \($0.value)\r\n" }.joined() + return ResolvedSource(url: document.url, headers: headers, + identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)", + subtitles: document.subtitles) + } catch { throw InvalidSource() } + } + + private static func isIDByte(_ byte: UInt8) -> Bool { + (65...90).contains(byte) || (97...122).contains(byte) || + (48...57).contains(byte) || byte == 45 || byte == 95 + } + + private static func validText(_ text: String, limit: Int) -> Bool { + text.utf8.count <= limit && !text.unicodeScalars.contains { $0.value < 32 || $0.value == 127 } + } + + private static func validURL(_ text: String) -> Bool { + guard !text.isEmpty, validText(text, limit: 32768), + !text.contains(where: { $0.isWhitespace }), + let components = URLComponents(string: text), + components.scheme == "https" || components.scheme == "http", + let host = components.host, !host.isEmpty, + components.user == nil, components.password == nil, + components.fragment == nil, components.url != nil else { return false } + return components.port == nil || (1...65535).contains(components.port!) + } + + private static func validHeaders(_ headers: [String: String]) -> Bool { + guard headers.count <= 64 else { return false } + var names = Set() + var size = 0 + for (name, value) in headers { + guard !name.isEmpty, name.utf8.count <= 256, + name.utf8.allSatisfy({ isIDByte($0) || "!#$%&'*+.^`|~".utf8.contains($0) }), + names.insert(name.lowercased()).inserted, + value.utf8.count <= 16384, + !value.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) else { return false } + size += name.utf8.count + value.utf8.count + 4 + } + return size <= 65536 + } +} diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index e74f480..7f1da64 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -37,6 +37,12 @@ final class PlayerModel: ObservableObject { @Published private(set) var youtubeQuality: Int? @Published private(set) var automaticYouTubeQuality = false @Published private(set) var showsBufferInfo = false + @Published private(set) var subtitleTracks: [ResolvedSubtitle] = [] + @Published private(set) var selectedSubtitle: Int? + @Published private(set) var subtitleText = "" + @Published private(set) var subtitleError: String? + private var subtitleDocument: WebVTTDocument? + private var subtitleTask: Task? @Published var seekPosition = 0.0 @Published var isSeeking = false @Published private(set) var volume = 1.0 @@ -218,6 +224,45 @@ final class PlayerModel: ObservableObject { resolveYouTube(url, height: nil) } + func openResolvedSource(_ file: URL) { + do { + let source = try ResolvedSource.load(from: file) + cancelYouTubeResolution() + openMedia(.resolved(source)) + } catch { + commandError = error.localizedDescription + revealControls() + } + } + + func selectSubtitle(_ index: Int?) { + subtitleTask?.cancel() + subtitleTask = nil + subtitleDocument = nil + subtitleText = "" + subtitleError = nil + selectedSubtitle = index + guard let index, subtitleTracks.indices.contains(index) else { return } + let track = subtitleTracks[index] + subtitleTask = Task { [weak self] in + do { + let document = try await WebVTTDocument.load(url: track.url, headers: track.headers) + guard !Task.isCancelled, let self else { return } + self.subtitleDocument = document + self.updateSubtitle() + } catch { + guard !Task.isCancelled, let self else { return } + self.subtitleError = error.localizedDescription + } + } + } + + private func updateSubtitle() { + subtitleText = state.seek_pending || transition.seekPending || isBusy || + state.status == PLAYER_EMPTY || state.status == PLAYER_ERROR ? "" : + subtitleDocument?.text(at: state.position) ?? "" + } + func setShowsBufferInfo(_ enabled: Bool) { showsBufferInfo = enabled revealControls() @@ -338,11 +383,24 @@ final class PlayerModel: ObservableObject { } } } + case .resolved(let source): + accepted = source.identity.withCString { identity in + source.url.withCString { url in + source.headers.withCString { headers in + var input = PlayerNetworkSource(video_url: url, audio_url: nil, + video_headers: headers, audio_headers: nil, duration: 0, is_live: false, + media_url: identity, initial_position: savedPosition ?? 0) + return player_open_network(handle, &input) + } + } + } } guard accepted else { switch source { case .file: commandError = "Could not queue the file to open. Please try again." + case .resolved: + commandError = "Could not queue the resolved stream to open." case .youtube: failYouTubeChange(change) } @@ -363,6 +421,19 @@ final class PlayerModel: ObservableObject { youtubeHeights = video.availableHeights youtubeHeight = video.height youtubeQuality = height + case .resolved(let source): + fileName = source.title + fileIcon = nil + youtubeHeights = [] + youtubeHeight = nil + youtubeQuality = nil + } + selectSubtitle(nil) + if case .resolved(let resolved) = source { + subtitleTracks = resolved.subtitles + selectSubtitle(subtitleTracks.firstIndex(where: \.isDefault)) + } else { + subtitleTracks = [] } needsVideoFit = !change.isQuality commandError = transition.needsLocalRestoration || !change.playing || player_play(handle) ? nil : @@ -429,6 +500,8 @@ final class PlayerModel: ObservableObject { return } transition.closeMedia() + selectSubtitle(nil) + subtitleTracks = [] compactIntent = nil compactWindow.restore(resumeFullScreen: false, animated: false) fileName = nil @@ -744,6 +817,7 @@ final class PlayerModel: ObservableObject { guard observation.ready else { return } defer { systemPlayback?.update() } state = next + updateSubtitle() if let playing = observation.playback { setPlayback(playing: playing) } if !isBusy && (next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || !next.has_video) { compactWindow.restore(resumeFullScreen: false, animated: false) @@ -792,6 +866,7 @@ final class PlayerModel: ObservableObject { func shutdown() { saveProgress() cancelYouTubeResolution() + selectSubtitle(nil) systemPlayback?.shutdown() systemPlayback = nil compactWindow.restore(resumeFullScreen: false, animated: false) @@ -834,11 +909,13 @@ final class PlayerModel: ObservableObject { private enum MediaSource { case file(URL) case youtube(YouTubeVideo, height: Int?) + case resolved(ResolvedSource) var url: URL { switch self { case .file(let url): return url case .youtube(let video, _): return video.webpageURL + case .resolved(let source): return URL(string: source.identity)! } } var live: Bool { @@ -1555,6 +1632,40 @@ private struct PlayerContent: View { if model.isCompact { CompactDragSurface(model: model).accessibilityHidden(true) } + if !model.subtitleText.isEmpty { + Text(model.subtitleText) + .font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold)) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .padding(8) + .background(.black.opacity(0.75), in: RoundedRectangle(cornerRadius: 4)) + .padding(.horizontal, 24) + .padding(.bottom, model.controlsVisible ? 110 : 24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .allowsHitTesting(false) + } + if !model.subtitleTracks.isEmpty && (model.controlsVisible || model.subtitleError != nil) { + VStack(alignment: .trailing) { + Menu { + Button("Off") { model.selectSubtitle(nil) } + ForEach(model.subtitleTracks.indices, id: \.self) { index in + Button { + model.selectSubtitle(index) + } label: { + Label(model.subtitleTracks[index].label + " (" + model.subtitleTracks[index].language + ")", + systemImage: model.selectedSubtitle == index ? "checkmark" : "text.bubble") + } + } + } label: { + Image(systemName: "captions.bubble").accessibilityLabel("Subtitles") + } + if let error = model.subtitleError { + Text(error).font(.caption).foregroundStyle(.yellow) + } + } + .padding(.top, 44).padding(.trailing, 16) + .frame(maxWidth: .infinity, alignment: .trailing) + } if let error = model.errorText { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow) @@ -1918,6 +2029,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let url = URL(string: arguments[index + 1]) { PlayerModel.shared.open(url) } + if let index = arguments.firstIndex(of: "--source-file"), index + 1 < arguments.count { + PlayerModel.shared.openResolvedSource(URL(fileURLWithPath: arguments[index + 1])) + } } // Let SwiftUI create the initial window for a Finder/document launch. diff --git a/Sources/CLI/PlayerCommand.swift b/Sources/CLI/PlayerCommand.swift index 57e13b7..e92cf59 100644 --- a/Sources/CLI/PlayerCommand.swift +++ b/Sources/CLI/PlayerCommand.swift @@ -4,22 +4,25 @@ enum PlayerCommand { case help case printDefaultConfig case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?) + case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?) static let usage = """ Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL] terebi [--new-instance] [--no-config] --url YOUTUBE_URL terebi --new-instance --url YOUTUBE_URL --progress-file PATH + terebi --new-instance --source-file PATH [--progress-file PATH] terebi [--new-instance] [--no-config] -- FILE terebi --help terebi --default-config - Open one local media file or YouTube video and start playback, resuming saved - video progress when available. With no arguments, open or activate the player. + Open one local file, YouTube video or resolved source and start playback, + resuming saved video progress. With no arguments, open or activate the player. Quote paths and URLs containing spaces or shell metacharacters. --new-instance Launch a separate player, leaving existing playback untouched. --no-config Use built-in settings instead of config.toml on a fresh launch. --url URL Open a YouTube video URL. + --source-file PATH Open a validated resolved HTTP(S) source in a new player. --progress-file PATH Write JSONL playback facts to a new private file; requires --new-instance. The GUI owns reporting after this CLI exits. -- Treat the following argument as a filename, even with a leading '-'. @@ -77,6 +80,19 @@ enum PlayerCommand { guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --default-config.") } return .printDefaultConfig } + if inputs.first == "--source-file" { + guard newInstance else { throw ArgumentError("--source-file requires --new-instance.") } + guard inputs.count == 2, !inputs[1].isEmpty, !inputs[1].contains("\0"), + !inputs[1].hasPrefix("--") else { + throw ArgumentError("--source-file requires exactly one filesystem path.") + } + let base = directory.hasDirectoryPath ? directory : + URL(fileURLWithPath: directory.path, isDirectory: true) + let sourceFile = URL(fileURLWithPath: inputs[1], relativeTo: base).absoluteURL.standardizedFileURL + _ = try ResolvedSource.load(from: sourceFile) + return .launchSource(sourceFile: sourceFile, newInstance: newInstance, + noConfig: noConfig, progressFile: progressFile) + } if inputs.first == "--url" { guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") } return try launch(YouTubeResolver.validateURL(inputs[1])) diff --git a/Sources/CLI/TerebiCLI.swift b/Sources/CLI/TerebiCLI.swift index 4347852..4a59f79 100644 --- a/Sources/CLI/TerebiCLI.swift +++ b/Sources/CLI/TerebiCLI.swift @@ -18,6 +18,13 @@ enum TerebiCLI { case .printDefaultConfig: print(PlayerConfiguration.defaultTOML, terminator: "") case .launch(let url, let newInstance, let noConfig, let progressFile): + await launch(url: url, sourceFile: nil, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile) + case .launchSource(let sourceFile, let newInstance, let noConfig, let progressFile): + await launch(url: nil, sourceFile: sourceFile, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile) + } + } + + private static func launch(url: URL?, sourceFile: URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?) async { if let progressFile { var existing = stat() guard lstat(progressFile.path, &existing) != 0 else { @@ -62,6 +69,9 @@ enum TerebiCLI { if let progressFile { configuration.arguments += ["--progress-file", progressFile.path] } + if let sourceFile { + configuration.arguments += ["--source-file", sourceFile.path] + } if newInstance, let url { configuration.arguments += ["--open-media", url.absoluteString] } @@ -75,7 +85,6 @@ enum TerebiCLI { fail("Could not launch the player: \(error.localizedDescription)", code: 69) } } - } private static func fail(_ message: String, code: Int32) -> Never { FileHandle.standardError.write(Data("terebi: \(message)\n".utf8)) diff --git a/Sources/Core/network_input.hpp b/Sources/Core/network_input.hpp index 0319039..885e47c 100644 --- a/Sources/Core/network_input.hpp +++ b/Sources/Core/network_input.hpp @@ -17,6 +17,7 @@ extern "C" { #include #include #include +#include #include namespace playback { @@ -49,6 +50,9 @@ class NetworkInput { public: static constexpr double aheadSeconds = 60, behindSeconds = 30; AVFormatContext *format = nullptr; + // Resolved sources bind credentials to their original origin for all HLS I/O. + std::string headerOrigin; + std::string requestHeaders; NetworkInput(const std::atomic &stopping, const std::atomic &epoch, uint64_t activeEpoch, std::condition_variable &playerWake, StreamCacheBudget &budget) diff --git a/Sources/Core/player.cpp b/Sources/Core/player.cpp index 66e5633..0c4ad56 100644 --- a/Sources/Core/player.cpp +++ b/Sources/Core/player.cpp @@ -18,6 +18,7 @@ extern "C" { #include #include #include +#include #include #include #include @@ -167,6 +168,53 @@ bool validHeaders(const char *text) { } return true; } + +std::string networkOrigin(const char *text) { + if (!validNetworkURL(text)) return {}; + std::string_view url(text); + const auto start = url.starts_with("https://") ? 8u : 7u; + std::string authority(url.substr(start, url.find_first_of("/?#", start) - start)); + for (char &value : authority) + if (value >= 'A' && value <= 'Z') value += 'a' - 'A'; + const auto colon = authority.front() == '[' ? authority.find(':', authority.find(']')) : authority.find(':'); + if (colon == std::string::npos) authority += start == 8 ? ":443" : ":80"; + else { + // Canonicalize explicit default ports and leading zeroes. + authority.replace(colon + 1, std::string::npos, std::to_string(std::stoul(authority.substr(colon + 1)))); + } + return std::string(url.substr(0, start)) + authority; +} + +int resolvedIOOpen(AVFormatContext *format, AVIOContext **pb, const char *url, + int flags, AVDictionary **options) noexcept { + try { + const auto &input = *static_cast(format->opaque); + // crypto forwards options to its underlying HTTP protocol; do not unwrap the + // actual URL, since FFmpeg must still decrypt AES-128 segments itself. + const char *resource = std::strncmp(url, "crypto+", 7) == 0 || + std::strncmp(url, "crypto:", 7) == 0 ? url + 7 : url; + if (networkOrigin(resource) != input.headerOrigin) return AVERROR(EACCES); + int result = av_dict_set(options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0); + if (result >= 0) result = av_dict_set(options, "headers", input.requestHeaders.c_str(), 0); + // FFmpeg 9's HTTP redirect loop retains custom headers. Reject every redirect + // before any second request, including same-origin redirects, rather than leak. + if (result >= 0) result = av_dict_set(options, "max_redirects", "0", 0); + if (result < 0) return result; + return avio_open2(pb, url, flags, &format->interrupt_callback, options); + } catch (const std::bad_alloc &) { + return AVERROR(ENOMEM); + } catch (...) { + return AVERROR(EINVAL); + } +} + +void redactedFFmpegLog(void *, int level, const char *, va_list) { + if (level > AV_LOG_WARNING || level > av_log_get_level()) return; + // FFmpeg diagnostics can interpolate signed URLs, cookies, keys and headers, + // including at error level. Player::check still exposes operation + AVERROR. + std::fputs(level <= AV_LOG_ERROR ? "FFmpeg error (private details redacted)\n" : + "FFmpeg warning (private details redacted)\n", stderr); +} } struct Player { @@ -521,12 +569,19 @@ struct Player { steadyAnchor = Clock::now(); } - playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers, bool live) { + playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers, + bool live, bool resolved) { inputs[slot] = std::make_unique(stopping, requestedEpoch, activeEpoch, wake, cacheBudget); auto &input = *inputs[slot]; input.format = avformat_alloc_context(); if (!input.format) throw std::bad_alloc(); input.format->interrupt_callback = {playback::NetworkInput::interrupt, &input}; + if (resolved) { + input.headerOrigin = networkOrigin(url.c_str()); + input.requestHeaders = headers; + input.format->opaque = &input; + input.format->io_open = resolvedIOOpen; + } AVDictionary *options = nullptr; int result = av_dict_set(&options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0); // Leave room for socket-timeout recovery within the reader's total 15-second deadline. @@ -542,6 +597,12 @@ struct Player { if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_delay_total_max", "5", 0); // Leave short-seek read-ahead disabled: cache misses must escape stalled responses. if (result >= 0 && !headers.empty()) result = av_dict_set(&options, "headers", headers.c_str(), 0); + if (result >= 0 && resolved) result = av_dict_set(&options, "http_persistent", "0", 0); + // HLS persistent requests bypass io_open (and its origin/header policy). + if (result >= 0 && resolved) result = av_dict_set(&options, "http_multiple", "0", 0); + // Resolved HLS may name genuine TS/fMP4 resources as images. Probe their + // content instead of trusting suffixes; io_open still enforces every URL. + if (result >= 0 && resolved) result = av_dict_set(&options, "extension_picky", "0", 0); if (result < 0) { av_dict_free(&options); check(result, "Configuring network input"); @@ -569,7 +630,8 @@ struct Player { status = PLAYER_OPENING; publish(); if (network) { - auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live); + const bool resolved = command.mediaURL.starts_with("miruro:"); + auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live, resolved); format = primary->format; openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1); if (command.audioURL.empty()) { @@ -577,7 +639,7 @@ struct Player { } else { if (!tracks[1].codec) throw std::runtime_error("The selected video stream contains no playable video"); - auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live); + auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live, resolved); openTrack(0, audio->format, audio, AVMEDIA_TYPE_AUDIO, -1); if (!tracks[0].codec) throw std::runtime_error("The selected audio stream contains no playable audio"); @@ -629,10 +691,12 @@ struct Player { } if (context->duration != AV_NOPTS_VALUE && context->duration > 0) measuredDuration = std::max(measuredDuration, static_cast(context->duration) / AV_TIME_BASE); - // Manifest demuxers own their I/O; a nil top-level AVIOContext does not forbid time seeks. + // HLS time seeks reopen segments; byte-seek support on the initial + // manifest response says nothing about its demuxer's seek capability. seekable = seekable && !(context->ctx_flags & AVFMTCTX_UNSEEKABLE) && - (context->pb ? (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0 : - (context->iformat->flags & AVFMT_NOFILE) != 0); + (context->iformat == av_find_input_format("hls") || + (context->iformat->flags & AVFMT_NOFILE) != 0 || + (context->pb && (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0)); } // One origin for both inputs: independently zeroing them would erase real A/V offsets. origin = std::isfinite(first) ? first : 0; @@ -1376,7 +1440,11 @@ bool submit(Player *player, Function function) noexcept { extern "C" { Player *player_create(void) { - try { return new Player; } catch (...) { return nullptr; } + try { + static std::once_flag logging; + std::call_once(logging, [] { av_log_set_callback(redactedFFmpegLog); }); + return new Player; + } catch (...) { return nullptr; } } bool player_set_progress_file(Player *player, const char *path, char *error) { if (error) error[0] = '\0'; @@ -1416,7 +1484,7 @@ bool player_open(Player *player, const char *path) { bool player_open_network(Player *player, const PlayerNetworkSource *source) { if (!source || !std::isfinite(source->duration) || source->duration < 0 || !std::isfinite(source->initial_position) || source->initial_position < 0 || - (source->media_url && *source->media_url && !playback::ProgressReporter::validMediaURL(source->media_url)) || + (source->media_url && *source->media_url && !playback::ProgressReporter::validMediaIdentity(source->media_url)) || !validNetworkURL(source->video_url) || (source->audio_url && *source->audio_url && !validNetworkURL(source->audio_url)) || !validHeaders(source->video_headers) || !validHeaders(source->audio_headers)) return false; diff --git a/Sources/Core/player.h b/Sources/Core/player.h index e24541b..7855423 100644 --- a/Sources/Core/player.h +++ b/Sources/Core/player.h @@ -53,7 +53,8 @@ typedef struct PlayerNetworkSource { const char *audio_headers; double duration; bool is_live; - // Canonical YouTube webpage identity, never an extracted stream URL. + // Canonical YouTube webpage or stable miruro:: + // identity, never an extracted stream/CDN URL. // NULL/empty means no reporting for this source. const char *media_url; // Initial restored position, applied before publishing ready state. Zero diff --git a/Sources/Core/progress_reporter.cpp b/Sources/Core/progress_reporter.cpp index 7aa9eb7..5dad1ce 100644 --- a/Sources/Core/progress_reporter.cpp +++ b/Sources/Core/progress_reporter.cpp @@ -69,11 +69,31 @@ bool ProgressReporter::validMediaURL(std::string_view url) noexcept { return true; } +bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept { + if (validMediaURL(identity)) return true; + constexpr std::string_view prefix = "miruro:"; + if (identity.substr(0, prefix.size()) != prefix) return false; + const auto separator = identity.find(':', prefix.size()); + if (separator == std::string_view::npos) return false; + const auto id = identity.substr(prefix.size(), separator - prefix.size()); + if (id.empty() || id.size() > 512) return false; + for (char c : id) { + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '-' || c == '_')) return false; + } + const auto episode = identity.substr(separator + 1); + if (episode.empty() || episode.front() < '1' || episode.front() > '9') return false; + for (char c : episode) if (c < '0' || c > '9') return false; + int value = 0; + const auto parsed = std::from_chars(episode.data(), episode.data() + episode.size(), value); + return parsed.ec == std::errc{} && parsed.ptr == episode.data() + episode.size() && value > 0; +} + void ProgressReporter::begin(const std::string &url, bool isLive) noexcept { if (finished || disabled.load(std::memory_order_acquire)) return; if (url.empty()) { closeMedia(); return; } - if (!validMediaURL(url)) { - disable("Progress media URL is not a canonical YouTube watch URL"); + if (!validMediaIdentity(url)) { + disable("Progress media identity is invalid"); return; } try { @@ -144,11 +164,21 @@ bool ProgressReporter::emit(const char *event) noexcept { try { std::string record; record.reserve(256); - record += "{\"version\":1,\"event\":\""; + const bool miruro = mediaURL.compare(0, 7, "miruro:") == 0; + record += miruro ? "{\"version\":2,\"event\":\"" : "{\"version\":1,\"event\":\""; record += event; - record += "\",\"media_url\":\""; - record += mediaURL; - record += "\",\"position_seconds\":"; + if (miruro) { + const auto separator = mediaURL.find(':', 7); + record += "\",\"media_identity\":{\"provider\":\"miruro\",\"media_id\":\""; + record.append(mediaURL, 7, separator - 7); + record += "\",\"episode\":"; + record.append(mediaURL, separator + 1, std::string::npos); + record += "},\"position_seconds\":"; + } else { + record += "\",\"media_url\":\""; + record += mediaURL; + record += "\",\"position_seconds\":"; + } appendNumber(record, snapshot.position); record += ",\"duration_seconds\":"; if (snapshot.duration > 0) appendNumber(record, snapshot.duration); diff --git a/Sources/Core/progress_reporter.hpp b/Sources/Core/progress_reporter.hpp index 4891de3..2849f6d 100644 --- a/Sources/Core/progress_reporter.hpp +++ b/Sources/Core/progress_reporter.hpp @@ -20,6 +20,7 @@ public: ProgressReporter &operator=(const ProgressReporter &) = delete; static bool validMediaURL(std::string_view url) noexcept; + static bool validMediaIdentity(std::string_view identity) noexcept; void begin(const std::string &mediaURL, bool live = false) noexcept; void observe(const PlayerState &state) noexcept; void closeMedia() noexcept; diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..93ba996 --- /dev/null +++ b/TODO.md @@ -0,0 +1,2 @@ +- [ ] add miruro streaming support +- [ ] add chrome cast support \ No newline at end of file