Compare commits

...

2 Commits

16 changed files with 1610 additions and 127 deletions
+32 -1
View File
@@ -128,6 +128,34 @@ struct ConfigurationChecks {
"printed TOML is directly usable and preserves all default behavior and bindings") "printed TOML is directly usable and preserves all default behavior and bindings")
try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges, try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges,
"built-in defaults hide buffer info, disable automatic quality, and enable snapping") "built-in defaults hide buffer info, disable automatic quality, and enable snapping")
try require(defaults.miruroVideoProvider.isEmpty && defaults.miruroCaptionProvider.isEmpty,
"Miruro defaults select automatic video and captions independently")
let miruro = root.appendingPathComponent("miruro.toml")
for (settings, video, captions) in [
("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 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", ":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")
}
for value in ["true", "1", "[\"sun\"]", "{}"] {
try Data("[miruro]\n\(key) = \(value)\n".utf8).write(to: miruro)
try requireLoadError(at: miruro, "non-string Miruro provider")
}
}
for (value, expected) in [("time", CompactTitleProgress.time), ("percentage", .percentage), ("none", .none)] { for (value, expected) in [("time", CompactTitleProgress.time), ("percentage", .percentage), ("none", .none)] {
let progress = root.appendingPathComponent("compact-progress-\(value).toml") let progress = root.appendingPathComponent("compact-progress-\(value).toml")
try Data("[ui]\ncompact_title_progress = \"\(value)\"\n".utf8).write(to: progress) try Data("[ui]\ncompact_title_progress = \"\(value)\"\n".utf8).write(to: progress)
@@ -204,7 +232,7 @@ struct ConfigurationChecks {
try require(try PlayerConfiguration.load(from: nested).keys == expectedKeys && keySession.keys == defaults.keys, try require(try PlayerConfiguration.load(from: nested).keys == expectedKeys && keySession.keys == defaults.keys,
"key session overrides never persist") "key session overrides never persist")
let emptySections = root.appendingPathComponent("empty-sections.toml") let emptySections = root.appendingPathComponent("empty-sections.toml")
try Data("[ui]\n[keys]\n[playback]\n".utf8).write(to: emptySections) try Data("[ui]\n[keys]\n[playback]\n[miruro]\n".utf8).write(to: emptySections)
try require(try PlayerConfiguration.load(from: emptySections) == defaults, "empty nested sections use defaults") try require(try PlayerConfiguration.load(from: emptySections) == defaults, "empty nested sections use defaults")
let reordered = root.appendingPathComponent("reordered.toml") let reordered = root.appendingPathComponent("reordered.toml")
try Data(""" try Data("""
@@ -226,6 +254,9 @@ struct ConfigurationChecks {
("syntax", "[playback]\nautomatic_quality = [\n", ["automatic_quality"]), ("syntax", "[playback]\nautomatic_quality = [\n", ["automatic_quality"]),
("duplicate", "[playback]\nautomatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]), ("duplicate", "[playback]\nautomatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
("unknown", "unknown_setting = true\n", ["unknown_setting"]), ("unknown", "unknown_setting = true\n", ["unknown_setting"]),
("miruro-scalar", "miruro = true\n", ["miruro"]),
("miruro-unknown", "[miruro]\nprovider = \"sun\"\n", ["miruro.provider"]),
("miruro-nested", "[miruro.video_provider]\nname = \"sun\"\n", []),
("legacy-buffer", "show_buffer_info = true\n", ["show_buffer_info"]), ("legacy-buffer", "show_buffer_info = true\n", ["show_buffer_info"]),
("legacy-snap", "snap_to_edges = false\n", ["snap_to_edges"]), ("legacy-snap", "snap_to_edges = false\n", ["snap_to_edges"]),
("legacy-quality", "automatic_quality = true\n", ["automatic_quality"]), ("legacy-quality", "automatic_quality = true\n", ["automatic_quality"]),
+62 -7
View File
@@ -1,4 +1,5 @@
"""Local-only resolved HLS/VTT + native model smoke; no live requests.""" """Local-only resolved HLS/VTT + native model smoke; no live requests."""
import base64
import http.server import http.server
import json import json
from pathlib import Path from pathlib import Path
@@ -6,7 +7,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import threading import threading
from urllib.parse import urlsplit from urllib.parse import parse_qs, urlsplit
binary = str(Path(sys.argv[1]).resolve()) binary = str(Path(sys.argv[1]).resolve())
fixtures = Path(sys.argv[2]).resolve() fixtures = Path(sys.argv[2]).resolve()
@@ -17,24 +18,77 @@ class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_): def log_message(self, *_):
pass 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): def do_GET(self):
if self.transport():
return
path = urlsplit(self.path).path path = urlsplit(self.path).path
seen.append(path) seen.append(path)
expected = "subtitle-private" if path == "/english.jpeg" else "stream-private" alternate = path.startswith("/alternate/")
captions = path.startswith("/captions/")
if alternate or captions:
path = "/" + path.split("/", 2)[2]
expected = "alternate-private" if alternate else "subtitle-private" if captions or path == "/english.jpeg" else "stream-private"
if self.headers.get("X-Resource") != expected: if self.headers.get("X-Resource") != expected:
errors.append("wrong resource headers: " + path) errors.append("wrong resource headers: " + path)
self.send_error(403) self.send_error(403)
return return
if path == "/master.m3u8": if path == "/master.m3u8":
if urlsplit(self.path).query != "token=fixture": query = parse_qs(urlsplit(self.path).query)
errors.append("master token lost") 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' data = b'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\nvariant.m3u8?token=variant\n'
elif path == "/variant.m3u8": elif path == "/variant.m3u8":
if urlsplit(self.path).query != "token=variant": query = parse_qs(urlsplit(self.path).query)
errors.append("variant token lost") 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() 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": elif path == "/english.jpeg":
data = b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n' 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')
elif path.startswith("/resolved-vod-") and path.endswith(".ts"): elif path.startswith("/resolved-vod-") and path.endswith(".ts"):
data = (fixtures / path[1:]).read_bytes() data = (fixtures / path[1:]).read_bytes()
else: else:
@@ -71,6 +125,7 @@ try:
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets" assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
assert not errors, errors assert not errors, errors
assert {"/master.m3u8", "/variant.m3u8", "/english.jpeg"}.issubset(seen), "master, nested playlist and external VTT fetched" assert {"/master.m3u8", "/variant.m3u8", "/english.jpeg"}.issubset(seen), "master, nested playlist and external VTT fetched"
assert {"/alternate/master.m3u8", "/alternate/english.jpeg"}.issubset(seen), "alternate video host and independent caption provider fetched"
assert any(path.endswith(".ts") for path in seen), "media segment fetched" assert any(path.endswith(".ts") for path in seen), "media segment fetched"
print("PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress") print("PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
finally: finally:
+181 -12
View File
@@ -32,12 +32,13 @@ struct ResolvedPlaybackCheck {
NSApp.setActivationPolicy(.prohibited) NSApp.setActivationPolicy(.prohibited)
let input = CommandLine.arguments[1] let input = CommandLine.arguments[1]
let file = URL(fileURLWithPath: input) let file = URL(fileURLWithPath: input)
let preference = CommandLine.arguments.first(where: { $0.hasPrefix("--provider=") }).map { String($0.dropFirst(11)) } ?? ""
let source: ResolvedSource let source: ResolvedSource
if input.hasPrefix("https://") { if input.hasPrefix("https://") {
guard case .miruro(let mediaID, let episode) = try StreamURL.parse(input) else { guard case .miruro(let mediaID, let episode) = try StreamURL.parse(input) else {
fatalError("FAIL: URL smoke requires a Miruro episode") fatalError("FAIL: URL smoke requires a Miruro episode")
} }
source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode) source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode).select(videoProvider: preference).source
} else { } else {
source = try ResolvedSource.load(from: file) source = try ResolvedSource.load(from: file)
} }
@@ -49,7 +50,8 @@ struct ResolvedPlaybackCheck {
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) } defer { defaults.removePersistentDomain(forName: suite) }
let clock = Clock() 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 miruroMetadataResolver: { id, episode in
if input.hasPrefix("https://") { if input.hasPrefix("https://") {
return try await MiruroMetadataResolver.resolve(mediaID: id, episode: episode) return try await MiruroMetadataResolver.resolve(mediaID: id, episode: episode)
@@ -85,7 +87,8 @@ struct ResolvedPlaybackCheck {
fatalError("FAIL: smoke requires a default subtitle") fatalError("FAIL: smoke requires a default subtitle")
} }
let track = source.subtitles[index] 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 } 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") 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) let target = playableCues[1].start + min(0.1, (playableCues[1].end - playableCues[1].start) / 2)
@@ -105,7 +108,10 @@ struct ResolvedPlaybackCheck {
print("PASS: default VTT selected, forward/backward seek and acknowledged cue timing") 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 segmentSkips(model, clock: clock, file: file) }
if !input.hasPrefix("https://") { if !input.hasPrefix("https://") {
try await transportProtocol(source: source)
model.shutdown() model.shutdown()
try await providerLifecycle(source: source)
try await dubOnlySelection(source: source)
try await queueMetadataLifecycle(source: source) try await queueMetadataLifecycle(source: source)
try await streamQueueLifecycle(source: source) try await streamQueueLifecycle(source: source)
try await enqueueLifecycle(source: source) try await enqueueLifecycle(source: source)
@@ -113,6 +119,170 @@ struct ResolvedPlaybackCheck {
} }
} }
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)
return (String(parts[0]), String(parts[1]).trimmingCharacters(in: .whitespaces))
})
}
func provider(_ name: String, url: String, header: [String: String], subtitles: [[String: Any]]) -> [String: Any] {
["provider": name, "servers": [["headers": header, "streams": [["url": url, "format": "hls"]]]],
"subtitles": subtitles]
}
let subtitles: [[String: Any]] = source.subtitles.map {
["file": $0.url, "format": $0.format, "language": $0.language, "label": $0.label, "default": $0.isDefault]
}
// Source-file fixtures allow independent headers; Miruro returns each set on its own provider.
let video = provider("sun", url: source.url, header: headers(source.headers), subtitles: [])
let captions = provider("captions", url: source.url.replacingOccurrences(of: "/master.m3u8", with: "/captions/master.m3u8"),
header: headers(source.subtitles[0].headers), subtitles: subtitles)
var providers = [video, captions]
var config: [String: Any] = ["sun": ["player": "native", "proxy": false],
"captions": ["player": "native", "proxy": false]]
if alternate {
providers.append(provider("alternate", url: source.url.replacingOccurrences(of: "/master.m3u8", with: "/alternate/master.m3u8"),
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]]))
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": track, "providers": providers]]
]), mediaID: "fixture-ID", episode: 1)
}
static func providerLifecycle(source: ResolvedSource) async throws {
let providers = try providerSources(source, alternate: true)
let suite = "terebi.provider-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var requests = 0
var pending: CheckedContinuation<MiruroSources, Never>?
var completed = 0
let model = PlayerModel(defaults: defaults,
configuration: .init(miruroVideoProvider: "unavailable", miruroCaptionProvider: "captions"),
miruroResolver: { _, _ in
requests += 1
defer { completed += 1 }
if requests == 4 { throw CocoaError(.fileReadCorruptFile) }
if requests == 5 { return await withCheckedContinuation { pending = $0 } }
return providers
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() }
let url = "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1"
model.openStream(url)
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: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: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: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: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")
require(model.setPlayback(playing: true), "resume before automatic host choice")
try await until("playback resumes before host switch", model: model) { model.state.status == PLAYER_PLAYING }
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:ssub"
}
require(model.selectedSubtitle == nil, "host changes preserve explicit subtitle Off")
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:ssub" &&
model.state.status == PLAYER_PLAYING && model.canChangeMiruroProvider,
"failed host fetch retains previous source and resumes playback")
model.setMiruroVideoProvider("")
try await until("replacement provider request is held", model: model) { pending != nil }
model.closeFile()
pending!.resume(returning: providers)
try await until("closed player discards late provider results", model: model) {
completed == requests && model.state.status == PLAYER_EMPTY
}
require(model.miruroVideoProviders.isEmpty && model.selectedMiruroCaptionProvider == nil,
"closing clears episode provider state")
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 { static func queueMetadataLifecycle(source: ResolvedSource) async throws {
let suite = "terebi.queue-metadata-check." + UUID().uuidString let suite = "terebi.queue-metadata-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
@@ -263,7 +433,7 @@ struct ResolvedPlaybackCheck {
return video return video
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in }, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in
episodeRequests += 1 episodeRequests += 1
return source return try providerSources(source)
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) }) }, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() } defer { model.shutdown() }
require(model.progressInitializationError == nil, "queue report registration") require(model.progressInitializationError == nil, "queue report registration")
@@ -281,7 +451,7 @@ struct ResolvedPlaybackCheck {
stale = nil stale = nil
// Yield to the canceled resolver; it must not open or redirect its report. // Yield to the canceled resolver; it must not open or redirect its report.
for _ in 0..<10 { await Task.yield() } for _ in 0..<10 { await Task.yield() }
require(model.queue.currentID == secondID && model.fileName == source.title && require(model.queue.currentID == secondID && !model.miruroVideoProviders.isEmpty &&
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report") !FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
model.advanceQueue(-1) model.advanceQueue(-1)
try await until("previous resolves YouTube afresh", model: model) { try await until("previous resolves YouTube afresh", model: model) {
@@ -297,7 +467,7 @@ struct ResolvedPlaybackCheck {
require(model.setPlayback(playing: true), "play first stream to natural end") require(model.setPlayback(playing: true), "play first stream to natural end")
try await until("natural end advances and freshly resolves second stream", model: model) { try await until("natural end advances and freshly resolves second stream", model: model) {
model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING &&
model.fileName == source.title && episodeRequests == 2 !model.miruroVideoProviders.isEmpty && episodeRequests == 2
} }
require(model.setPlayback(playing: false), "pause second stream for restoration") require(model.setPlayback(playing: false), "pause second stream for restoration")
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED } try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
@@ -311,7 +481,7 @@ struct ResolvedPlaybackCheck {
} }
model.advanceQueue(1) model.advanceQueue(1)
try await until("second revisit resumes acknowledged bookmark", model: model) { try await until("second revisit resumes acknowledged bookmark", model: model) {
!model.isBusy && model.fileName == source.title && model.state.status == PLAYER_PLAYING && !model.isBusy && model.queue.currentID == secondID && model.state.status == PLAYER_PLAYING &&
model.state.position >= 0.7 model.state.position >= 0.7
} }
require(youtubeRequests == 3 && episodeRequests == 3, "every revisit resolves fresh transport metadata") require(youtubeRequests == 3 && episodeRequests == 3, "every revisit resolves fresh transport metadata")
@@ -361,7 +531,7 @@ struct ResolvedPlaybackCheck {
return YouTubeVideo(title: url.absoluteString, return YouTubeVideo(title: url.absoluteString,
video: .init(url: source.url, headers: source.headers), audio: nil, video: .init(url: source.url, headers: source.headers), audio: nil,
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: []) duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in episodeRequests += 1; return source }, }, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in episodeRequests += 1; return try providerSources(source) },
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) }) miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() } defer { model.shutdown() }
func deliver(_ items: [(URL, URL)]) throws { func deliver(_ items: [(URL, URL)]) throws {
@@ -415,7 +585,7 @@ struct ResolvedPlaybackCheck {
} }
require(model.setPlayback(playing: true), "resume original through natural end") require(model.setPlayback(playing: true), "resume original through natural end")
try await until("natural end advances to enqueued Miruro item", model: model) { try await until("natural end advances to enqueued Miruro item", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == source.title && !model.isBusy && model.state.status == PLAYER_PLAYING && !model.miruroVideoProviders.isEmpty &&
model.queue.currentURL == second model.queue.currentURL == second
} }
model.advanceQueue(1) model.advanceQueue(1)
@@ -424,7 +594,7 @@ struct ResolvedPlaybackCheck {
} }
model.advanceQueue(-1) model.advanceQueue(-1)
try await until("enqueued Miruro report revisits", model: model) { try await until("enqueued Miruro report revisits", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == source.title !model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second
} }
model.advanceQueue(-1) model.advanceQueue(-1)
try await until("original single video remains navigable", model: model) { try await until("original single video remains navigable", model: model) {
@@ -510,7 +680,7 @@ struct ResolvedPlaybackCheck {
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: []) duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } } if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
return video return video
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in source }, }, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in try providerSources(source) },
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) }) miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() } defer { model.shutdown() }
try await model.addStreamToQueue(first.absoluteString) try await model.addStreamToQueue(first.absoluteString)
@@ -527,7 +697,6 @@ struct ResolvedPlaybackCheck {
try await model.addStreamToQueue(episode.absoluteString) try await model.addStreamToQueue(episode.absoluteString)
require(model.queue.items.map(\.url) == [first, second, episode] && require(model.queue.items.map(\.url) == [first, second, episode] &&
model.queue.items[1].displayName == "Sidebar second" && model.queue.items[1].displayName == "Sidebar second" &&
model.queue.items[2].displayName == source.title &&
model.queue.currentID == selected && model.state.revision == revision && model.queue.currentID == selected && model.state.revision == revision &&
model.state.position == position && model.state.status == PLAYER_PAUSED, model.state.position == position && model.state.status == PLAYER_PAUSED,
"fetched YouTube and Miruro additions preserve paused playback, order and titles") "fetched YouTube and Miruro additions preserve paused playback, order and titles")
+167 -15
View File
@@ -40,7 +40,17 @@ struct StreamResolverChecks {
throw Failure(message: "Accepted \(message)") throw Failure(message: "Accepted \(message)")
} }
static func project(_ json: String) throws -> ResolvedSource { static func project(_ json: String) throws -> ResolvedSource {
try MiruroResolver.decode(Data(json.utf8), mediaID: mediaID, episode: 1) try MiruroResolver.decode(Data(json.utf8), mediaID: mediaID, episode: 1).select().source
}
static func catalog(_ document: [String: Any]) throws -> MiruroSources {
try MiruroResolver.decode(JSONSerialization.data(withJSONObject: document), mediaID: mediaID, episode: 1)
}
static func provider(_ name: String, captions: Bool = true) -> [String: Any] {
["provider": name,
"servers": [["headers": ["Referer": "https://\(name).example/"],
"streams": [["url": "https://\(name).example/master.m3u8", "format": "hls"]]]],
"subtitles": captions ? [["file": "https://\(name).example/english.vtt", "format": "vtt",
"language": "en", "label": "English", "default": true]] : []]
} }
static func checks() async throws { static func checks() async throws {
@@ -84,22 +94,162 @@ struct StreamResolverChecks {
try require(source.subtitles.count == 2 && source.subtitles[0].isDefault, "only external VTT retained with default") 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[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") try require(source.subtitles.allSatisfy { $0.headers == source.headers }, "verified Sun subtitle headers retained")
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: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: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: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: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")
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": [42, ["url": "file:///secret", "format": "hls"],
["url": "https://river.example/master.m3u8", "format": "application/vnd.apple.mpegurl"]] as [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("bad name"), resilient] as [Any]]]
let resilientSources = try catalog(document)
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")
}
let renewed = try project(fixture.replacingOccurrences(of: "token=secret", with: "token=renewed")) 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 [ for (old, replacement) in [
("\"episodeNumber\": 1", "\"episodeNumber\": 49"), ("\"episodeNumber\": 1", "\"episodeNumber\": 49"),
("\"proxy\": false", "\"proxy\": true"), ("\"proxy\": false", "\"proxy\": true"),
("\"enabled\": false", "\"enabled\": true"),
("\"player\": \"native\"", "\"player\": \"iframe\""),
("\"track\": \"ssub\"", "\"track\": \"dub\""),
("\"provider\": \"sun\"", "\"provider\": \"bee\""),
("\"format\": \"hls\"", "\"format\": \"mp4\""), ("\"format\": \"hls\"", "\"format\": \"mp4\""),
("\"embed\": null", "\"embed\": {\"url\": \"https://embed.example/secret\"}"),
("https://vault.example/master.m3u8?token=secret", "file:///secret"), ("https://vault.example/master.m3u8?token=secret", "file:///secret"),
("https://vault.example/english.jpeg", "https://user:secret@vault.example/english.jpeg"), ("\"embed\": null", "\"embed\": false"),
("https://ultracloud.cc/\"", "https://ultracloud.cc/\\r\\nX-Injected: secret\""), ("https://ultracloud.cc/\"", "https://ultracloud.cc/\\r\\nX-Injected: secret\""),
("\"Origin\":", "\"origin\": \"secret\", \"Origin\":"), ("\"Origin\":", "\"origin\": \"secret\", \"Origin\":")
("\"default\": false", "\"default\": true")
] { ] {
try reject("unsafe/unsupported source projection") { try reject("unsafe/unsupported source projection") {
_ = try project(fixture.replacingOccurrences(of: old, with: replacement)) _ = try project(fixture.replacingOccurrences(of: old, with: replacement))
@@ -122,7 +272,7 @@ struct StreamResolverChecks {
_ = try await cancelled.value _ = try await cancelled.value
throw Failure(message: "Cancelled resolution was accepted") throw Failure(message: "Cancelled resolution was accepted")
} catch is CancellationError {} } catch is CancellationError {}
print("PASS: streaming URL security, Sun projection, shared validation, 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 { static func main() async {
@@ -132,12 +282,14 @@ struct StreamResolverChecks {
guard case .miruro(let id, let episode) = try StreamURL.parse(args[1]) else { guard case .miruro(let id, let episode) = try StreamURL.parse(args[1]) else {
throw Failure(message: "--live requires a Miruro episode URL") throw Failure(message: "--live requires a Miruro episode URL")
} }
let source = try await MiruroResolver.resolve(mediaID: id, episode: episode) let sources = try await MiruroResolver.resolve(mediaID: id, episode: episode)
print("PASS: live \(source.identity); \(source.subtitles.count) VTT tracks") let selection = try sources.select()
print("PASS: live video=\(selection.videoProvider); captions=\(selection.captionProvider ?? "none"); \(sources.videoProviders.count) hosts; \(sources.captionProviders.count) caption providers; \(selection.source.subtitles.count) VTT tracks")
} else if args.count == 2, args[0] == "--fixture" { } else if args.count == 2, args[0] == "--fixture" {
let data = try Data(contentsOf: URL(fileURLWithPath: args[1])) let data = try Data(contentsOf: URL(fileURLWithPath: args[1]))
let source = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1) let sources = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1)
print("PASS: fixture \(source.identity); \(source.subtitles.count) VTT tracks") let selection = try sources.select()
print("PASS: fixture video=\(selection.videoProvider); captions=\(selection.captionProvider ?? "none"); \(sources.videoProviders.count) hosts; \(sources.captionProviders.count) caption providers; \(selection.source.subtitles.count) VTT tracks")
} else if args.isEmpty { } else if args.isEmpty {
try await checks() try await checks()
} else { } else {
+1
View File
@@ -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) 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/StreamResolver.swift
SWIFT_SOURCES += Sources/App/MiruroMetadata.swift SWIFT_SOURCES += Sources/App/MiruroMetadata.swift
SWIFT_SOURCES += Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift
CLI_SOURCES += Sources/App/StreamResolver.swift CLI_SOURCES += Sources/App/StreamResolver.swift
SWIFT_SOURCES += Sources/App/StreamQueue.swift SWIFT_SOURCES += Sources/App/StreamQueue.swift
CLI_SOURCES += Sources/App/StreamQueue.swift CLI_SOURCES += Sources/App/StreamQueue.swift
+18
View File
@@ -81,6 +81,24 @@ Copy the settings you want to change into the config file; omitted settings keep
The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[keys]`; give both actions the same chord to keep a single toggle. The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[keys]`; give both actions the same chord to keep a single toggle.
### 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. 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:
```toml
[miruro]
video_provider = ""
caption_provider = ""
```
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.
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 ## Limitations
- SDR output only; HDR is not supported. - SDR output only; HDR is not supported.
+2 -1
View File
@@ -97,7 +97,8 @@ private struct CastMediaCleanup: @unchecked Sendable { let handle: OpaquePointer
var tracks: [CastTextTrack] = [] var tracks: [CastTextTrack] = []
for (index, subtitle) in input.subtitles.enumerated() { for (index, subtitle) in input.subtitles.enumerated() {
try Task.checkCancellation() 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() } guard let text = String(data: data, encoding: .utf8) else { throw CastDeliveryError() }
_ = try WebVTTDocument.parse(text) _ = try WebVTTDocument.parse(text)
let trackURL = try owner.subtitle(id: index + 1, data: data) let trackURL = try owner.subtitle(id: index + 1, data: data)
+205
View File
@@ -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(#"<script\b[^>]*\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<SHA256>.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)
}
}
+455
View File
@@ -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[..<colon]))
}
if let range { request.setValue(range, forHTTPHeaderField: "Range") }
let (bytes, response) = try await session.bytes(for: request)
guard let response = response as? HTTPURLResponse,
[200, 206].contains(response.statusCode), response.expectedContentLength <= limit else {
throw MiruroPlaybackError()
}
var data = Data()
if response.expectedContentLength > 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..<match.upperBound]).data(using: .utf8),
let env = try JSONSerialization.jsonObject(with: data) as? [String: String],
let text = env["PUBLIC_PROXY_A"], let base = URL(string: text),
base.scheme == "https", base.user == nil, base.password == nil, base.host != nil else {
throw MiruroPlaybackError()
}
let hex = env["PUBLIC_PROXY_OBF_KEY"] ?? ""
guard hex.count.isMultiple(of: 2), hex.count <= 512 else { throw MiruroPlaybackError() }
var key: [UInt8] = []
var index = hex.startIndex
while index < hex.endIndex {
let end = hex.index(index, offsetBy: 2)
guard let byte = UInt8(hex[index..<end], radix: 16) else { throw MiruroPlaybackError() }
key.append(byte)
index = end
}
return Self(base: base, key: key, alternate: env["PUBLIC_PROXY_B"].flatMap(URL.init(string:)))
}
// The deployed proxy also rewrites nested references. Decode those before
// applying provider rules; wrapping the proxy itself makes nested HLS fail.
func originalURL(_ url: URL) throws -> 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[..<colon].caseInsensitiveCompare(name) == .orderedSame else { continue }
return String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces)
}
return nil
}
private func start() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) 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<Void, Error>) 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<addrinfo>?
guard getaddrinfo(host, nil, &hints, &addresses) == 0, let first = addresses else { throw MiruroPlaybackError() }
defer { freeaddrinfo(first) }
var current: UnsafeMutablePointer<addrinfo>? = 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)
}
}
+36
View File
@@ -171,6 +171,41 @@ struct PlayerQualityMenu: View {
} }
} }
@MainActor
struct PlayerMiruroMenu: View {
@ObservedObject var model: PlayerModel
var body: some View {
if !model.miruroVideoProviders.isEmpty {
Menu("Miruro") {
Menu("Video Host") {
Button(model.miruroVideoProvider.isEmpty ? "✓ Automatic" : "Automatic") {
model.setMiruroVideoProvider("")
}
ForEach(model.miruroVideoProviders, id: \.self) { provider in
Button(model.miruroVideoProvider == provider ? "✓ \(provider)" : provider) {
model.setMiruroVideoProvider(provider)
}
}
Text("Current: \(model.selectedMiruroVideoProvider ?? "None")")
}
Menu("Caption Provider") {
Button(model.miruroCaptionProvider.isEmpty ? "✓ Automatic" : "Automatic") {
model.setMiruroCaptionProvider("")
}
ForEach(model.miruroCaptionProviders, id: \.self) { provider in
Button(model.miruroCaptionProvider == provider ? "✓ \(provider)" : provider) {
model.setMiruroCaptionProvider(provider)
}
}
Text("Current: \(model.selectedMiruroCaptionProvider ?? "None")")
}
}
.disabled(!model.canChangeMiruroProvider)
}
}
}
@MainActor @MainActor
struct PlayerCommands: Commands { struct PlayerCommands: Commands {
@FocusedObject private var focusedModel: PlayerModel? @FocusedObject private var focusedModel: PlayerModel?
@@ -275,6 +310,7 @@ struct PlayerCommands: Commands {
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown") Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
} }
} }
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") { Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
model.performMenuAction(bufferInfo) model.performMenuAction(bufferInfo)
} }
+25 -12
View File
@@ -8,18 +8,21 @@ struct ResolvedSubtitle: Decodable, Sendable {
let label: String let label: String
let isDefault: Bool let isDefault: Bool
private let headerFields: [String: String] private let headerFields: [String: String]
var proxy: MiruroProxy? = nil
var headers: String { var headers: String {
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined() headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
} }
fileprivate var validatedHeaders: [String: String] { headerFields } 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.url = url
self.format = format self.format = format
self.language = language self.language = language
self.label = label self.label = label
self.isDefault = isDefault self.isDefault = isDefault
self.headerFields = headers self.headerFields = headers
self.proxy = proxy
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case url, format, language, label case url, format, language, label
@@ -34,6 +37,13 @@ struct ResolvedSource: Sendable {
let identity: String let identity: String
let title: String let title: String
let subtitles: [ResolvedSubtitle] 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 Document: Decodable {
struct Identity: Decodable { struct Identity: Decodable {
@@ -87,14 +97,7 @@ struct ResolvedSource: Sendable {
validIdentity(mediaID: id.media_id, episode: id.episode), validIdentity(mediaID: id.media_id, episode: id.episode),
validURL(document.url), validHeaders(document.headers), validURL(document.url), validHeaders(document.headers),
validText(document.title ?? "", limit: 4096), validText(document.title ?? "", limit: 4096),
document.subtitles.count <= 32, validSubtitles(document.subtitles) else { throw InvalidSource() }
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
for subtitle in document.subtitles {
guard validURL(subtitle.url), subtitle.format == "vtt",
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() }
}
let headers = document.headers.sorted { $0.key < $1.key } let headers = document.headers.sorted { $0.key < $1.key }
.map { "\($0.key): \($0.value)\r\n" }.joined() .map { "\($0.key): \($0.value)\r\n" }.joined()
return ResolvedSource(url: document.url, headers: headers, return ResolvedSource(url: document.url, headers: headers,
@@ -102,16 +105,26 @@ struct ResolvedSource: Sendable {
subtitles: document.subtitles) 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 { private static func isIDByte(_ byte: UInt8) -> Bool {
(65...90).contains(byte) || (97...122).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) ||
(48...57).contains(byte) || byte == 45 || byte == 95 (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 } 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), guard !text.isEmpty, validText(text, limit: 32768),
!text.contains(where: { $0.isWhitespace }), !text.contains(where: { $0.isWhitespace }),
let components = URLComponents(string: text), let components = URLComponents(string: text),
@@ -122,7 +135,7 @@ struct ResolvedSource: Sendable {
return components.port == nil || (1...65535).contains(components.port!) 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 } guard headers.count <= 64 else { return false }
var names = Set<String>() var names = Set<String>()
var size = 0 var size = 0
+242 -46
View File
@@ -41,10 +41,85 @@ 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
let captionProvider: String?
}
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: [Captions]
let videoProviders: [String]
let captionProviders: [String]
fileprivate init(videos: [Candidate], captions: [Captions]) {
self.videos = videos
self.captions = captions
videoProviders = videos.map(\.id)
captionProviders = captions.map(\.id)
}
func select(videoProvider: String? = nil, captionProvider: String? = nil) throws -> MiruroSelection {
func requested(_ preference: String?, provider: String, track: String) -> Bool {
preference == "\(provider):\(track)" ||
(preference == provider && track != "dub")
}
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)
}
}
enum MiruroResolver { enum MiruroResolver {
static let bodyLimit = 2 * 1024 * 1024 static let bodyLimit = 2 * 1024 * 1024
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource { static func resolve(mediaID: String, episode: Int) async throws -> MiruroSources {
try Task.checkCancellation() try Task.checkCancellation()
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
throw StreamResolutionError(message: "Invalid Miruro episode identity.") throw StreamResolutionError(message: "Invalid Miruro episode identity.")
@@ -100,89 +175,210 @@ enum MiruroResolver {
} }
} }
// Match Tubular's direct ssub/Sun projection, without provider or server retry. static func decode(_ data: Data, mediaID: String, episode: Int) throws -> MiruroSources {
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> ResolvedSource {
do { do {
guard data.count <= bodyLimit, guard data.count <= bodyLimit,
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() } ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
let response = try JSONDecoder().decode(Sources.self, from: data) let response = try JSONDecoder().decode(Sources.self, from: data)
guard response.episodeNumber == episode else { throw InvalidProjection() } guard response.episodeNumber == episode else { throw InvalidProjection() }
let config = response.config.sun var videos: [MiruroSources.Candidate] = []
guard config.player == "native", !config.proxy, var captions: [MiruroSources.Captions] = []
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else { var videoIDs = Set<String>()
throw StreamResolutionError(message: "Miruro Sun requires unsupported proxy or HLS rewriting.") var captionIDs = Set<String>()
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
} }
guard let provider = response.tracks.first(where: { $0.track == "ssub" })? let subtitles = returned.lazy
.providers.first(where: { $0.provider == "sun" }), .filter { $0.format == "vtt" }.map {
let server = provider.servers.first(where: { !$0.streams.isEmpty }), ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language,
let stream = server.streams.first else { label: $0.label, isDefault: $0.default, headers: headers, proxy: proxy)
throw StreamResolutionError(message: "Direct Miruro Sun soft-subtitle source is unavailable.")
} }
guard ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format), let values = Array(subtitles)
!stream.hasEmbed else { throw InvalidProjection() } 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 ?? [:] let headers = server.headers ?? [:]
var subtitles: [ResolvedSubtitle] = [] guard ResolvedSource.validHeaders(headers) else { continue }
for subtitle in provider.subtitles where subtitle.format == "vtt" { guard !videoIDs.contains(id), let config,
guard subtitles.count < 32 else { throw InvalidProjection() } var requirements = try? config.requirements() else { continue }
subtitles.append(ResolvedSubtitle(url: subtitle.file, format: subtitle.format, language: subtitle.language, var locations: [(String, Bool)] = []
label: subtitle.label, isDefault: subtitle.default, headers: headers)) 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)
} }
return try ResolvedSource.validated(.init(version: 1, url: stream.url, headers: headers, } else if config.player == "iframe" {
identity: .init(provider: "miruro", media_id: mediaID, episode: episode), if let embed = server.embed { locations.append((embed.url, true)) }
title: nil, subtitles: subtitles)) 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
}
}
}
}
// Provider order is advisory and deduplicated; each provider retains all usable tracks.
var seen = Set<String>()
let providerOrder = ((response.providerOrder ?? []) + videos.map(\.provider) + captions.map(\.provider))
.filter { seen.insert($0).inserted }
func variants(_ name: String) -> [String] {
var seen = Set<String>()
return ((response.config[name]?.value?.variantOrder ?? []) + ["ssub", "sub", "dub"])
.filter { seen.insert($0).inserted }
}
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 { } catch {
if let error = error as? StreamResolutionError { throw error } if let error = error as? StreamResolutionError { throw error }
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.") throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
} }
} }
private static func validProviderName(_ name: String) -> Bool {
!name.isEmpty && name.utf8.count <= 128 && name.utf8.allSatisfy {
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95
}
}
private struct InvalidProjection: Error {} private struct InvalidProjection: Error {}
// A malformed entry must not discard valid neighboring providers, servers or streams.
private struct Entry<Value: Decodable>: Decodable {
let value: Value?
init(from decoder: Decoder) throws { value = try? Value(from: decoder) }
}
private struct Sources: Decodable { private struct Sources: Decodable {
let episodeNumber: Int let episodeNumber: Int
let tracks: [Track] let tracks: [Entry<Track>]
let config: Config let config: [String: Entry<ProviderConfig>]
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<Track>].self, forKey: .tracks)
config = try values.decodeIfPresent([String: Entry<ProviderConfig>].self, forKey: .config) ?? [:]
providerOrder = try values.decodeIfPresent([String].self, forKey: .providerOrder)
}
} }
private struct Config: Decodable { let sun: ProviderConfig }
private struct ProviderConfig: Decodable { private struct ProviderConfig: Decodable {
let player: String let player: String
let proxy: Bool let proxy: MiruroProxy?
let hls: HLS? 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 { private struct HLS: Decodable {
struct Rule: Decodable { let enabled: Bool? } struct Segments: Decodable { let enabled: Bool?; let origin: String? }
let segments: Rule? struct Query: Decodable { let enabled: Bool?; let params: [String: String]? }
let query: Rule? let segments: Segments?
let query: Query?
} }
private struct Track: Decodable { private struct Track: Decodable {
let track: String let track: String
let providers: [Provider] let providers: [Entry<Provider>]
} }
private struct Provider: Decodable { private struct Provider: Decodable {
let provider: String let provider: String
let servers: [Server] let servers: [Entry<Server>]
let subtitles: [Subtitle] let subtitles: [Entry<Subtitle>]
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<Server>].self, forKey: .servers) ?? []
subtitles = try values.decodeIfPresent([Entry<Subtitle>].self, forKey: .subtitles) ?? []
} }
}
private struct Embed: Decodable { let url: String }
private struct Server: Decodable { private struct Server: Decodable {
let headers: [String: String]? let headers: [String: String]?
let streams: [Stream] let streams: [Entry<Stream>]
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.decodeIfPresent([Entry<Stream>].self, forKey: .streams) ?? []
embed = try values.decodeIfPresent(Embed.self, forKey: .embed)
}
} }
private struct Stream: Decodable { private struct Stream: Decodable {
let url: String let url: String
let format: String let format: String
let hasEmbed: Bool let embed: Embed?
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
} }
} private struct Subtitle: Decodable, Equatable {
}
private struct Subtitle: Decodable {
let file: String let file: String
let format: String let format: String
let language: String let language: String
+116 -20
View File
@@ -121,6 +121,23 @@ final class PlayerModel: ObservableObject {
@Published private(set) var youtubeQuality: Int? @Published private(set) var youtubeQuality: Int?
@Published private(set) var automaticYouTubeQuality = false @Published private(set) var automaticYouTubeQuality = false
@Published private(set) var showsBufferInfo = false @Published private(set) var showsBufferInfo = false
@Published private(set) var miruroVideoProvider: String
@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 &&
(pendingMiruro != nil ? canOpen && !resolvingStream : canTogglePlayback) &&
!isSeeking && !transition.seekPending && !state.seek_pending
}
@Published private(set) var subtitleTracks: [ResolvedSubtitle] = [] @Published private(set) var subtitleTracks: [ResolvedSubtitle] = []
@Published private(set) var selectedSubtitle: Int? @Published private(set) var selectedSubtitle: Int?
@Published private(set) var subtitleText = "" @Published private(set) var subtitleText = ""
@@ -188,7 +205,7 @@ final class PlayerModel: ObservableObject {
private var reportingErrorBuffer = [CChar]() private var reportingErrorBuffer = [CChar]()
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
private let youtubeTitleResolver: @MainActor (URL) async throws -> String? private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource private let miruroResolver: @MainActor (String, Int) async throws -> MiruroSources
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
private var reportingErrorMessage: String { private var reportingErrorMessage: String {
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
@@ -198,7 +215,7 @@ final class PlayerModel: ObservableObject {
progressFile: String? = nil, streamQueue: StreamQueue? = nil, progressFile: String? = nil, streamQueue: StreamQueue? = nil,
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) }, youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
youtubeTitleResolver: @escaping @MainActor (URL) async throws -> String? = { try await YouTubeResolver.resolveTitle($0) }, youtubeTitleResolver: @escaping @MainActor (URL) async throws -> String? = { try await YouTubeResolver.resolveTitle($0) },
miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) }, miruroResolver: @escaping @MainActor (String, Int) async throws -> MiruroSources = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) }, miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }, qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) { skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
@@ -212,6 +229,8 @@ final class PlayerModel: ObservableObject {
compactTitleProgress = configuration.compactTitleProgress compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality automaticYouTubeQuality = configuration.automaticQuality
showsBufferInfo = configuration.showBufferInfo showsBufferInfo = configuration.showBufferInfo
miruroVideoProvider = configuration.miruroVideoProvider
miruroCaptionProvider = configuration.miruroCaptionProvider
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults) compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
playbackProgress = PlaybackProgress(defaults: defaults) playbackProgress = PlaybackProgress(defaults: defaults)
recentMedia = playbackProgress.recentMedia recentMedia = playbackProgress.recentMedia
@@ -404,11 +423,14 @@ final class PlayerModel: ObservableObject {
title = video.title title = video.title
source = .youtube(video, height: nil) source = .youtube(video, height: nil)
case .miruro(let mediaID, let episode): case .miruro(let mediaID, let episode):
let resolved = try await miruroResolver(mediaID, episode) let providers = try await miruroResolver(mediaID, episode)
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) } guard resolved.identity == "miruro:\(mediaID):\(episode)" else { throw CocoaError(.fileReadCorruptFile) }
url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))! url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
title = resolved.title title = resolved.title
source = .resolved(resolved) source = .resolved(resolved, providers: providers)
} }
try Task.checkCancellation() try Task.checkCancellation()
guard canOpen else { throw CancellationError() } guard canOpen else { throw CancellationError() }
@@ -533,7 +555,8 @@ final class PlayerModel: ObservableObject {
let track = subtitleTracks[index] let track = subtitleTracks[index]
subtitleTask = Task { [weak self] in subtitleTask = Task { [weak self] in
do { 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 } guard !Task.isCancelled, let self else { return }
self.subtitleDocument = document self.subtitleDocument = document
self.updateSubtitle() self.updateSubtitle()
@@ -578,6 +601,48 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: height, change: change) resolveYouTube(url, height: height, change: change)
} }
func setMiruroVideoProvider(_ provider: String) {
guard canChangeMiruroProvider, let handle,
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 }
miruroVideoProvider = provider
let change = transition.qualityChange(player_get_state(handle), playing: effectivePlaying)
resolveMiruro(mediaID: String(identity[1]), episode: episode, change: change)
}
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(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) }
selectSubtitle(index)
revealControls()
} catch {
commandError = error.localizedDescription
}
}
private func resolveMiruro(mediaID: String, episode: Int, change: MediaChange = .open) { private func resolveMiruro(mediaID: String, episode: Int, change: MediaChange = .open) {
guard let handle else { return } guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) } if castDevice != nil { stopCasting(resume: false) }
@@ -594,22 +659,30 @@ final class PlayerModel: ObservableObject {
commandError = nil commandError = nil
revealControls() revealControls()
let resolver = miruroResolver let resolver = miruroResolver
let videoProvider = miruroVideoProvider
let captionProvider = miruroCaptionProvider
let task = Task { [weak self] in let task = Task { [weak self] in
var catalog: MiruroSources?
do { do {
let source = try await resolver(mediaID, episode) let providers = try await resolver(mediaID, episode)
catalog = providers
let selection = try providers.select(videoProvider: videoProvider, captionProvider: captionProvider)
let source = try await MiruroTransport.prepare(selection.source)
guard source.identity == "miruro:\(mediaID):\(episode)" else { guard source.identity == "miruro:\(mediaID):\(episode)" else {
throw CocoaError(.fileReadCorruptFile) throw CocoaError(.fileReadCorruptFile)
} }
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
self.objectWillChange.send() self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return } guard let change = self.transition.finishResolution(request) else { return }
self.openMedia(.resolved(source), change: change) self.openMedia(.resolved(source, providers: providers), change: change)
} catch { } catch {
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
self.objectWillChange.send() self.objectWillChange.send()
guard self.transition.finishResolution(request) != nil else { return } guard self.transition.finishResolution(request) != nil else { return }
self.commandError = error.localizedDescription if let catalog, self.currentMedia?.url.absoluteString != "miruro:\(mediaID):\(episode)" {
self.revealControls() self.pendingMiruro = (mediaID, episode, catalog)
}
self.failStreamChange(change, error: error)
} }
} }
transition.attachResolution(task, request: request) transition.attachResolution(task, request: request)
@@ -652,19 +725,23 @@ final class PlayerModel: ObservableObject {
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
self.objectWillChange.send() self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return } guard let change = self.transition.finishResolution(request) else { return }
self.failYouTubeChange(change, error: error) self.failStreamChange(change, error: error)
} }
} }
transition.attachResolution(task, request: request) transition.attachResolution(task, request: request)
} }
private func cancelStreamResolution() { private func cancelStreamResolution() {
if pendingMiruro != nil {
objectWillChange.send()
pendingMiruro = nil
}
guard transition.isResolving else { return } guard transition.isResolving else { return }
objectWillChange.send() objectWillChange.send()
transition.cancelResolution() transition.cancelResolution()
} }
private func failYouTubeChange(_ change: MediaChange, error: Error? = nil) { private func failStreamChange(_ change: MediaChange, error: Error? = nil) {
if change.automatic { if change.automatic {
automaticQuality.extractionFailed(at: qualityNow) automaticQuality.extractionFailed(at: qualityNow)
return return
@@ -791,7 +868,7 @@ final class PlayerModel: ObservableObject {
} }
} }
} }
case .resolved(let source): case .resolved(let source, _):
accepted = source.identity.withCString { identity in accepted = source.identity.withCString { identity in
source.url.withCString { url in source.url.withCString { url in
source.headers.withCString { headers in source.headers.withCString { headers in
@@ -808,9 +885,9 @@ final class PlayerModel: ObservableObject {
case .file: case .file:
commandError = "Could not queue the file to open. Please try again." commandError = "Could not queue the file to open. Please try again."
case .resolved: case .resolved:
commandError = "Could not queue the resolved stream to open." failStreamChange(change, error: CocoaError(.fileReadUnknown))
case .youtube: case .youtube:
failYouTubeChange(change) failStreamChange(change)
} }
return return
} }
@@ -819,6 +896,16 @@ final class PlayerModel: ObservableObject {
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
queueCompletionHandled = false queueCompletionHandled = false
currentMedia = source currentMedia = source
pendingMiruro = nil
if case .resolved(_, let providers) = source,
let selection = try? providers?.select(videoProvider: miruroVideoProvider,
captionProvider: miruroCaptionProvider) {
selectedMiruroVideoProvider = selection.videoProvider
selectedMiruroCaptionProvider = selection.captionProvider
} else {
selectedMiruroVideoProvider = nil
selectedMiruroCaptionProvider = nil
}
shouldRecordRecentMedia = !change.isQuality shouldRecordRecentMedia = !change.isQuality
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() } if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
switch source { switch source {
@@ -834,7 +921,7 @@ final class PlayerModel: ObservableObject {
youtubeHeights = video.availableHeights youtubeHeights = video.availableHeights
youtubeHeight = video.height youtubeHeight = video.height
youtubeQuality = height youtubeQuality = height
case .resolved(let source): case .resolved(let source, _):
fileName = source.title fileName = source.title
fileIcon = nil fileIcon = nil
youtubeHeights = [] youtubeHeights = []
@@ -844,10 +931,16 @@ final class PlayerModel: ObservableObject {
if let fileName, !source.url.isFileURL, let id = queue.currentID { if let fileName, !source.url.isFileURL, let id = queue.currentID {
queue.updateLabel(fileName, for: id) queue.updateLabel(fileName, for: id)
} }
let previousSubtitleLanguage = selectedSubtitle.flatMap {
subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil
}
selectSubtitle(nil) selectSubtitle(nil)
if case .resolved(let resolved) = source { if case .resolved(let resolved, _) = source {
subtitleTracks = resolved.subtitles subtitleTracks = resolved.subtitles
selectSubtitle(subtitleTracks.firstIndex(where: \.isDefault)) let defaultIndex = subtitleTracks.firstIndex(where: \.isDefault)
selectSubtitle(change.isQuality ? previousSubtitleLanguage.flatMap { language in
subtitleTracks.firstIndex { $0.language == language } ?? defaultIndex
} : defaultIndex)
} else { } else {
subtitleTracks = [] subtitleTracks = []
} }
@@ -1010,6 +1103,8 @@ final class PlayerModel: ObservableObject {
queueCompletionHandled = true queueCompletionHandled = true
queueCastDevice = nil queueCastDevice = nil
currentMedia = nil currentMedia = nil
selectedMiruroVideoProvider = nil
selectedMiruroCaptionProvider = nil
replaceMetadata(for: nil) replaceMetadata(for: nil)
shouldRecordRecentMedia = false shouldRecordRecentMedia = false
selectSubtitle(nil) selectSubtitle(nil)
@@ -1818,7 +1913,7 @@ final class PlayerModel: ObservableObject {
audioURL: video.audio?.url, audioHeaders: video.audio?.headers, audioURL: video.audio?.url, audioHeaders: video.audio?.headers,
local: false, restricted: false, live: video.isLive, local: false, restricted: false, live: video.isLive,
duration: duration, subtitles: []) duration: duration, subtitles: [])
case .resolved(let resolved): case .resolved(let resolved, _):
return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers, return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers,
audioURL: nil, audioHeaders: nil, local: false, restricted: true, audioURL: nil, audioHeaders: nil, local: false, restricted: true,
live: false, duration: duration, subtitles: resolved.subtitles) live: false, duration: duration, subtitles: resolved.subtitles)
@@ -1860,13 +1955,13 @@ final class PlayerModel: ObservableObject {
private enum MediaSource { private enum MediaSource {
case file(URL) case file(URL)
case youtube(YouTubeVideo, height: Int?) case youtube(YouTubeVideo, height: Int?)
case resolved(ResolvedSource) case resolved(ResolvedSource, providers: MiruroSources? = nil)
var url: URL { var url: URL {
switch self { switch self {
case .file(let url): return url case .file(let url): return url
case .youtube(let video, _): return video.webpageURL case .youtube(let video, _): return video.webpageURL
case .resolved(let source): return URL(string: source.identity)! case .resolved(let source, _): return URL(string: source.identity)!
} }
} }
var live: Bool { var live: Bool {
@@ -2940,6 +3035,7 @@ private struct PlayerContent: View {
PlayerActionButton(model: model, action: .closeFile, title: "Close File") PlayerActionButton(model: model, action: .closeFile, title: "Close File")
Divider() Divider()
PlayerQualityMenu(model: model) PlayerQualityMenu(model: model)
PlayerMiruroMenu(model: model)
Divider() Divider()
if model.isCompact { if model.isCompact {
Menu("Subtitles") { Menu("Subtitles") {
@@ -10,14 +10,19 @@ public struct PlayerConfiguration: Equatable, Sendable {
public var automaticQuality: Bool public var automaticQuality: Bool
public var snapToEdges: Bool public var snapToEdges: Bool
public var compactTitleProgress: CompactTitleProgress public var compactTitleProgress: CompactTitleProgress
public var miruroVideoProvider: String
public var miruroCaptionProvider: String
public var keys: KeyBindings public var keys: KeyBindings
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true, public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
compactTitleProgress: CompactTitleProgress = .time, keys: KeyBindings = KeyBindings()) { compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
self.showBufferInfo = showBufferInfo self.showBufferInfo = showBufferInfo
self.automaticQuality = automaticQuality self.automaticQuality = automaticQuality
self.snapToEdges = snapToEdges self.snapToEdges = snapToEdges
self.compactTitleProgress = compactTitleProgress self.compactTitleProgress = compactTitleProgress
self.miruroVideoProvider = miruroVideoProvider
self.miruroCaptionProvider = miruroCaptionProvider
self.keys = keys self.keys = keys
} }
@@ -28,6 +33,9 @@ public struct PlayerConfiguration: Equatable, Sendable {
var lines = [ var lines = [
"[playback]", "[playback]",
"automatic_quality = \(defaults.automaticQuality)", "automatic_quality = \(defaults.automaticQuality)",
"", "[miruro]",
"video_provider = \"\(defaults.miruroVideoProvider)\"",
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
"", "[ui]", "", "[ui]",
"buffer_info = \(defaults.showBufferInfo)", "buffer_info = \(defaults.showBufferInfo)",
"compact_snap_to_edges = \(defaults.snapToEdges)", "compact_snap_to_edges = \(defaults.snapToEdges)",
@@ -85,6 +93,8 @@ public struct PlayerConfiguration: Equatable, Sendable {
automaticQuality: values.playback?.automaticQuality ?? false, automaticQuality: values.playback?.automaticQuality ?? false,
snapToEdges: values.ui?.compactSnapToEdges ?? true, snapToEdges: values.ui?.compactSnapToEdges ?? true,
compactTitleProgress: values.ui?.compactTitleProgress ?? .time, compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
miruroVideoProvider: values.miruro?.videoProvider ?? "",
miruroCaptionProvider: values.miruro?.captionProvider ?? "",
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:])) keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
} catch { } catch {
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))") throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
@@ -143,10 +153,11 @@ public struct PlayerConfiguration: Equatable, Sendable {
let playback: PlaybackConfiguration? let playback: PlaybackConfiguration?
let ui: UIConfiguration? let ui: UIConfiguration?
let keys: KeysConfiguration? let keys: KeysConfiguration?
let miruro: MiruroConfiguration?
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: SettingKey.self) let container = try decoder.container(keyedBy: SettingKey.self)
try validate(container, allowed: ["playback", "ui", "keys"]) try validate(container, allowed: ["playback", "ui", "keys", "miruro"])
if container.contains(SettingKey("playback")) { if container.contains(SettingKey("playback")) {
playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback"))) playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback")))
} else { playback = nil } } else { playback = nil }
@@ -156,6 +167,9 @@ public struct PlayerConfiguration: Equatable, Sendable {
if container.contains(SettingKey("keys")) { if container.contains(SettingKey("keys")) {
keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys"))) keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys")))
} else { keys = nil } } else { keys = nil }
if container.contains(SettingKey("miruro")) {
miruro = try MiruroConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("miruro")))
} else { miruro = nil }
} }
} }
@@ -168,6 +182,32 @@ public struct PlayerConfiguration: Equatable, Sendable {
} }
} }
private struct MiruroConfiguration {
let videoProvider: String?
let captionProvider: String?
init(container: KeyedDecodingContainer<SettingKey>) throws {
try validate(container, allowed: ["video_provider", "caption_provider"])
videoProvider = try Self.provider("video_provider", in: container)
captionProvider = try Self.provider("caption_provider", in: container)
}
private static func provider(_ name: String, in container: KeyedDecodingContainer<SettingKey>) throws -> String? {
let value = try container.decodeIfPresent(String.self, forKey: SettingKey(name))
if let value {
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
}
}
private struct UIConfiguration { private struct UIConfiguration {
let bufferInfo: Bool? let bufferInfo: Bool?
let compactSnapToEdges: Bool? let compactSnapToEdges: Bool?
-4
View File
@@ -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.
+25 -6
View File
@@ -11,7 +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. - **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. - **Terebi:** episode-URL validation, fresh source resolution, headers/subtitles, titles, optional timeline bands, stable-identity resume and progress reporting.
Native source resolution selects direct **`ssub` / `sun`**. Other providers, iframe embedding, cookies, impersonation and protection-bypass machinery are not supported. 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 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 ## Launch contract
@@ -115,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. `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. 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.
- 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.
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. - 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. - 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. - 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.
@@ -197,9 +214,11 @@ Explicit live native URL smoke (contacts Miruro and media/subtitle resources):
```sh ```sh
.build/miruro-metadata-check --live 'https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1' .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'
.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. 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. 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.
@@ -211,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. - 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. - 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. - 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.
- Other providers' proxies/HLS rewrites, iframe providers, downloads, thumbnails, automatic skipping, embedded captions and additional external-subtitle formats remain out of scope. - 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. The implementation and this handoff are local; no remote publication or Tubular issue/comment update was performed.