From 8df500da83c44b9ed310c3ed32c9e5eb8ddbbce5 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 22 Oct 2026 19:04:30 +0200 Subject: [PATCH] feat: support Miruro provider tracks, proxies, and native embeds --- Checks/configuration.swift | 7 +- Checks/resolved-playback.py | 59 ++- Checks/resolved-playback.swift | 90 +++- Checks/stream-resolver.swift | 174 +++++-- Makefile | 1 + README.md | 8 +- Sources/App/CastMediaServer.swift | 3 +- Sources/App/MiruroEmbed.swift | 205 ++++++++ Sources/App/MiruroTransport.swift | 455 ++++++++++++++++++ Sources/App/ResolvedSource.swift | 37 +- Sources/App/StreamResolver.swift | 281 +++++++---- Sources/App/TerebiApp.swift | 39 +- .../Configuration/PlayerConfiguration.swift | 11 +- TODO.md | 4 - docs/miruro-playback-handoff.md | 30 +- 15 files changed, 1201 insertions(+), 203 deletions(-) create mode 100644 Sources/App/MiruroEmbed.swift create mode 100644 Sources/App/MiruroTransport.swift diff --git a/Checks/configuration.swift b/Checks/configuration.swift index c800013..cfb9cb9 100644 --- a/Checks/configuration.swift +++ b/Checks/configuration.swift @@ -135,16 +135,19 @@ struct ConfigurationChecks { ("video_provider = \"new-Host_2\"", "new-Host_2", ""), ("caption_provider = \"moon\"", "", "moon"), ("video_provider = \"sun\"\ncaption_provider = \"moon\"", "sun", "moon"), + ("video_provider = \"sun:dub\"\ncaption_provider = \"bee:ssub\"", "sun:dub", "bee:ssub"), + ("video_provider = \"new-Host_2:sub\"", "new-Host_2:sub", ""), ("video_provider = \"\"\ncaption_provider = \"\"", "", ""), ("video_provider = \"\(String(repeating: "a", count: 128))\"", String(repeating: "a", count: 128), "") ] { try Data("[miruro]\n\(settings)\n".utf8).write(to: miruro) try require(try PlayerConfiguration.load(from: miruro) == PlayerConfiguration(miruroVideoProvider: video, miruroCaptionProvider: captions), - "partial Miruro settings preserve omitted defaults and accept runtime provider names") + "partial Miruro settings accept provider preferences and explicit provider:track choices") } for key in ["video_provider", "caption_provider"] { - for name in ["with space", " sun", "sun ", "é", "host.name", "host/name", String(repeating: "a", count: 129)] { + for name in ["with space", " sun", "sun ", "é", "host.name", "host/name", ":ssub", "sun:", "sun:auto", + "sun:DUB", "sun:ssub:sub", "sun:sub ", String(repeating: "a", count: 129)] { try Data("[miruro]\n\(key) = \"\(name)\"\n".utf8).write(to: miruro) try requireLoadError(at: miruro, "invalid Miruro provider") } diff --git a/Checks/resolved-playback.py b/Checks/resolved-playback.py index 06df001..05c9cda 100644 --- a/Checks/resolved-playback.py +++ b/Checks/resolved-playback.py @@ -1,4 +1,5 @@ """Local-only resolved HLS/VTT + native model smoke; no live requests.""" +import base64 import http.server import json from pathlib import Path @@ -6,7 +7,7 @@ import subprocess import sys import tempfile import threading -from urllib.parse import urlsplit +from urllib.parse import parse_qs, urlsplit binary = str(Path(sys.argv[1]).resolve()) fixtures = Path(sys.argv[2]).resolve() @@ -17,7 +18,51 @@ class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, *_): pass + def transport(self): + path = urlsplit(self.path).path + try: + encoded, suffix = path.lstrip("/").split("/", 1) + target, referer = encoded.split("~") + def decode(value): + raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + return bytes(byte ^ [1, 2, 3][index % 3] for index, byte in enumerate(raw)).decode() + upstream = urlsplit(decode(target)) + if not upstream.path.startswith("/transport-"): + return False + assert decode(referer) == "https://transport.example/" + assert self.headers.get("X-Secret") is None and self.headers.get("Referer") is None, "private headers leaked to proxy" + except (ValueError, UnicodeError): + return False + query = parse_qs(upstream.query) + if upstream.path.endswith(".m3u8"): + assert query == {"skip": ["fixture"]}, "provider query or token-removal rule lost" + port = self.server.server_port + if upstream.path == "/transport-master.m3u8": + def encode(value): + raw = value.encode() + return base64.urlsafe_b64encode(bytes(byte ^ [1, 2, 3][index % 3] for index, byte in enumerate(raw))).decode().rstrip("=") + nested = (f"http://localhost:{port}/" + encode(f"http://127.0.0.1:{port}/transport-variant.m3u8?token=private") + + "~" + encode("https://transport.example/") + "/pl.m3u8") + data = f"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\n{nested}\n".encode() + elif upstream.path == "/transport-variant.m3u8": + data = (f'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="http://2130706433:{port}/transport-private"\n' + '#EXTINF:1,\nhttps://foreign.invalid/transport-segment.bin\n#EXT-X-ENDLIST\n').encode() + elif upstream.path == "/transport-segment.bin": + assert upstream.hostname == "127.0.0.1", "segment origin rewrite lost" + data = bytes(range(256)) + else: + errors.append("unexpected transport request") + self.send_error(403) + return True + self.send_response(200) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return True + def do_GET(self): + if self.transport(): + return path = urlsplit(self.path).path seen.append(path) alternate = path.startswith("/alternate/") @@ -30,13 +75,17 @@ class Handler(http.server.BaseHTTPRequestHandler): self.send_error(403) return if path == "/master.m3u8": - if urlsplit(self.path).query != "token=fixture": - errors.append("master token lost") + query = parse_qs(urlsplit(self.path).query) + if query.get("token") != ["fixture"] or (alternate and query.get("rewrite") != ["1"]): + errors.append("master token or provider query lost") data = b'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\nvariant.m3u8?token=variant\n' elif path == "/variant.m3u8": - if urlsplit(self.path).query != "token=variant": - errors.append("variant token lost") + query = parse_qs(urlsplit(self.path).query) + if query.get("token") != ["variant"] or (alternate and query.get("rewrite") != ["1"]): + errors.append("variant token or provider query lost") data = (fixtures / "resolved-vod.m3u8").read_bytes() + if alternate: + data = data.replace(b"resolved-vod-", b"https://invalid.example/alternate/resolved-vod-") elif path == "/english.jpeg": data = (b'WEBVTT\n\n00:00.000 --> 00:10.000\nAlternate English cue\n' if alternate else b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n') diff --git a/Checks/resolved-playback.swift b/Checks/resolved-playback.swift index 5806de1..9e0e57f 100644 --- a/Checks/resolved-playback.swift +++ b/Checks/resolved-playback.swift @@ -32,12 +32,13 @@ struct ResolvedPlaybackCheck { NSApp.setActivationPolicy(.prohibited) let input = CommandLine.arguments[1] let file = URL(fileURLWithPath: input) + let preference = CommandLine.arguments.first(where: { $0.hasPrefix("--provider=") }).map { String($0.dropFirst(11)) } ?? "" let source: ResolvedSource if input.hasPrefix("https://") { guard case .miruro(let mediaID, let episode) = try StreamURL.parse(input) else { fatalError("FAIL: URL smoke requires a Miruro episode") } - source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode).select().source + source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode).select(videoProvider: preference).source } else { source = try ResolvedSource.load(from: file) } @@ -49,7 +50,8 @@ struct ResolvedPlaybackCheck { let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let clock = Clock() - let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil, + let report = CommandLine.arguments.count > 2 && !CommandLine.arguments[2].hasPrefix("--") ? CommandLine.arguments[2] : nil + let model = PlayerModel(defaults: defaults, configuration: .init(miruroVideoProvider: preference), progressFile: report, miruroMetadataResolver: { id, episode in if input.hasPrefix("https://") { return try await MiruroMetadataResolver.resolve(mediaID: id, episode: episode) @@ -85,7 +87,8 @@ struct ResolvedPlaybackCheck { 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 preparedTrack = try await MiruroTransport.subtitle(track) + let document = try await WebVTTDocument.load(url: preparedTrack.url, headers: preparedTrack.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) @@ -105,8 +108,10 @@ struct ResolvedPlaybackCheck { print("PASS: default VTT selected, forward/backward seek and acknowledged cue timing") if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) } if !input.hasPrefix("https://") { + try await transportProtocol(source: source) model.shutdown() try await providerLifecycle(source: source) + try await dubOnlySelection(source: source) try await queueMetadataLifecycle(source: source) try await streamQueueLifecycle(source: source) try await enqueueLifecycle(source: source) @@ -114,7 +119,35 @@ struct ResolvedPlaybackCheck { } } - static func providerSources(_ source: ResolvedSource, alternate: Bool = false) throws -> MiruroSources { + static func transportProtocol(source: ResolvedSource) async throws { + let original = URLComponents(string: source.url)! + let base = "\(original.scheme!)://\(original.host!):\(original.port!)" + let deployment = MiruroProxyDeployment(base: URL(string: base.replacingOccurrences(of: "127.0.0.1", with: "localhost") + "/")!, key: [1, 2, 3]) + var input = ResolvedSource(url: base + "/transport-master.m3u8?token=private", headers: + "Referer: https://transport.example/\r\nX-Secret: private\r\n", + identity: source.identity, title: source.title, subtitles: []) + input.requirements = .init(proxy: .init(rotate: false, segments: true), + segmentOrigin: base, playlistQuery: ["skip": "fixture"]) + let prepared = try await MiruroTransport.prepare(input, deployment: deployment) + let (master, _) = try await URLSession.shared.data(from: URL(string: prepared.url)!) + let nested = String(data: master, encoding: .utf8)!.components(separatedBy: .newlines) + .first { $0.hasPrefix("http://") }! + let (variant, _) = try await URLSession.shared.data(from: URL(string: nested)!) + let lines = String(data: variant, encoding: .utf8)!.components(separatedBy: .newlines) + let segment = lines.first { $0.hasPrefix("http://") }! + let (body, response) = try await URLSession.shared.data(from: URL(string: segment)!) + require((response as? HTTPURLResponse)?.statusCode == 200 && body == Data((0..<256).map(UInt8.init)), + "proxied nested playlists decode once; query and segment-origin rules fetch real resource bytes") + let keyLine = lines.first { $0.hasPrefix("#EXT-X-KEY:") }! + let key = keyLine.components(separatedBy: "\"")[1] + let (_, blocked) = try await URLSession.shared.data(from: URL(string: key)!) + require((blocked as? HTTPURLResponse)?.statusCode == 502, + "manifest key cannot make the relay fetch a foreign loopback destination") + withExtendedLifetime(prepared) {} + print("PASS: proxy codec, already-proxied nested HLS, query/origin rules, credential isolation and private-destination rejection") + } + + static func providerSources(_ source: ResolvedSource, alternate: Bool = false, track: String = "ssub") throws -> MiruroSources { func headers(_ text: String) -> [String: String] { Dictionary(uniqueKeysWithValues: text.components(separatedBy: "\r\n").filter { !$0.isEmpty }.map { let parts = $0.split(separator: ":", maxSplits: 1) @@ -140,11 +173,15 @@ struct ResolvedPlaybackCheck { header: ["X-Resource": "alternate-private"], subtitles: [["file": source.subtitles[0].url.replacingOccurrences(of: "/english.jpeg", with: "/alternate/english.jpeg"), "format": "vtt", "language": "en", "label": "Alternate English", "default": true]])) - config["alternate"] = ["player": "native", "proxy": false] + let origin = URLComponents(string: source.url)! + let segmentOrigin = "\(origin.scheme!)://\(origin.host!):\(origin.port!)" + config["alternate"] = ["player": "native", "proxy": false, + "hls": ["query": ["enabled": true, "params": ["rewrite": "1"]], + "segments": ["enabled": true, "origin": segmentOrigin]]] } return try MiruroResolver.decode(JSONSerialization.data(withJSONObject: [ "episodeNumber": 1, "providerOrder": ["sun", "captions", "alternate"], "config": config, - "tracks": [["track": "ssub", "providers": providers]] + "tracks": [["track": track, "providers": providers]] ]), mediaID: "fixture-ID", episode: 1) } @@ -171,23 +208,29 @@ struct ResolvedPlaybackCheck { try await until("configured missing host falls back with independent captions", model: model) { model.canChangeMiruroProvider && model.state.position > 0.2 && !model.subtitleText.isEmpty } - require(model.selectedMiruroVideoProvider == "sun" && model.selectedMiruroCaptionProvider == "captions", + require(model.selectedMiruroVideoProvider == "sun:ssub" && model.selectedMiruroCaptionProvider == "captions:ssub", "startup preferences select independent providers with host fallback") require(model.setPlayback(playing: false), "pause before provider switches") try await until("provider pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED } let position = model.state.position let revision = model.state.revision - model.setMiruroCaptionProvider("alternate") + model.setMiruroCaptionProvider("alternate:ssub") try await until("alternate provider's own VTT is loaded", model: model) { model.subtitleText == "Alternate English cue" } require(model.state.revision == revision && model.state.position == position && requests == 1, "caption selection does not reopen video, seek or refetch sources") - model.setMiruroVideoProvider("alternate") + model.setMiruroVideoProvider("alternate:ssub") try await until("alternate host preserves paused position", model: model) { model.canChangeMiruroProvider && model.state.status == PLAYER_PAUSED && model.state.revision != revision && abs(model.state.position - position) < 0.2 } - require(model.selectedMiruroVideoProvider == "alternate" && model.selectedMiruroCaptionProvider == "alternate", + require(model.selectedMiruroVideoProvider == "alternate:ssub" && model.selectedMiruroCaptionProvider == "alternate:ssub", "host and captions remain independently selected") + let preparedRevision = model.state.revision + model.setMiruroCaptionProvider("captions:ssub") + try await until("caption change retains prepared video transport", model: model) { + !model.subtitleText.isEmpty && model.selectedMiruroCaptionProvider == "captions:ssub" + } + require(model.state.revision == preparedRevision, "prepared video is not reopened for captions") model.selectSubtitle(nil) model.setMiruroCaptionProvider("") require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "caption provider respects explicit Off") @@ -196,15 +239,15 @@ struct ResolvedPlaybackCheck { model.setMiruroVideoProvider("") try await until("automatic host returns to Sun and preserves playing intent", model: model) { model.canChangeMiruroProvider && model.state.status == PLAYER_PLAYING && - model.selectedMiruroVideoProvider == "sun" + model.selectedMiruroVideoProvider == "sun:ssub" } require(model.selectedSubtitle == nil, "host changes preserve explicit subtitle Off") - model.setMiruroVideoProvider("alternate") + model.setMiruroVideoProvider("alternate:ssub") let deadline = ContinuousClock.now.advanced(by: .seconds(5)) while (model.resolvingStream || model.state.status != PLAYER_PLAYING), ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) } - require(model.commandError != nil && model.selectedMiruroVideoProvider == "sun" && + require(model.commandError != nil && model.selectedMiruroVideoProvider == "sun:ssub" && model.state.status == PLAYER_PLAYING && model.canChangeMiruroProvider, "failed host fetch retains previous source and resumes playback") model.setMiruroVideoProvider("") @@ -219,6 +262,27 @@ struct ResolvedPlaybackCheck { print("PASS: provider config fallback, independent caption headers, in-place captions, paused/playing host switches and failure retention") } + static func dubOnlySelection(source: ResolvedSource) async throws { + let providers = try providerSources(source, track: "dub") + let suite = "terebi.dub-check." + UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let model = PlayerModel(defaults: defaults, miruroResolver: { _, _ in providers }, + miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) }) + defer { model.shutdown() } + model.openStream("https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1") + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while model.resolvingStream, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) } + require(model.commandError != nil && model.miruroVideoProviders.contains("sun:dub") && model.canChangeMiruroProvider, + "dub-only episode exposes explicit choices without automatic language fallback") + model.setMiruroVideoProvider("sun:dub") + try await until("explicit dub-only choice decodes", model: model) { + model.state.has_video && model.state.position > 0.2 && model.selectedMiruroVideoProvider == "sun:dub" + } + require(model.selectedMiruroCaptionProvider == "captions:dub", "automatic captions follow explicit dub track") + print("PASS: dub-only choices remain accessible and explicit selection plays") + } + static func queueMetadataLifecycle(source: ResolvedSource) async throws { let suite = "terebi.queue-metadata-check." + UUID().uuidString let defaults = UserDefaults(suiteName: suite)! diff --git a/Checks/stream-resolver.swift b/Checks/stream-resolver.swift index a7dda06..0fa16b7 100644 --- a/Checks/stream-resolver.swift +++ b/Checks/stream-resolver.swift @@ -94,90 +94,158 @@ struct StreamResolverChecks { try require(source.subtitles.count == 2 && source.subtitles[0].isDefault, "only external VTT retained with default") try require(source.subtitles[0].url.hasSuffix(".jpeg") && source.subtitles[1].label == "Spanish (Latin America)", "subtitle format and labels independent of file extension") try require(source.subtitles.allSatisfy { $0.headers == source.headers }, "verified Sun subtitle headers retained") - let defaults = try MiruroResolver.decode(Data(fixture.utf8), mediaID: mediaID, episode: 1) - let defaultSelection = try defaults.select() - try require(defaults.videoProviders == ["sun"] && defaults.captionProviders == ["sun"], "menus contain usable native providers") - try require(defaultSelection.videoProvider == "sun" && defaultSelection.captionProvider == "sun", "existing Sun defaults") let native: [String: Any] = ["player": "native", "proxy": false] + var captionOnly = provider("bun") + captionOnly["servers"] = [["headers": ["Referer": "https://bun.example/"], + "streams": [], "embed": ["url": "https://bun.example/embed"]]] + var missingVideo = provider("captions") + missingVideo["servers"] = [["headers": ["Origin": "https://captions.example"]]] + let independentCatalog = try catalog([ + "episodeNumber": 1, + "config": ["sun": native, "bun": ["player": "iframe", "proxy": false]], + "tracks": [["track": "ssub", "providers": [provider("sun"), captionOnly, missingVideo]]] + ]) + let independentCaptions = try independentCatalog.select(captionProvider: "bun:ssub") + try require(independentCaptions.source.subtitles.first?.url == "https://bun.example/english.vtt" && + independentCaptions.source.subtitles.first?.headers == "Referer: https://bun.example/\r\n", + "iframe-only provider captions are discovered independently of video eligibility") + let unconfiguredCaptions = try independentCatalog.select(captionProvider: "captions") + try require(unconfiguredCaptions.captionProvider == "captions:ssub" && + unconfiguredCaptions.source.subtitles.first?.headers == "Origin: https://captions.example\r\n", + "captions without config or video streams retain their own server headers") + let embedded = try independentCatalog.select(videoProvider: "bun:ssub") + try require(independentCatalog.videoProviders == ["sun:ssub", "bun:ssub"] && + independentCatalog.captionProviders == ["sun:ssub", "bun:ssub", "captions:ssub"] && + embedded.source.url == "https://bun.example/embed" && embedded.source.requirements.embed, + "iframe choices retain the embed URL and require asynchronous native preparation") + let captionsOnly = try catalog([ + "episodeNumber": 1, "tracks": [["track": "ssub", "providers": [missingVideo]]] + ]) + try require(captionsOnly.videoProviders.isEmpty && captionsOnly.captionProviders == ["captions:ssub"], + "caption discovery does not require any video candidate or config table") + try reject("caption-only catalog playback") { _ = try captionsOnly.select() } var document: [String: Any] = [ "episodeNumber": 1, "providerOrder": ["cloud", "river", "sun", "cloud", "missing"], "config": ["sun": native, "river": native, "cloud": native], "tracks": [["track": "ssub", "providers": [provider("river"), provider("sun"), provider("cloud")]]] ] let choices = try catalog(document) - try require(choices.videoProviders == ["cloud", "river", "sun"] && - choices.captionProviders == ["cloud", "river", "sun"], "runtime names follow deduplicated provider order") + try require(choices.videoProviders == ["cloud:ssub", "river:ssub", "sun:ssub"] && + choices.captionProviders == ["cloud:ssub", "river:ssub", "sun:ssub"], + "choices expose provider and track in deduplicated runtime provider order") let automatic = try choices.select() - try require(automatic.videoProvider == "sun" && automatic.captionProvider == "sun", "Auto prefers usable Sun") - let independent = try choices.select(videoProvider: "river", captionProvider: "cloud") - try require(independent.videoProvider == "river" && independent.captionProvider == "cloud", "explicit independent choices") - try require(independent.source.url == "https://river.example/master.m3u8" && - independent.source.headers == "Referer: https://river.example/\r\n", "selected host retains its URL and headers") - try require(independent.source.subtitles.first?.url == "https://cloud.example/english.vtt" && - independent.source.subtitles.first?.headers == "Referer: https://cloud.example/\r\n", "captions retain their own provider headers") - let sameHost = try choices.select(videoProvider: "river") - try require(sameHost.captionProvider == "river", "Auto captions prefer chosen video provider") + try require(automatic.videoProvider == "sun:ssub" && automatic.captionProvider == "sun:ssub", + "Auto prefers usable Japanese Sun") + let independent = try choices.select(videoProvider: "river", captionProvider: "cloud:ssub") + try require(independent.videoProvider == "river:ssub" && independent.captionProvider == "cloud:ssub" && + independent.source.url == "https://river.example/master.m3u8" && + independent.source.headers == "Referer: https://river.example/\r\n" && + independent.source.subtitles.first?.headers == "Referer: https://cloud.example/\r\n", + "bare provider preference and exact caption choice retain independent URLs and headers") let absent = try choices.select(videoProvider: "absent", captionProvider: "unsupported") let empty = try choices.select(videoProvider: "", captionProvider: "") - try require(absent.videoProvider == "sun" && absent.captionProvider == "sun" && - empty.videoProvider == "sun" && empty.captionProvider == "sun", "absent and empty preferences fall back to Auto") - document["tracks"] = [["track": "ssub", "providers": [provider("river"), provider("cloud")]]] - let withoutSun = try catalog(document).select() - try require(withoutSun.videoProvider == "cloud" && withoutSun.captionProvider == "cloud", "provider order applies without Sun") - document["providerOrder"] = [] - let returnedOrder = try catalog(document).select() - try require(returnedOrder.videoProvider == "river", "returned order is deterministic when no provider order applies") + try require(absent.videoProvider == "sun:ssub" && absent.captionProvider == "sun:ssub" && + empty.videoProvider == "sun:ssub" && empty.captionProvider == "sun:ssub", + "unavailable and empty preferences fall back to Japanese Auto") document["tracks"] = [["track": "ssub", "providers": [provider("river", captions: false), provider("cloud")]]] + let withoutSun = try catalog(document).select() + try require(withoutSun.videoProvider == "cloud:ssub" && withoutSun.captionProvider == "cloud:ssub", + "provider order applies without Sun") + document["providerOrder"] = [] let otherCaptions = try catalog(document).select() - try require(otherCaptions.videoProvider == "river" && otherCaptions.captionProvider == "cloud", "Auto captions fall back independently") + try require(otherCaptions.videoProvider == "river:ssub" && otherCaptions.captionProvider == "cloud:ssub", + "response order is retained and captions fall back independently") document["tracks"] = [["track": "sub", "providers": [provider("river", captions: false)]]] let noCaptions = try catalog(document) let bare = try noCaptions.select(captionProvider: "cloud") - try require(noCaptions.captionProviders.isEmpty && bare.captionProvider == nil && bare.source.subtitles.isEmpty, "Japanese sub without captions remains playable") - var soft = provider("river") - soft["servers"] = [["streams": [["url": "https://soft.example/master.m3u8", "format": "m3u8"]]]] - document["tracks"] = [["track": "sub", "providers": [provider("river")]], - ["track": "ssub", "providers": [soft]], - ["track": "dub", "providers": [provider("cloud")]]] - let preferredVariant = try catalog(document) - let preferredSelection = try preferredVariant.select() - try require(preferredVariant.videoProviders == ["river"] && - preferredSelection.source.url == "https://soft.example/master.m3u8", "ssub wins per provider without dub fallback") - var mixedConfigs: [String: Any] = ["river": native, "proxy": ["player": "native", "proxy": ["segments": true]], - "iframe": ["player": "iframe", "proxy": false], "broken": ["player": 42], "bad name": native] - mixedConfigs["rewrite"] = ["player": "native", "proxy": false, "hls": ["query": ["enabled": true]]] - document["config"] = mixedConfigs + try require(noCaptions.captionProviders.isEmpty && bare.captionProvider == nil && bare.source.subtitles.isEmpty, + "Japanese sub without captions remains playable") + document["tracks"] = [ + ["track": "ssub", "providers": [provider("river")]], + ["track": "dub", "providers": [provider("river"), provider("sun")]], + ["track": "sub", "providers": [provider("river")]]] + document["config"] = ["sun": native, "river": ["player": "native", "proxy": false, + "variantOrder": ["dub", "sub", "sub", "ssub"]]] + let variants = try catalog(document) + let preferredVariant = try variants.select(videoProvider: "river") + let dubbed = try variants.select(videoProvider: "river:dub", captionProvider: "river:dub") + try require(variants.videoProviders == ["river:dub", "river:sub", "river:ssub", "sun:dub"] && + preferredVariant.videoProvider == "river:sub" && preferredVariant.captionProvider == "river:sub" && + dubbed.videoProvider == "river:dub" && dubbed.captionProvider == "river:dub", + "all tracks remain selectable; bare provider uses variantOrder without implicit dub") + document["tracks"] = [["track": "dub", "providers": [provider("sun")]]] + let dubOnly = try catalog(document) + try reject("automatic dub fallback") { _ = try dubOnly.select() } + try reject("bare provider dub fallback") { _ = try dubOnly.select(videoProvider: "sun") } + let explicitDub = try dubOnly.select(videoProvider: "sun:dub") + try require(explicitDub.videoProvider == "sun:dub", + "dub-only catalog remains available for an explicit track choice") + let processed: [String: Any] = ["player": "native", "proxy": ["rotate": true, "segments": true], + "hls": ["segments": ["enabled": true, "origin": "https://segments.example"], + "query": ["enabled": true, "params": ["skip": "opaque", "token": "secret"]]]] + document["config"] = ["river": native, "bee": processed] + document["tracks"] = [["track": "ssub", "providers": [provider("river"), provider("bee")]]] + let processing = try catalog(document).select(videoProvider: "bee", captionProvider: "bee") + try require(processing.source.url == "https://bee.example/master.m3u8" && + processing.source.requirements.proxy?.rotate == true && + processing.source.requirements.proxy?.segments == true && + processing.source.requirements.segmentOrigin == "https://segments.example" && + processing.source.requirements.playlistQuery == ["skip": "opaque", "token": "secret"] && + processing.source.subtitles.first?.proxy?.segments == true && + !processing.source.requirements.embed, + "selection preserves native proxy/HLS requirements and caption proxy independently") + let parent = provider("bee") + var embeddedCaptions = provider("bun") + embeddedCaptions["subtitles"] = parent["subtitles"] + embeddedCaptions["servers"] = [["streams": [], "embed": ["url": "https://megaplay.buzz/videojs/stream/example"]]] + document["config"] = ["river": native, "bee": processed, + "bun": ["player": "iframe", "proxy": false, "parent": "bee"]] + document["tracks"] = [["track": "ssub", "providers": [provider("river"), parent, embeddedCaptions]]] + let sharedCaptions = try catalog(document).select(captionProvider: "bun:ssub") + try require(sharedCaptions.captionProvider == "bun:ssub" && + sharedCaptions.source.subtitles.first?.headers == "Referer: https://bee.example/\r\n" && + sharedCaptions.source.subtitles.first?.proxy?.segments == true, + "identical iframe caption resources use their parent's known request transport") + embeddedCaptions["subtitles"] = provider("bun")["subtitles"] + document["tracks"] = [["track": "ssub", "providers": [provider("river"), parent, embeddedCaptions]]] + let distinctCaptions = try catalog(document).select(captionProvider: "bun:ssub") + try require(distinctCaptions.source.subtitles.first?.proxy == nil && + distinctCaptions.source.subtitles.first?.headers.isEmpty == true, + "parent credentials and routing never propagate to different caption resources") + var unsafeHLS = processed + unsafeHLS["hls"] = ["segments": ["enabled": true, "origin": "file:///secret"]] + document["config"] = ["river": native, "bee": unsafeHLS] + let captionDespiteHLS = try catalog(document).select(videoProvider: "river", captionProvider: "bee:ssub") + try require(captionDespiteHLS.captionProvider == "bee:ssub" && + captionDespiteHLS.source.subtitles.first?.proxy?.segments == true, + "unsupported video processing does not hide valid independent captions") var resilient = provider("river") resilient["servers"] = [ - ["streams": [["url": "https://embed.example/master.m3u8", "format": "hls"]], "embed": ["url": "https://embed.example/"]], ["streams": [42, ["url": "file:///secret", "format": "hls"], ["url": "https://river.example/master.m3u8", "format": "application/vnd.apple.mpegurl"]] as [Any], - "headers": ["Referer": "https://river.example/"]]] as [[String: Any]] + "headers": ["Referer": "https://river.example/"]]] + document["config"] = ["river": native, "broken": ["player": 42], "bad name": native] document["tracks"] = [["track": "ssub", "providers": [ - 42, ["provider": "broken", "servers": "invalid"], provider("proxy"), provider("iframe"), - provider("rewrite"), provider("bad name"), resilient] as [Any]]] + 42, ["provider": "broken", "servers": "invalid"], provider("bad name"), resilient] as [Any]]] let resilientSources = try catalog(document) - let resilientChoice = try resilientSources.select(videoProvider: "proxy", captionProvider: "iframe") - try require(resilientSources.videoProviders == ["river"] && resilientSources.captionProviders == ["river"] && - resilientChoice.source.url == "https://river.example/master.m3u8", "malformed and unsupported entries do not poison usable alternatives") + try require(resilientSources.videoProviders == ["river:ssub"] && + resilientSources.captionProviders == ["river:ssub"], + "malformed neighboring entries do not poison usable provider tracks") for (old, replacement) in [ ("https://vault.example/english.jpeg", "https://user:secret@vault.example/english.jpeg"), ("\"default\": false", "\"default\": true") ] { let safeVideo = try project(fixture.replacingOccurrences(of: old, with: replacement)) - try require(safeVideo.url == source.url && safeVideo.subtitles.isEmpty, "unsafe caption sets are excluded without losing safe video") + try require(safeVideo.url == source.url && safeVideo.subtitles.isEmpty, + "unsafe caption sets are excluded without losing safe video") } let renewed = try project(fixture.replacingOccurrences(of: "token=secret", with: "token=renewed")) - try require(renewed.identity == source.identity && renewed.url != source.url, "token rotation does not change stable identity") + try require(renewed.identity == source.identity && renewed.url != source.url, + "token rotation does not change stable identity") for (old, replacement) in [ ("\"episodeNumber\": 1", "\"episodeNumber\": 49"), ("\"proxy\": false", "\"proxy\": true"), - ("\"enabled\": false", "\"enabled\": true"), - ("\"player\": \"native\"", "\"player\": \"iframe\""), - ("\"track\": \"ssub\"", "\"track\": \"dub\""), - ("\"proxy\": false", "\"proxy\": {\"segments\": true}"), ("\"format\": \"hls\"", "\"format\": \"mp4\""), - ("\"embed\": null", "\"embed\": {\"url\": \"https://embed.example/secret\"}"), ("https://vault.example/master.m3u8?token=secret", "file:///secret"), ("\"embed\": null", "\"embed\": false"), ("https://ultracloud.cc/\"", "https://ultracloud.cc/\\r\\nX-Injected: secret\""), @@ -204,7 +272,7 @@ struct StreamResolverChecks { _ = try await cancelled.value throw Failure(message: "Cancelled resolution was accepted") } catch is CancellationError {} - print("PASS: streaming URL security, runtime native providers, independent captions, bounded responses and cancellation") + print("PASS: streaming URL security, provider/track choices, proxy/HLS requirements, independent captions, bounded responses and cancellation") } static func main() async { diff --git a/Makefile b/Makefile index b7fe0ea..3cd10ef 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE) SWIFT_SOURCES += Sources/App/StreamResolver.swift SWIFT_SOURCES += Sources/App/MiruroMetadata.swift +SWIFT_SOURCES += Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift CLI_SOURCES += Sources/App/StreamResolver.swift SWIFT_SOURCES += Sources/App/StreamQueue.swift CLI_SOURCES += Sources/App/StreamQueue.swift diff --git a/README.md b/README.md index 8e3e426..d2b179c 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[key ### Miruro providers -Choose **Playback → Miruro → Video Host** or **Caption Provider** in Terebi's menu bar, or use **gear → Miruro**, to change providers for the session. Video and caption providers are independent; available names come from the episode response. These menus do not write the config file. +Choose **Playback → Miruro → Video Host** or **Caption Provider** in Terebi's menu bar, or use **gear → Miruro**, to change providers for the session. Choices retain both provider and track: `ssub` (soft subtitles), `sub` (hard subtitles), or `dub` (dubbed audio). Video and caption providers are independent; available choices come from the episode response. These menus do not write the config file. Optional startup preferences: @@ -93,9 +93,11 @@ video_provider = "" caption_provider = "" ``` -An empty or omitted preference means **Auto**. Automatic video selection prefers a usable **Sun** host, then the returned provider order. Automatic captions prefer the selected video's provider, then Sun, then the returned order. An unavailable or unsupported preference falls back to automatic selection; choosing captions does not change the video host. Provider names accept up to 128 ASCII letters, digits, underscores or hyphens; valid names need not be available for every episode. Unknown config keys and non-string values are rejected. +An empty or omitted preference means **Auto**. Automatic video selection prefers Japanese Sun, then the returned provider order; each provider's variant order is respected. A bare name such as `"bee"` prefers that provider's Japanese variant; `"bee:ssub"` or `"sun:sub"` selects a specific variant. Dub requires an explicit choice such as `"bun:dub"`—it is never an automatic language fallback. Dub-only episodes retain their choices in the menu after Automatic reports that no Japanese source is available. -Only native-playable sources are offered: iframe embeds and sources requiring a proxy or HLS rewriting are excluded. Provider selection does not switch into dubbed audio. +Automatic captions prefer the selected provider/track, then a compatible track from that provider, Sun, and returned order. Caption discovery is independent of video compatibility, including iframe-only providers. Missing preferences fall back to automatic selection. Provider names accept up to 128 ASCII letters, digits, underscores or hyphens, optionally followed by `:ssub`, `:sub` or `:dub`. Unknown config keys and non-string values are rejected. + +Native playback supports the returned proxy configuration and HLS query/segment-origin rules used by Bee/Hop. MegaPlay iframe sources used by Bun are extracted natively; no embedded browser, browser cookies or advertising scripts are run. Unsupported embed hosts fail rather than being passed to the native decoder. Source expiry and upstream deployment changes can still interrupt playback. ## Limitations diff --git a/Sources/App/CastMediaServer.swift b/Sources/App/CastMediaServer.swift index 9468143..8a72c51 100644 --- a/Sources/App/CastMediaServer.swift +++ b/Sources/App/CastMediaServer.swift @@ -97,7 +97,8 @@ private struct CastMediaCleanup: @unchecked Sendable { let handle: OpaquePointer var tracks: [CastTextTrack] = [] for (index, subtitle) in input.subtitles.enumerated() { try Task.checkCancellation() - let data = try await WebVTTDocument.loadData(url: subtitle.url, headers: subtitle.headers) + let prepared = try await MiruroTransport.subtitle(subtitle) + let data = try await WebVTTDocument.loadData(url: prepared.url, headers: prepared.headers) guard let text = String(data: data, encoding: .utf8) else { throw CastDeliveryError() } _ = try WebVTTDocument.parse(text) let trackURL = try owner.subtitle(id: index + 1, data: data) diff --git a/Sources/App/MiruroEmbed.swift b/Sources/App/MiruroEmbed.swift new file mode 100644 index 0000000..2a50800 --- /dev/null +++ b/Sources/App/MiruroEmbed.swift @@ -0,0 +1,205 @@ +import Foundation +import CommonCrypto +import CryptoKit + +/// Native counterpart of MegaPlay's deployed e1-player and newclient codecs. +struct MiruroEmbed: Sendable { + let url: URL + // Public embed-origin headers apply to its cross-origin media resources too: + // deployed CDN segment endpoints reject requests without this Referer. + let headers: String + private let key: Data + private let iv: Data + + static func resolve(url: URL, now: @Sendable () -> Date = { Date() }) async throws -> MiruroEmbed { + do { + try validateOrigin(url) + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 25 + configuration.timeoutIntervalForResource = 25 + configuration.httpShouldSetCookies = false + configuration.httpCookieStorage = nil + configuration.urlCache = nil + let session = URLSession(configuration: configuration, delegate: EmbedRedirectPolicy(), delegateQueue: nil) + defer { session.invalidateAndCancel() } + let html = try await text(url, session: session) + guard let player = capture(#"<[^>]+\bid\s*=\s*["']megaplay-player["'][^>]*>"#, html), + let id = capture(#"\bdata-id\s*=\s*["']([^"']+)["']"#, player), + let base = capture(#"\bbase_url\s*:\s*["']([^"']+)["']"#, html), + let baseURL = URL(string: base.replacingOccurrences(of: "\\/", with: "/"), relativeTo: url)?.absoluteURL else { + throw EmbedError() + } + try validateOrigin(baseURL) + // Only read script data; never evaluate provider JavaScript. + let scripts = captures(#"]*\bsrc\s*=\s*["']([^"']+)["']"#, html) + guard let scriptURL = scripts.compactMap({ URL(string: $0, relativeTo: url)?.absoluteURL }) + .first(where: { $0.path.hasSuffix("/e1-player.min.js") }) else { throw EmbedError() } + try validateOrigin(scriptURL) + let script = try await text(scriptURL, session: session) + guard let keyText = capture(#"\bSOURCE_ENC_KEY\s*=\s*"([^"\\]*)""#, script), + let ivText = capture(#"\bSOURCE_ENC_IV\s*=\s*"([^"\\]*)""#, script), + let secret = capture(#"\bCDN_TOKEN_SECRET\s*=\s*"([^"\\]*)""#, script) else { throw EmbedError() } + let key = padded(keyText, count: 32) + let iv = padded(ivText, count: 16) + var endpoint = URLComponents(url: baseURL.appendingPathComponent("stream/getSources"), resolvingAgainstBaseURL: false) + endpoint?.queryItems = [URLQueryItem(name: "id", value: id)] + guard let endpointURL = endpoint?.url else { throw EmbedError() } + let payload = try await request(endpointURL, session: session) + guard let object = try JSONSerialization.jsonObject(with: payload) as? [String: Any], + object["error"] == nil || (object["error"] as? Bool) == false else { throw EmbedError() } + let source: Any + if let encrypted = object["enc"] as? String { + source = try JSONSerialization.jsonObject(with: decrypt(encrypted, key: key, iv: iv)) + guard (source as? [String: Any])?["file"] is String else { throw EmbedError() } + } else if let plain = object["sources"] { + source = plain + } else { throw EmbedError() } + guard let file = streamFile(source), let stream = URL(string: file) else { throw EmbedError() } + try validateHTTP(stream) + let tokenEnabled = capture(#"\bcdnTokenEnable\s*:\s*(false|0|"false"|'false'|"0"|'0')\s*[,}]"#, html) == nil + let finalURL = try attachToken(stream, secret: secret, enabled: tokenEnabled, now: now()) + try Task.checkCancellation() + return MiruroEmbed(url: finalURL, headers: "Referer: https://megaplay.buzz/\r\nOrigin: https://megaplay.buzz\r\n", key: key, iv: iv) + } catch { + if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled { + throw CancellationError() + } + throw EmbedError() + } + } + + func resourceURL(_ url: URL) throws -> URL { + try Self.validateHTTP(url) + guard let token = Self.capture(#"/segment/([A-Za-z0-9_-]+)"#, url.path) else { return url } + let data = try Self.decrypt(token, key: key, iv: iv) + guard let text = String(data: data, encoding: .utf8), let decoded = URL(string: text) else { throw EmbedError() } + try Self.validateHTTP(decoded) + return decoded + } + + func resourceData(_ data: Data, url: URL) throws -> Data { + try Self.validateHTTP(url) + // newclient v4.23 SegmentStrip: only these hosts, and only bodies longer than 252 bytes. + guard let host = url.host, + Self.capture(#"ibyteimg\.com|tiktokcdn\.com|ipstatp\.com|yoot\.akirax\.buzz"#, host) != nil, + data.count > 252 else { return data } + return Data(data.dropFirst(252)) + } + + private static func request(_ url: URL, session: URLSession) async throws -> Data { + try validateOrigin(url) + var request = URLRequest(url: url) + request.timeoutInterval = 25 + request.setValue("https://megaplay.buzz/", forHTTPHeaderField: "Referer") + let (bytes, response) = try await session.bytes(for: request) + let limit = 2 * 1024 * 1024 + guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode), + response.expectedContentLength <= Int64(limit) else { throw EmbedError() } + var data = Data() + for try await byte in bytes { + try Task.checkCancellation() + guard data.count < limit else { throw EmbedError() } + data.append(byte) + } + return data + } + + private static func text(_ url: URL, session: URLSession) async throws -> String { + let data = try await request(url, session: session) + guard let text = String(data: data, encoding: .utf8) else { throw EmbedError() } + return text + } + + private static func streamFile(_ source: Any) -> String? { + if let string = source as? String { return string } + if let array = source as? [Any], let first = array.first { return streamFile(first) } + return (source as? [String: Any])?["file"] as? String + } + + private static func attachToken(_ url: URL, secret: String, enabled: Bool, now: Date) throws -> URL { + guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { throw EmbedError() } + guard enabled, !(parts.queryItems ?? []).contains(where: { $0.name == "token" }), + let pathKey = capture(#"/([a-f0-9]{32}/[a-f0-9]{32})/"#, url.path) else { return url } + let seconds = now.timeIntervalSince1970.rounded(.down) + guard seconds.isFinite, seconds >= 0, seconds < Double(Int64.max - 90) else { throw EmbedError() } + let message = Data("\(Int64(seconds) + 90)|\(pathKey.lowercased())".utf8) + let signature = HMAC.authenticationCode(for: message, using: SymmetricKey(data: Data(secret.utf8))) + let token = base64URL(message) + "." + base64URL(Data(signature)) + var items = parts.queryItems ?? [] + items.append(URLQueryItem(name: "token", value: token)) + parts.queryItems = items + guard let result = parts.url else { throw EmbedError() } + return result + } + + private static func padded(_ string: String, count: Int) -> Data { + var data = Data(string.utf8.prefix(count)) + data.append(Data(repeating: 0, count: count - data.count)) + return data + } + + private static func base64URL(_ data: Data) -> String { + data.base64EncodedString().replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } + + private static func decrypt(_ token: String, key: Data, iv: Data) throws -> Data { + var encoded = token.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4) + guard let encrypted = Data(base64Encoded: encoded), !encrypted.isEmpty, + encrypted.count % kCCBlockSizeAES128 == 0 else { throw EmbedError() } + var output = Data(count: encrypted.count + kCCBlockSizeAES128) + let capacity = output.count + var written = 0 + let status = output.withUnsafeMutableBytes { destination in + encrypted.withUnsafeBytes { input in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt(CCOperation(kCCDecrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, key.count, ivBytes.baseAddress, input.baseAddress, encrypted.count, + destination.baseAddress, capacity, &written) + } + } + } + } + guard status == kCCSuccess else { throw EmbedError() } + output.count = written + return output + } + + private static func validateHTTP(_ url: URL) throws { + guard ["https", "http"].contains(url.scheme?.lowercased() ?? ""), + let host = url.host, !host.isEmpty, url.user == nil, url.password == nil else { throw EmbedError() } + } + + private static func validateOrigin(_ url: URL) throws { + try validateHTTP(url) + guard url.scheme?.lowercased() == "https", url.host?.lowercased() == "megaplay.buzz", + url.port == nil || url.port == 443 else { throw EmbedError() } + } + + private static func capture(_ pattern: String, _ text: String) -> String? { + captures(pattern, text).first + } + + private static func captures(_ pattern: String, _ text: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return [] } + return regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { match in + let index = match.numberOfRanges > 1 ? 1 : 0 + guard let range = Range(match.range(at: index), in: text) else { return nil } + return String(text[range]) + } + } +} + +private struct EmbedError: LocalizedError { + var errorDescription: String? { "The embedded provider could not prepare this stream." } +} + +private final class EmbedRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + completionHandler(nil) + } +} diff --git a/Sources/App/MiruroTransport.swift b/Sources/App/MiruroTransport.swift new file mode 100644 index 0000000..0dbf0c5 --- /dev/null +++ b/Sources/App/MiruroTransport.swift @@ -0,0 +1,455 @@ +import Foundation +import Network +import Darwin + +private struct MiruroPlaybackError: LocalizedError { + var errorDescription: String? { "Could not prepare this Miruro provider. Private source details are redacted." } +} + +private final class MiruroMediaRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable { + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) } +} + +// No cookies, redirects or credentials inherited from the app/browser session. +enum MiruroMediaHTTP { + private static let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpShouldSetCookies = false + configuration.httpCookieStorage = nil + configuration.urlCredentialStorage = nil + configuration.urlCache = nil + configuration.timeoutIntervalForRequest = 20 + configuration.timeoutIntervalForResource = 30 + return URLSession(configuration: configuration, delegate: MiruroMediaRedirectPolicy(), delegateQueue: nil) + }() + + static func read(_ url: URL, headers: String = "", limit: Int = 2 * 1024 * 1024, + range: String? = nil) async throws -> (Data, HTTPURLResponse) { + _ = try YouTubeResolver.validateURL(url.absoluteString) + var request = URLRequest(url: url) + for line in headers.components(separatedBy: "\r\n") where !line.isEmpty { + guard let colon = line.firstIndex(of: ":") else { throw MiruroPlaybackError() } + request.setValue(String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces), + forHTTPHeaderField: String(line[.. 0 { data.reserveCapacity(Int(response.expectedContentLength)) } + for try await byte in bytes { + try Task.checkCancellation() + guard data.count < limit else { throw MiruroPlaybackError() } + data.append(byte) + } + return (data, response) + } +} + +struct MiruroProxyDeployment: Sendable { + let base: URL + let key: [UInt8] + var alternate: URL? = nil + + static func decode(_ html: String) throws -> Self { + let pattern = #"env:\s*(\{[^\n]*\})"# + guard let match = html.range(of: pattern, options: .regularExpression), + let start = html[match].firstIndex(of: "{"), + let data = String(html[start.. URL { + guard [base, alternate].compactMap({ $0 }).contains(where: { + $0.scheme == url.scheme && $0.host == url.host && $0.port == url.port + }) else { return url } + guard let encoded = url.path.split(separator: "/").first?.split(separator: "~").first else { + throw MiruroPlaybackError() + } + var text = encoded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + text += String(repeating: "=", count: (4 - text.count % 4) % 4) + guard let data = Data(base64Encoded: text) else { throw MiruroPlaybackError() } + var bytes = Array(data) + if !key.isEmpty { for index in bytes.indices { bytes[index] ^= key[index % key.count] } } + guard let target = String(bytes: bytes, encoding: .utf8), let result = URL(string: target), + ["http", "https"].contains(result.scheme), result.user == nil, result.password == nil else { + throw MiruroPlaybackError() + } + return result + } + + func url(_ target: URL, referer: String?, proxy: MiruroProxy, suffix: String) throws -> URL { + func encode(_ text: String) -> String { + var bytes = Array(text.utf8) + if !key.isEmpty { for index in bytes.indices { bytes[index] ^= key[index % key.count] } } + return Data(bytes).base64EncodedString().replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } + var path = encode(target.absoluteString) + if let referer, !referer.isEmpty { path += "~" + encode(referer) } + else if proxy.rotate { path += "~" } + if proxy.rotate { path += "~anon" } + guard let url = URL(string: base.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/" + path + suffix) else { + throw MiruroPlaybackError() + } + return url + } +} + +private actor MiruroDeploymentCache { + static let shared = MiruroDeploymentCache() + private var deployment: MiruroProxyDeployment? + func get() async throws -> MiruroProxyDeployment { + if let deployment { return deployment } + let (data, _) = try await MiruroMediaHTTP.read(URL(string: "https://barelystarted.miruro.tv/")!) + guard let html = String(data: data, encoding: .utf8) else { throw MiruroPlaybackError() } + let result = try MiruroProxyDeployment.decode(html) + deployment = result + return result + } +} + +// Only registered resources are reachable: no caller-supplied upstream URLs, and +// no private headers ever cross the loopback native/Cast handoff. +// ponytail: buffer up to eight 64-MiB resources; stream the relay if that ceiling becomes limiting. +final class MiruroTransport: @unchecked Sendable { + private enum Kind: String { case playlist, segment, resource } + private struct Resource { let url: URL; let kind: Kind; let depth: Int } + private let listener: NWListener + private let queue = DispatchQueue(label: "terebi.miruro.transport") + private let lock = NSLock() + private var resources: [String: Resource] = [:] + private var paths: [String: String] = [:] + private var active = 0 + private var preparedMaster: (Data, String, String?)? + private let token = UUID().uuidString + private let sourceURL: URL + private let headers: String + private let requirements: MiruroPlaybackRequirements + private let deployment: MiruroProxyDeployment? + private let embed: MiruroEmbed? + + private init(source: ResolvedSource, deployment: MiruroProxyDeployment?, embed: MiruroEmbed?) throws { + guard let sourceURL = URL(string: source.url) else { throw MiruroPlaybackError() } + self.sourceURL = embed?.url ?? sourceURL + headers = embed?.headers ?? source.headers + requirements = source.requirements + self.deployment = deployment + self.embed = embed + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any) + listener = try NWListener(using: parameters) + } + + static func prepare(_ source: ResolvedSource, deployment override: MiruroProxyDeployment? = nil) async throws -> ResolvedSource { + let rules = source.requirements + guard rules.embed || rules.proxy != nil || rules.segmentOrigin != nil || !rules.playlistQuery.isEmpty else { return source } + do { + let deployment: MiruroProxyDeployment? + if let override { deployment = override } + else if rules.proxy != nil { deployment = try await MiruroDeploymentCache.shared.get() } + else { deployment = nil } + let embed = try await rules.embed ? MiruroEmbed.resolve(url: URL(string: source.url)!) : nil + let owner = try MiruroTransport(source: source, deployment: deployment, embed: embed) + try await owner.start() + let url = try owner.register(owner.sourceURL, kind: .playlist, depth: 0) + // Exercise the master before replacing acknowledged playback. A failed + // provider switch must leave the current decoder and position intact. + let master = try await owner.load(Resource(url: owner.sourceURL, kind: .playlist, depth: 0), range: nil) + owner.lock.withLock { owner.preparedMaster = master } + var prepared = ResolvedSource(url: url.absoluteString, headers: "", identity: source.identity, + title: source.title, subtitles: source.subtitles) + prepared.playbackLifetime = owner + return prepared + } catch { + if Task.isCancelled || error is CancellationError { throw CancellationError() } + throw MiruroPlaybackError() + } + } + + static func subtitle(_ track: ResolvedSubtitle) async throws -> ResolvedSubtitle { + guard let proxy = track.proxy, let url = URL(string: track.url) else { return track } + do { + let deployment = try await MiruroDeploymentCache.shared.get() + let target = try deployment.url(url, referer: header("Referer", in: track.headers), proxy: proxy, suffix: "/sub.vtt") + return ResolvedSubtitle(url: target.absoluteString, format: track.format, language: track.language, + label: track.label, isDefault: track.isDefault, headers: [:]) + } catch { + if Task.isCancelled || error is CancellationError { throw CancellationError() } + throw MiruroPlaybackError() + } + } + + private static func header(_ name: String, in headers: String) -> String? { + for line in headers.components(separatedBy: "\r\n") { + guard let colon = line.firstIndex(of: ":"), line[..) in + listener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.listener.stateUpdateHandler = nil + continuation.resume() + case .failed, .cancelled: + self?.listener.stateUpdateHandler = nil + continuation.resume(throwing: MiruroPlaybackError()) + default: break + } + } + listener.newConnectionHandler = { [weak self] connection in self?.accept(connection) } + listener.start(queue: queue) + } + try Task.checkCancellation() + } + + deinit { listener.cancel() } + + private func register(_ url: URL, kind: Kind, depth: Int) throws -> URL { + guard depth <= 12, ["http", "https"].contains(url.scheme), url.user == nil, url.password == nil, + url.fragment == nil, url.absoluteString.utf8.count <= 32768, let port = listener.port else { throw MiruroPlaybackError() } + return try lock.withLock { + let key = kind.rawValue + ":" + url.absoluteString + let path: String + if let existing = paths[key] { path = existing } + else { + guard resources.count < 32768 else { throw MiruroPlaybackError() } + path = "/" + token + "/" + String(resources.count) + (kind == .playlist ? ".m3u8" : ".bin") + resources[path] = Resource(url: url, kind: kind, depth: depth) + paths[key] = path + } + return URL(string: "http://127.0.0.1:\(port.rawValue)\(path)")! + } + } + + private func accept(_ connection: NWConnection) { + let admitted = lock.withLock { () -> Bool in + guard active < 8 else { return false } + active += 1 + return true + } + guard admitted else { connection.cancel(); return } + connection.start(queue: queue) + queue.asyncAfter(deadline: .now() + 35) { connection.cancel() } + receive(connection, data: Data()) + } + + private func receive(_ connection: NWConnection, data: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] next, _, complete, error in + guard let self else { connection.cancel(); return } + var data = data + if let next { data.append(next) } + guard error == nil, data.count <= 16384 else { self.finish(connection); return } + guard data.range(of: Data("\r\n\r\n".utf8)) != nil else { + if complete { self.finish(connection) } else { self.receive(connection, data: data) } + return + } + Task { [self] in + defer { finish(connection) } + do { + guard let request = String(data: data, encoding: .utf8) else { throw MiruroPlaybackError() } + let lines = request.components(separatedBy: "\r\n") + let first = lines[0].split(separator: " ") + guard first.count == 3, first[0] == "GET" || first[0] == "HEAD", + let resource = lock.withLock({ resources[String(first[1])] }) else { throw MiruroPlaybackError() } + var range: String? + for line in lines.dropFirst() where line.lowercased().hasPrefix("range:") { + let value = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces) + guard value.range(of: #"^bytes=[0-9]+-[0-9]*$"#, options: .regularExpression) != nil, range == nil else { throw MiruroPlaybackError() } + range = value + } + let (body, type, contentRange) = try await load(resource, range: range) + let status = contentRange == nil ? "200 OK" : "206 Partial Content" + var response = "HTTP/1.1 \(status)\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nConnection: close\r\n" + if let contentRange { response += "Content-Range: \(contentRange)\r\n" } + try await send(Data((response + "\r\n").utf8), connection: connection) + if first[0] == "GET" { try await send(body, connection: connection) } + } catch { + try? await send(Data("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8), connection: connection) + } + } + } + } + + private func finish(_ connection: NWConnection) { + connection.cancel() + lock.withLock { active -= 1 } + } + + private func send(_ data: Data, connection: NWConnection) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + }) + } + } + + private func load(_ resource: Resource, range: String?) async throws -> (Data, String, String?) { + if resource.depth == 0, resource.kind == .playlist, + let master = lock.withLock({ () -> (Data, String, String?)? in + defer { preparedMaster = nil } + return preparedMaster + }) { return master } + let original = try deployment?.originalURL(resource.url) ?? resource.url + var url = try embed?.resourceURL(original) ?? original + if resource.kind == .playlist, !requirements.playlistQuery.isEmpty { + var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! + var query = components.queryItems ?? [] + query.removeAll { requirements.playlistQuery[$0.name] != nil } + query += requirements.playlistQuery.sorted { $0.key < $1.key }.map { URLQueryItem(name: $0.key, value: $0.value) } + components.queryItems = query + guard let result = components.url else { throw MiruroPlaybackError() } + url = result + } + if resource.kind == .segment, let origin = requirements.segmentOrigin, + let replacement = URLComponents(string: origin), var components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + components.scheme = replacement.scheme + components.host = replacement.host + components.port = replacement.port + guard let result = components.url else { throw MiruroPlaybackError() } + url = result + } + func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) { + // Same-origin fixture/source access remains allowed. A manifest or + // decrypted token cannot turn the relay into a local-network proxy. + if !Self.sameOrigin(url, sourceURL) { try Self.requirePublicDestination(url) } + let target: URL + let requestHeaders: String + if let proxy = requirements.proxy, let deployment, + resource.kind == .playlist || proxy.segments { + target = try deployment.url(url, referer: Self.header("Referer", in: headers), proxy: proxy, + suffix: resource.kind == .playlist ? "/pl.m3u8" : "/seg.jpg") + requestHeaders = "" + } else { + target = url + // Only the embed's fixed public Referer has cross-origin scope. + requestHeaders = embed?.headers ?? (Self.sameOrigin(url, sourceURL) ? headers : "") + } + return try await MiruroMediaHTTP.read(target, headers: requestHeaders, + limit: resource.kind == .playlist ? 2 * 1024 * 1024 : 64 * 1024 * 1024, + range: resource.kind == .playlist || embed != nil ? nil : range) + } + let raw: Data + let response: HTTPURLResponse + if resource.kind == .playlist, let skip = requirements.playlistQuery["skip"], !skip.isEmpty, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.queryItems?.contains(where: { $0.name == "token" }) == true { + components.queryItems?.removeAll { $0.name == "token" } + do { (raw, response) = try await fetch(components.url!) } + catch { + try Task.checkCancellation() + (raw, response) = try await fetch(url) + } + } else { (raw, response) = try await fetch(url) } + if resource.kind == .playlist { + guard response.statusCode == 200, let text = String(data: raw, encoding: .utf8), text.hasPrefix("#EXTM3U") else { throw MiruroPlaybackError() } + return (try rewrite(text, base: url, depth: resource.depth), "application/vnd.apple.mpegurl", nil) + } + guard embed == nil || response.statusCode == 200 else { throw MiruroPlaybackError() } + let data = try embed?.resourceData(raw, url: url) ?? raw + if embed != nil, let range { + let offsets = range.dropFirst(6).split(separator: "-", omittingEmptySubsequences: false) + guard let start = Int(offsets[0]), start < data.count else { throw MiruroPlaybackError() } + let end = offsets[1].isEmpty ? data.count - 1 : min(Int(offsets[1]) ?? -1, data.count - 1) + guard end >= start else { throw MiruroPlaybackError() } + return (data.subdata(in: start..<(end + 1)), "application/octet-stream", "bytes \(start)-\(end)/\(data.count)") + } + let contentRange = response.statusCode == 206 ? response.value(forHTTPHeaderField: "Content-Range") : nil + if response.statusCode == 206 { + guard let contentRange, + contentRange.range(of: #"^bytes [0-9]+-[0-9]+/([0-9]+|\*)$"#, options: .regularExpression) != nil else { + throw MiruroPlaybackError() + } + } + return (data, "application/octet-stream", contentRange) + } + + private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.scheme?.lowercased() == rhs.scheme?.lowercased() && lhs.host?.lowercased() == rhs.host?.lowercased() && + (lhs.port ?? (lhs.scheme == "https" ? 443 : 80)) == (rhs.port ?? (rhs.scheme == "https" ? 443 : 80)) + } + + private static func requirePublicDestination(_ url: URL) throws { + guard let host = url.host else { throw MiruroPlaybackError() } + var hints = addrinfo() + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + var addresses: UnsafeMutablePointer? + guard getaddrinfo(host, nil, &hints, &addresses) == 0, let first = addresses else { throw MiruroPlaybackError() } + defer { freeaddrinfo(first) } + var current: UnsafeMutablePointer? = first + while let entry = current { + let value = entry.pointee + if value.ai_family == AF_INET { + let address = value.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) } + let a = address >> 24, b = (address >> 16) & 255 + guard a != 0, a != 10, a != 127, a < 224, + !(a == 169 && b == 254), !(a == 172 && (16...31).contains(b)), + !(a == 192 && b == 168), !(a == 100 && (64...127).contains(b)), + !(a == 198 && (18...19).contains(b)) else { throw MiruroPlaybackError() } + } else if value.ai_family == AF_INET6 { + let bytes = value.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { + withUnsafeBytes(of: $0.pointee.sin6_addr) { Array($0) } + } + // Only globally routed unicast; excludes loopback, link-local, + // unique-local, multicast and IPv4-mapped local destinations. + guard bytes[0] & 0xe0 == 0x20 else { throw MiruroPlaybackError() } + } else { throw MiruroPlaybackError() } + current = value.ai_next + } + } + + private func rewrite(_ text: String, base: URL, depth: Int) throws -> Data { + let regex = try NSRegularExpression(pattern: #"URI=\"([^\"]+)\""#) + var nextPlaylist = false + var lines: [String] = [] + for raw in text.components(separatedBy: .newlines) { + var line = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if line.hasPrefix("#") { + let kind: Kind = line.hasPrefix("#EXT-X-MAP:") ? .segment : + (line.hasPrefix("#EXT-X-MEDIA:") || line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") || line.hasPrefix("#EXT-X-RENDITION-REPORT:")) ? .playlist : .resource + for match in regex.matches(in: line, range: NSRange(line.startIndex..., in: line)).reversed() { + guard let range = Range(match.range(at: 1), in: line), + let url = URL(string: String(line[range]), relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() } + let replacement = try register(url, kind: kind, depth: depth + 1) + line.replaceSubrange(range, with: replacement.absoluteString) + } + if line.hasPrefix("#EXT-X-STREAM-INF:") { nextPlaylist = true } + } else if !line.isEmpty { + guard let url = URL(string: line, relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() } + let kind: Kind = nextPlaylist || url.path.lowercased().hasSuffix(".m3u8") ? .playlist : .segment + line = try register(url, kind: kind, depth: depth + 1).absoluteString + nextPlaylist = false + } + lines.append(line) + } + return Data(lines.joined(separator: "\n").utf8) + } +} + diff --git a/Sources/App/ResolvedSource.swift b/Sources/App/ResolvedSource.swift index 41fcf57..c6f5ad5 100644 --- a/Sources/App/ResolvedSource.swift +++ b/Sources/App/ResolvedSource.swift @@ -8,18 +8,21 @@ struct ResolvedSubtitle: Decodable, Sendable { let label: String let isDefault: Bool private let headerFields: [String: String] + var proxy: MiruroProxy? = nil var headers: String { headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined() } fileprivate var validatedHeaders: [String: String] { headerFields } - init(url: String, format: String, language: String, label: String, isDefault: Bool, headers: [String: String]) { + init(url: String, format: String, language: String, label: String, isDefault: Bool, + headers: [String: String], proxy: MiruroProxy? = nil) { self.url = url self.format = format self.language = language self.label = label self.isDefault = isDefault self.headerFields = headers + self.proxy = proxy } private enum CodingKeys: String, CodingKey { case url, format, language, label @@ -34,6 +37,13 @@ struct ResolvedSource: Sendable { let identity: String let title: String let subtitles: [ResolvedSubtitle] + var requirements: MiruroPlaybackRequirements = .init() + var playbackLifetime: (any Sendable)? = nil + + func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource { + ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles, + requirements: requirements, playbackLifetime: playbackLifetime) + } struct Document: Decodable { struct Identity: Decodable { @@ -87,14 +97,7 @@ struct ResolvedSource: Sendable { validIdentity(mediaID: id.media_id, episode: id.episode), 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() } - } + validSubtitles(document.subtitles) 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, @@ -102,16 +105,26 @@ struct ResolvedSource: Sendable { subtitles: document.subtitles) } + static func validSubtitles(_ subtitles: [ResolvedSubtitle]) -> Bool { + subtitles.count <= 32 && subtitles.filter({ $0.isDefault }).count <= 1 && + subtitles.allSatisfy { + validURL($0.url) && $0.format == "vtt" && + validText($0.language, limit: 128) && !$0.language.isEmpty && + validText($0.label, limit: 4096) && !$0.label.isEmpty && + validHeaders($0.validatedHeaders) + } + } + 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 { + 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 { + static func validURL(_ text: String) -> Bool { guard !text.isEmpty, validText(text, limit: 32768), !text.contains(where: { $0.isWhitespace }), let components = URLComponents(string: text), @@ -122,7 +135,7 @@ struct ResolvedSource: Sendable { return components.port == nil || (1...65535).contains(components.port!) } - private static func validHeaders(_ headers: [String: String]) -> Bool { + static func validHeaders(_ headers: [String: String]) -> Bool { guard headers.count <= 64 else { return false } var names = Set() var size = 0 diff --git a/Sources/App/StreamResolver.swift b/Sources/App/StreamResolver.swift index e45bc06..f2c8c22 100644 --- a/Sources/App/StreamResolver.swift +++ b/Sources/App/StreamResolver.swift @@ -41,6 +41,29 @@ enum StreamURL: Sendable { } } +struct MiruroProxy: Decodable, Sendable { + let rotate: Bool + let segments: Bool + + enum CodingKeys: String, CodingKey { case rotate, segments } + init(rotate: Bool, segments: Bool) { + self.rotate = rotate + self.segments = segments + } + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + rotate = try values.decodeIfPresent(Bool.self, forKey: .rotate) ?? false + segments = try values.decodeIfPresent(Bool.self, forKey: .segments) ?? true + } +} + +struct MiruroPlaybackRequirements: Sendable { + var proxy: MiruroProxy? = nil + var segmentOrigin: String? = nil + var playlistQuery: [String: String] = [:] + var embed = false +} + struct MiruroSelection: Sendable { let source: ResolvedSource let videoProvider: String @@ -50,32 +73,46 @@ struct MiruroSelection: Sendable { struct MiruroSources: Sendable { fileprivate struct Candidate: Sendable { let provider: String + let track: String let source: ResolvedSource + var id: String { "\(provider):\(track)" } + } + fileprivate struct Captions: Sendable { + let provider: String + let track: String + let subtitles: [ResolvedSubtitle] + var id: String { "\(provider):\(track)" } } fileprivate let videos: [Candidate] - fileprivate let captions: [Candidate] + fileprivate let captions: [Captions] let videoProviders: [String] let captionProviders: [String] - fileprivate init(videos: [Candidate], captions: [Candidate]) { + fileprivate init(videos: [Candidate], captions: [Captions]) { self.videos = videos self.captions = captions - videoProviders = videos.map(\.provider) - captionProviders = captions.map(\.provider) + videoProviders = videos.map(\.id) + captionProviders = captions.map(\.id) } func select(videoProvider: String? = nil, captionProvider: String? = nil) throws -> MiruroSelection { - guard let video = videos.first(where: { $0.provider == videoProvider }) ?? - videos.first(where: { $0.provider == "sun" }) ?? videos.first else { - throw StreamResolutionError(message: "Direct Miruro Japanese source is unavailable.") + func requested(_ preference: String?, provider: String, track: String) -> Bool { + preference == "\(provider):\(track)" || + (preference == provider && track != "dub") } - let caption = captions.first(where: { $0.provider == captionProvider }) ?? - captions.first(where: { $0.provider == video.provider }) ?? - captions.first(where: { $0.provider == "sun" }) ?? captions.first - let source = ResolvedSource(url: video.source.url, headers: video.source.headers, - identity: video.source.identity, title: video.source.title, subtitles: caption?.source.subtitles ?? []) - return MiruroSelection(source: source, videoProvider: video.provider, captionProvider: caption?.provider) + let japanese = videos.lazy.filter { $0.track != "dub" } + guard let video = videos.first(where: { requested(videoProvider, provider: $0.provider, track: $0.track) }) ?? + japanese.first(where: { $0.provider == "sun" }) ?? japanese.first else { + throw StreamResolutionError(message: "Miruro Japanese source is unavailable. Select an available provider and track explicitly.") + } + let compatibleCaptions = captions.lazy.filter { $0.track == video.track || (video.track != "dub" && $0.track != "dub") } + let caption = captions.first(where: { requested(captionProvider, provider: $0.provider, track: $0.track) }) ?? + captions.first(where: { $0.id == video.id }) ?? + compatibleCaptions.first(where: { $0.provider == video.provider }) ?? + compatibleCaptions.first(where: { $0.provider == "sun" }) ?? compatibleCaptions.first + let source = video.source.replacingSubtitles(caption?.subtitles ?? []) + return MiruroSelection(source: source, videoProvider: video.id, captionProvider: caption?.id) } } @@ -145,66 +182,93 @@ enum MiruroResolver { let response = try JSONDecoder().decode(Sources.self, from: data) guard response.episodeNumber == episode else { throw InvalidProjection() } var videos: [MiruroSources.Candidate] = [] - var captions: [MiruroSources.Candidate] = [] - var videoNames = Set() - var captionNames = Set() - // Japanese variants only, preferring soft subtitles independently for each provider. - for variant in ["ssub", "sub"] { - for track in response.tracks.lazy.compactMap(\.value) where track.track == variant { - for provider in track.providers.lazy.compactMap(\.value) { - let name = provider.provider - guard validProviderName(name), let config = response.config[name]?.value, - config.player == "native", !config.proxy, - config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else { continue } - let needsVideo = !videoNames.contains(name) - let needsCaptions = !captionNames.contains(name) - guard needsVideo || needsCaptions else { continue } - for server in provider.servers.lazy.compactMap(\.value) where !server.hasEmbed { - let headers = server.headers ?? [:] - var found = false - for stream in server.streams.lazy.compactMap(\.value) { - guard !stream.hasEmbed, - ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format), - let video = try? ResolvedSource.validated(.init(version: 1, url: stream.url, - headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode), - title: nil, subtitles: [])) else { continue } - if videoNames.insert(name).inserted { - videos.append(.init(provider: name, source: video)) - } - // Caption requests retain this provider/server's headers, never the selected video's. - let subtitles = provider.subtitles.lazy.compactMap(\.value).filter { $0.format == "vtt" }.map { - ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language, - label: $0.label, isDefault: $0.default, headers: headers) - } - if needsCaptions, !subtitles.isEmpty, - let caption = try? ResolvedSource.validated(.init(version: 1, url: stream.url, - headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode), - title: nil, subtitles: Array(subtitles))) { - captions.append(.init(provider: name, source: caption)) - captionNames.insert(name) - } - found = true - break + var captions: [MiruroSources.Captions] = [] + var videoIDs = Set() + var captionIDs = Set() + for track in response.tracks.lazy.compactMap(\.value) where ["ssub", "sub", "dub"].contains(track.track) { + for provider in track.providers.lazy.compactMap(\.value) { + let name = provider.provider + guard validProviderName(name) else { continue } + let id = "\(name):\(track.track)" + let config = response.config[name]?.value + let servers = provider.servers.compactMap(\.value) + // Prefer supplied server headers, but do not require a playable video to use them. + let captionServer = servers.first { !($0.headers ?? [:]).isEmpty && ResolvedSource.validHeaders($0.headers ?? [:]) } ?? + servers.first { ResolvedSource.validHeaders($0.headers ?? [:]) } + if !captionIDs.contains(id), captionServer != nil || servers.isEmpty { + var headers = captionServer?.headers ?? [:] + var proxy = config?.proxy + let returned = provider.subtitles.compactMap(\.value) + // Iframe captions may be exact copies of a parent's resources. + // Reuse only that verified caption transport, never its video rules. + if headers.isEmpty, config?.player == "iframe", let parentName = config?.parent, + let parent = track.providers.compactMap(\.value).first(where: { $0.provider == parentName }), + parent.subtitles.compactMap(\.value) == returned, + let parentServer = parent.servers.compactMap(\.value).first(where: { + !($0.headers ?? [:]).isEmpty && ResolvedSource.validHeaders($0.headers ?? [:]) + }) { + headers = parentServer.headers ?? [:] + proxy = response.config[parentName]?.value?.proxy + } + let subtitles = returned.lazy + .filter { $0.format == "vtt" }.map { + ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language, + label: $0.label, isDefault: $0.default, headers: headers, proxy: proxy) } - if found { break } + let values = Array(subtitles) + if !values.isEmpty, ResolvedSource.validSubtitles(values) { + captions.append(.init(provider: name, track: track.track, subtitles: values)) + captionIDs.insert(id) + } + } + for server in servers { + let headers = server.headers ?? [:] + guard ResolvedSource.validHeaders(headers) else { continue } + guard !videoIDs.contains(id), let config, + var requirements = try? config.requirements() else { continue } + var locations: [(String, Bool)] = [] + if config.player == "native", server.embed == nil { + locations = server.streams.compactMap(\.value).compactMap { stream in + guard stream.embed == nil, + ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format) else { return nil } + return (stream.url, false) + } + } else if config.player == "iframe" { + if let embed = server.embed { locations.append((embed.url, true)) } + locations.append(contentsOf: server.streams.lazy.compactMap(\.value).compactMap { stream in + stream.embed.map { ($0.url, true) } + }) + } + for (url, embed) in locations { + guard var source = try? ResolvedSource.validated(.init(version: 1, url: url, + headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode), + title: nil, subtitles: [])) else { continue } + requirements.embed = embed + source.requirements = requirements + videos.append(.init(provider: name, track: track.track, source: source)) + videoIDs.insert(id) + break } } } } - // API order is advisory; retain response order for names omitted from it. - let order = response.providerOrder ?? [] - func ordered(_ candidates: [MiruroSources.Candidate]) -> [MiruroSources.Candidate] { - var remaining = Dictionary(uniqueKeysWithValues: candidates.map { ($0.provider, $0) }) - var result: [MiruroSources.Candidate] = [] - for name in order { - if let candidate = remaining.removeValue(forKey: name) { result.append(candidate) } - } - result.append(contentsOf: candidates.filter { remaining[$0.provider] != nil }) - return result + // Provider order is advisory and deduplicated; each provider retains all usable tracks. + var seen = Set() + let providerOrder = ((response.providerOrder ?? []) + videos.map(\.provider) + captions.map(\.provider)) + .filter { seen.insert($0).inserted } + func variants(_ name: String) -> [String] { + var seen = Set() + return ((response.config[name]?.value?.variantOrder ?? []) + ["ssub", "sub", "dub"]) + .filter { seen.insert($0).inserted } } - let sources = MiruroSources(videos: ordered(videos), captions: ordered(captions)) - _ = try sources.select() - return sources + let orderedVideos = providerOrder.flatMap { name in + variants(name).compactMap { variant in videos.first { $0.provider == name && $0.track == variant } } + } + let orderedCaptions = providerOrder.flatMap { name in + variants(name).compactMap { variant in captions.first { $0.provider == name && $0.track == variant } } + } + guard !orderedVideos.isEmpty || !orderedCaptions.isEmpty else { throw InvalidProjection() } + return MiruroSources(videos: orderedVideos, captions: orderedCaptions) } catch { if let error = error as? StreamResolutionError { throw error } throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.") @@ -228,17 +292,57 @@ enum MiruroResolver { let tracks: [Entry] let config: [String: Entry] let providerOrder: [String]? + enum CodingKeys: String, CodingKey { case episodeNumber, tracks, config, providerOrder } + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + episodeNumber = try values.decode(Int.self, forKey: .episodeNumber) + tracks = try values.decode([Entry].self, forKey: .tracks) + config = try values.decodeIfPresent([String: Entry].self, forKey: .config) ?? [:] + providerOrder = try values.decodeIfPresent([String].self, forKey: .providerOrder) + } } private struct ProviderConfig: Decodable { let player: String - // Objects describe proxy work this native player cannot perform. - let proxy: Bool + let proxy: MiruroProxy? let hls: HLS? + let variantOrder: [String]? + let parent: String? + enum CodingKeys: String, CodingKey { case player, proxy, hls, variantOrder, parent } + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + player = try values.decode(String.self, forKey: .player) + if let flag = try? values.decode(Bool.self, forKey: .proxy) { + guard !flag else { throw InvalidProjection() } + proxy = nil + } else { + proxy = try values.decode(MiruroProxy.self, forKey: .proxy) + } + hls = try values.decodeIfPresent(HLS.self, forKey: .hls) + variantOrder = try values.decodeIfPresent([String].self, forKey: .variantOrder) + parent = try values.decodeIfPresent(String.self, forKey: .parent) + } + + func requirements() throws -> MiruroPlaybackRequirements { + guard player == "native" || player == "iframe" else { throw InvalidProjection() } + var result = MiruroPlaybackRequirements(proxy: proxy) + if let segments = hls?.segments, segments.enabled == true { + guard let origin = segments.origin, ResolvedSource.validURL(origin) else { throw InvalidProjection() } + result.segmentOrigin = origin + } + if let query = hls?.query, query.enabled == true { + guard let params = query.params, params.count <= 64, + params.allSatisfy({ !$0.key.isEmpty && ResolvedSource.validText($0.key, limit: 256) && + ResolvedSource.validText($0.value, limit: 16384) }) else { throw InvalidProjection() } + result.playlistQuery = params + } + return result + } } private struct HLS: Decodable { - struct Rule: Decodable { let enabled: Bool? } - let segments: Rule? - let query: Rule? + struct Segments: Decodable { let enabled: Bool?; let origin: String? } + struct Query: Decodable { let enabled: Bool?; let params: [String: String]? } + let segments: Segments? + let query: Query? } private struct Track: Decodable { let track: String @@ -248,40 +352,33 @@ enum MiruroResolver { let provider: String let servers: [Entry] let subtitles: [Entry] + enum CodingKeys: String, CodingKey { case provider, servers, subtitles } + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + provider = try values.decode(String.self, forKey: .provider) + servers = try values.decodeIfPresent([Entry].self, forKey: .servers) ?? [] + subtitles = try values.decodeIfPresent([Entry].self, forKey: .subtitles) ?? [] + } } + private struct Embed: Decodable { let url: String } private struct Server: Decodable { let headers: [String: String]? let streams: [Entry] - let hasEmbed: Bool + let embed: Embed? enum CodingKeys: String, CodingKey { case headers, streams, embed } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) headers = try values.decodeIfPresent([String: String].self, forKey: .headers) - streams = try values.decode([Entry].self, forKey: .streams) - if values.contains(.embed) { - hasEmbed = try !values.decodeNil(forKey: .embed) - } else { - hasEmbed = false - } + streams = try values.decodeIfPresent([Entry].self, forKey: .streams) ?? [] + embed = try values.decodeIfPresent(Embed.self, forKey: .embed) } } private struct Stream: Decodable { let url: String let format: String - let hasEmbed: Bool - enum CodingKeys: String, CodingKey { case url, format, embed } - init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - url = try values.decode(String.self, forKey: .url) - format = try values.decode(String.self, forKey: .format) - if values.contains(.embed) { - hasEmbed = try !values.decodeNil(forKey: .embed) - } else { - hasEmbed = false - } - } + let embed: Embed? } - private struct Subtitle: Decodable { + private struct Subtitle: Decodable, Equatable { let file: String let format: String let language: String diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 14213a8..1e66f71 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -125,14 +125,17 @@ final class PlayerModel: ObservableObject { @Published private(set) var miruroCaptionProvider: String @Published private(set) var selectedMiruroVideoProvider: String? @Published private(set) var selectedMiruroCaptionProvider: String? + private var pendingMiruro: (mediaID: String, episode: Int, sources: MiruroSources)? private var miruroSources: MiruroSources? { + if let pendingMiruro { return pendingMiruro.sources } if case .resolved(_, let providers) = currentMedia { return providers } return nil } var miruroVideoProviders: [String] { miruroSources?.videoProviders ?? [] } var miruroCaptionProviders: [String] { miruroSources?.captionProviders ?? [] } var canChangeMiruroProvider: Bool { - castDevice == nil && miruroSources != nil && canTogglePlayback && + castDevice == nil && miruroSources != nil && + (pendingMiruro != nil ? canOpen && !resolvingStream : canTogglePlayback) && !isSeeking && !transition.seekPending && !state.seek_pending } @Published private(set) var subtitleTracks: [ResolvedSubtitle] = [] @@ -421,8 +424,9 @@ final class PlayerModel: ObservableObject { source = .youtube(video, height: nil) case .miruro(let mediaID, let episode): let providers = try await miruroResolver(mediaID, episode) - let resolved = try providers.select(videoProvider: miruroVideoProvider, + let selected = try providers.select(videoProvider: miruroVideoProvider, captionProvider: miruroCaptionProvider).source + let resolved = try await MiruroTransport.prepare(selected) guard resolved.identity == "miruro:\(mediaID):\(episode)" else { throw CocoaError(.fileReadCorruptFile) } url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))! title = resolved.title @@ -551,7 +555,8 @@ final class PlayerModel: ObservableObject { let track = subtitleTracks[index] subtitleTask = Task { [weak self] in do { - let document = try await WebVTTDocument.load(url: track.url, headers: track.headers) + let prepared = try await MiruroTransport.subtitle(track) + let document = try await WebVTTDocument.load(url: prepared.url, headers: prepared.headers) guard !Task.isCancelled, let self else { return } self.subtitleDocument = document self.updateSubtitle() @@ -598,8 +603,13 @@ final class PlayerModel: ObservableObject { func setMiruroVideoProvider(_ provider: String) { guard canChangeMiruroProvider, let handle, - provider.isEmpty || miruroVideoProviders.contains(provider), - provider != miruroVideoProvider, + provider.isEmpty || miruroVideoProviders.contains(provider) else { return } + if let pendingMiruro { + miruroVideoProvider = provider + resolveMiruro(mediaID: pendingMiruro.mediaID, episode: pendingMiruro.episode) + return + } + guard provider != miruroVideoProvider, case .resolved(let source, _) = currentMedia else { return } let identity = source.identity.split(separator: ":") guard identity.count == 3, let episode = Int(identity[2]) else { return } @@ -611,13 +621,18 @@ final class PlayerModel: ObservableObject { func setMiruroCaptionProvider(_ provider: String) { guard canChangeMiruroProvider, let providers = miruroSources, provider.isEmpty || miruroCaptionProviders.contains(provider) else { return } + if pendingMiruro != nil { + miruroCaptionProvider = provider + return + } + guard case .resolved(let current, _) = currentMedia else { return } do { let selection = try providers.select(videoProvider: selectedMiruroVideoProvider, captionProvider: provider) let language = selectedSubtitle.flatMap { subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil } miruroCaptionProvider = provider selectedMiruroCaptionProvider = selection.captionProvider - currentMedia = .resolved(selection.source, providers: providers) + currentMedia = .resolved(current.replacingSubtitles(selection.source.subtitles), providers: providers) subtitleTracks = selection.source.subtitles let index = language.flatMap { language in subtitleTracks.firstIndex { $0.language == language } ?? subtitleTracks.firstIndex(where: \.isDefault) } @@ -647,10 +662,12 @@ final class PlayerModel: ObservableObject { let videoProvider = miruroVideoProvider let captionProvider = miruroCaptionProvider let task = Task { [weak self] in + var catalog: MiruroSources? do { let providers = try await resolver(mediaID, episode) + catalog = providers let selection = try providers.select(videoProvider: videoProvider, captionProvider: captionProvider) - let source = selection.source + let source = try await MiruroTransport.prepare(selection.source) guard source.identity == "miruro:\(mediaID):\(episode)" else { throw CocoaError(.fileReadCorruptFile) } @@ -662,6 +679,9 @@ final class PlayerModel: ObservableObject { guard !Task.isCancelled, let self else { return } self.objectWillChange.send() guard self.transition.finishResolution(request) != nil else { return } + if let catalog, self.currentMedia?.url.absoluteString != "miruro:\(mediaID):\(episode)" { + self.pendingMiruro = (mediaID, episode, catalog) + } self.failStreamChange(change, error: error) } } @@ -712,6 +732,10 @@ final class PlayerModel: ObservableObject { } private func cancelStreamResolution() { + if pendingMiruro != nil { + objectWillChange.send() + pendingMiruro = nil + } guard transition.isResolving else { return } objectWillChange.send() transition.cancelResolution() @@ -872,6 +896,7 @@ final class PlayerModel: ObservableObject { queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision queueCompletionHandled = false currentMedia = source + pendingMiruro = nil if case .resolved(_, let providers) = source, let selection = try? providers?.select(videoProvider: miruroVideoProvider, captionProvider: miruroCaptionProvider) { diff --git a/Sources/Configuration/PlayerConfiguration.swift b/Sources/Configuration/PlayerConfiguration.swift index 3916837..86d9ff2 100644 --- a/Sources/Configuration/PlayerConfiguration.swift +++ b/Sources/Configuration/PlayerConfiguration.swift @@ -195,10 +195,13 @@ public struct PlayerConfiguration: Equatable, Sendable { private static func provider(_ name: String, in container: KeyedDecodingContainer) throws -> String? { let value = try container.decodeIfPresent(String.self, forKey: SettingKey(name)) if let value { - guard value.utf8.count <= 128, value.utf8.allSatisfy({ - (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 95 || $0 == 45 - }) else { - throw ConfigurationError(message: "miruro.\(name): Expected an empty string (Auto) or up to 128 ASCII letters, digits, underscores or hyphens.") + let parts = value.split(separator: ":", omittingEmptySubsequences: false) + let provider = parts.first ?? "" + guard value.isEmpty || ((parts.count == 1 || (parts.count == 2 && ["ssub", "sub", "dub"].contains(String(parts[1])))) && + !provider.isEmpty && provider.utf8.count <= 128 && provider.utf8.allSatisfy({ + (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 95 || $0 == 45 + })) else { + throw ConfigurationError(message: "miruro.\(name): Expected an empty string (Auto), a provider name (up to 128 ASCII letters, digits, underscores or hyphens), or provider:ssub, provider:sub or provider:dub.") } } return value diff --git a/TODO.md b/TODO.md index 642678c..e69de29 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +0,0 @@ -- [x] in compact mode, "side"bar should be expandable downwards (but hidden by default). - - let's also add a keybinding that toggles sidebar visibility in both normal and compact mode. - - also add some horizontal margin to the list entries -- [x] sidebar: fetch data quicker, so titles are populated independently of current-video stream extraction (including the current row); arrival still depends on the site/network. \ No newline at end of file diff --git a/docs/miruro-playback-handoff.md b/docs/miruro-playback-handoff.md index a70171a..9107e93 100644 --- a/docs/miruro-playback-handoff.md +++ b/docs/miruro-playback-handoff.md @@ -11,10 +11,12 @@ Tubular launches native episode URLs. Terebi resolves fresh direct sources and i - **Tubular:** catalog/detail browsing, actual returned episode selection, collections, private reporting sessions, cleanup and watched-state decisions. - **Terebi:** episode-URL validation, fresh source resolution, headers/subtitles, titles, optional timeline bands, stable-identity resume and progress reporting. -Native source resolution discovers validated direct Japanese `ssub`/`sub` providers at runtime, preferring soft subtitles per provider. **Playback → Miruro** and the gear menu offer independent **Video Host** / **Caption Provider** choices; optional `[miruro]` config strings `video_provider` and `caption_provider` set startup preferences (empty means Automatic). Missing/unsupported preferences fall back to Sun then returned order for video; captions prefer the chosen video's provider, then Sun and returned order. Dub is not an automatic fallback. Iframe/proxy/HLS-rewriting providers, cookies, impersonation and protection-bypass machinery remain unsupported. +Native resolution retains actual `provider:track` choices (`ssub`, `sub`, `dub`) and discovers captions independently of video eligibility. **Playback → Miruro** and the gear menu offer independent **Video Host** / **Caption Provider** choices. Optional `[miruro]` config strings `video_provider` and `caption_provider` accept a bare provider preference or an exact choice such as `bee:ssub`; empty means Automatic. Automatic video prefers Japanese Sun, then provider order and each provider's variant order. Dub is selectable explicitly, never an automatic language fallback. A dub-only failed automatic open retains the catalog so its choices remain accessible. Host changes resolve fresh sources and preserve acknowledged position and playing/paused intent; caption changes retain their own provider/server headers and do not reopen video. Caption language is retained when available, and explicit Off survives provider changes. Choices made in menus are session-only. Native source menus are disabled during opening, pending seeks and casting. -Provider-selection verification: resolver, configuration, CLI and metadata checks passed; app/CLI builds passed. Local native HLS/VTT playback exercised independent caption headers, in-place caption changes, missing-host fallback, paused/playing host changes, failed-fetch retention and rejection of late results after close. Existing mixed queues, report isolation and subtitle timing checks passed. A live probe found only Sun as a supported native video/caption provider for the sampled episode, with nine VTT tracks; other providers' live playback and visible menu/subtitle verification are not claimed. +Provider-selection verification: resolver/configuration checks and the app/CLI build passed. Local native HLS/VTT smoke covered proxy encoding and already-proxied nested manifests, provider query/segment-origin rules, rejection of foreign loopback keys, independent caption headers, caption changes after prepared playback, paused/playing host switches, failure retention, stale results, and interactive dub-only selection. Live native video/audio, default captions, measured-duration metadata and acknowledged forward/backward subtitle seeks passed for `bee:ssub`, `hop:ssub`, `bun:ssub`, `bun:dub` and `sun:sub` on the sampled episode. These are headless model checks, not visible menu/subtitle or perceived A/V-sync sign-off. + +Final live rerun: the optional metadata endpoint stopped supplying usable enrichment, so the existing complete URL smoke and standalone live metadata check failed their metadata assertion after provider video had decoded. A separate native `PlayerModel` smoke, using the supported absent-enrichment outcome, then passed actual video/audio, default captions and both acknowledged cue seeks for all five choices listed above. The original metadata assertion was not removed or weakened; upstream metadata availability remains a live-service limitation. ## Launch contract @@ -118,10 +120,22 @@ External source-document producers must preserve selected direct stream URLs and `identity.provider` is the catalog namespace `miruro`, not the stream provider `sun`. Titles, slugs and expiring CDN URLs are not identity. Tubular no longer projects source documents. -## Propagation and subtitle behavior +## Native provider transport -- Stream headers reach the master, nested variants, media segments, AES-128 keys and fMP4 init resources. Relative references use native HLS resolution. Existing query tokens are preserved; Terebi does not invent query inheritance or provider-specific URL rewrites. -- Resolved media requests must stay 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 conservative policy prevents FFmpeg from forwarding supplied private headers to another destination. Cross-origin HLS resources are not supported in this contract. +Terebi, not Tubular, prepares provider-specific playback. Bee/Hop object-valued proxies are preserved. Proxy bases and the public URL codec are read from Miruro's deployment environment; playlists, captions and resources use their distinct proxy routes. Already-proxied manifest references are decoded before applying provider rules, avoiding double wrapping. Enabled playlist query overrides apply to nested playlists; a truthy `skip` first tries without `token`, then the original token on request failure. Segment-origin rewriting applies to segments and `EXT-X-MAP`, not AES keys. + +MegaPlay iframe sources used by Bun are extracted without executing JavaScript or opening a browser. Terebi reads the embed's declared source endpoint and deployed public AES/HMAC codec constants, decrypts source/segment references, and handles the provider's token and selected-CDN prefix rules. The fixed public MegaPlay Referer accompanies its media requests. Other embed hosts are not treated as playable native streams. + +Iframe captions may duplicate a same-track parent's exact returned resource set. Only when those complete subtitle records match and the iframe has no request headers does Terebi reuse that parent's caption headers/proxy; Bun remains the selected caption provider. This is a native integration policy, not general inheritance of video configuration. Different resources never receive the parent's request context. + +Prepared playback uses a capability-token loopback relay retained by the current source, including across in-place caption changes and Cast preparation. Native decoding receives no upstream headers. Supplied private headers remain source-origin-bound; foreign destinations are checked against resolved private/local addresses, while the embed's fixed public headers have media-wide scope. Redirects are rejected. Manifests are bounded to 2 MiB, individual resources to 64 MiB, request concurrency to eight, route count to 32,768 and nesting depth to twelve. Resources are buffered individually, never downloaded as a complete episode; oversized resources fail closed. After an embed body is transformed, range offsets and `Content-Range` describe the transformed representation. + +Protocol references: [Miruro watch client](https://barelystarted.miruro.tv/_app/immutable/nodes/11.CUef647B.js), [deployment environment](https://barelystarted.miruro.tv/), [MegaPlay source bootstrap](https://megaplay.buzz/videojs/lib/e1-player.min.js?v=3.19), [MegaPlay resource transforms](https://megaplay.buzz/videojs/lib/newclient.min.js?v=4.23). Deployment changes can invalidate native extraction; errors redact source details. + +## Direct source-document propagation and subtitle behavior + +- The unchanged external version-1 source-document path supplies direct URLs, without native provider requirements. Stream headers reach the master, nested variants, media segments, AES-128 keys and fMP4 init resources. Relative references use native HLS resolution; existing query tokens are preserved without invented inheritance or rewrites. +- Direct source-document media requests stay on the initial stream's origin: scheme, host and effective port. **All media redirects, including same-origin redirects, are rejected before a second request.** The provider relay described above is a separate internal preparation path, not an extension of this external file format. - Sun serves MPEG-TS segments as `.jpg` / `image/jpeg`. Resolved playback probes actual media content instead of enforcing filename-extension matching; it does not change the URLs. HTTP(S)-only, origin and redirect restrictions remain in force. - External VTT classification uses explicit format and content, not filename extension or MIME type. `.jpeg` subtitle URLs work. Labels/languages appear in the captions menu; the declared default is selected, and the user can choose Off or another track. - Cues follow acknowledged player position, including seeking backwards. Zero-duration cues are permanently inactive rather than invalidating the track. Rendering is plain text; WebVTT CSS, positioning and typographic styling are not applied. @@ -200,9 +214,11 @@ Explicit live native URL smoke (contacts Miruro and media/subtitle resources): ```sh .build/miruro-metadata-check --live 'https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1' .build/resolved-playback-check 'https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1' +.build/resolved-playback-check 'https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1' --provider=bun:ssub ``` URL smoke expects a fixture episode with available title, bands and default subtitles. It checks native decode and acknowledged subtitle/seek state, not visual appearance or perceived A/V synchronization. Tubular's catalog probe does not run Terebi or retrieve sources. +The optional `--provider=provider:track` exercises that exact live choice; use `bee:ssub`, `hop:ssub`, `bun:ssub`, `sun:sub`, or an explicitly dubbed choice such as `bun:dub`. Live checks can emit redacted FFmpeg warnings/errors while still passing; they assert actual decoded state and cue timing, not the absence of diagnostics. Metadata implementation verification (2026-10-22): protocol/security/bounds checks, lifecycle replacement/A-B-A stale results/duration changes, stream resolver and CLI checks passed. The live placeholder resolved the descriptive title “The Apothecary Diaries Season 3 — Episode 1: Locusts,” opening 63.313–153.237 seconds and ending 1335.252–1425.384 seconds; native video/audio advanced, measured-duration bands appeared in model state, and default VTT plus forward/backward acknowledged cue seeks passed. FFmpeg emitted redacted diagnostics during this successful run; no visual synchronization or visible-band sign-off is claimed. @@ -214,7 +230,7 @@ Segment-skip verification (2026-10-22): local HLS/VTT playback exercised late me - Confirm title readability and gapped slider segments in normal/compact controls, buffer/playhead visibility and subtitle alignment in the visible app using Tubular's real launch path. Offscreen native rasterization at compact/normal widths confirmed all four interval-boundary gaps remain clear even with full buffer coverage. - Tubular collection launches, retained-report recovery and watched decisions remain its acceptance responsibility; this implementation changes no collection or progress schema. -- Media redirects/cross-origin HLS resources remain unsupported. Native URL launches resolve fresh; externally supplied source documents must be refreshed by their producer. Automatic expired-source refresh is not implemented. -- Other providers' proxies/HLS rewrites, iframe providers, downloads, thumbnails, automatic skipping, embedded captions and additional external-subtitle formats remain out of scope. +- Media redirects remain unsupported; external direct source documents also retain their same-origin restriction. Native URL launches resolve fresh; externally supplied source documents must be refreshed by their producer. Automatic expired-source refresh is not implemented. +- Embed extraction is currently MegaPlay-specific. Downloads, thumbnails, automatic skipping, embedded captions and additional external-subtitle formats remain out of scope. The implementation and this handoff are local; no remote publication or Tubular issue/comment update was performed.