feat: select Miruro playback and caption providers

This commit is contained in:
2026-10-22 18:45:14 +02:00
parent 4576080955
commit 80d9e705e5
10 changed files with 561 additions and 76 deletions
+29 -1
View File
@@ -128,6 +128,31 @@ struct ConfigurationChecks {
"printed TOML is directly usable and preserves all default behavior and bindings")
try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges,
"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 = \"\"\ncaption_provider = \"\"", "", ""),
("video_provider = \"\(String(repeating: "a", count: 128))\"", String(repeating: "a", count: 128), "")
] {
try Data("[miruro]\n\(settings)\n".utf8).write(to: miruro)
try require(try PlayerConfiguration.load(from: miruro)
== PlayerConfiguration(miruroVideoProvider: video, miruroCaptionProvider: captions),
"partial Miruro settings preserve omitted defaults and accept runtime provider names")
}
for key in ["video_provider", "caption_provider"] {
for name in ["with space", " sun", "sun ", "é", "host.name", "host/name", 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)] {
let progress = root.appendingPathComponent("compact-progress-\(value).toml")
try Data("[ui]\ncompact_title_progress = \"\(value)\"\n".utf8).write(to: progress)
@@ -204,7 +229,7 @@ struct ConfigurationChecks {
try require(try PlayerConfiguration.load(from: nested).keys == expectedKeys && keySession.keys == defaults.keys,
"key session overrides never persist")
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")
let reordered = root.appendingPathComponent("reordered.toml")
try Data("""
@@ -226,6 +251,9 @@ struct ConfigurationChecks {
("syntax", "[playback]\nautomatic_quality = [\n", ["automatic_quality"]),
("duplicate", "[playback]\nautomatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
("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-snap", "snap_to_edges = false\n", ["snap_to_edges"]),
("legacy-quality", "automatic_quality = true\n", ["automatic_quality"]),
+8 -2
View File
@@ -20,7 +20,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
path = urlsplit(self.path).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:
errors.append("wrong resource headers: " + path)
self.send_error(403)
@@ -34,7 +38,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
errors.append("variant token lost")
data = (fixtures / "resolved-vod.m3u8").read_bytes()
elif path == "/english.jpeg":
data = b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n'
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"):
data = (fixtures / path[1:]).read_bytes()
else:
@@ -71,6 +76,7 @@ try:
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
assert not errors, errors
assert {"/master.m3u8", "/variant.m3u8", "/english.jpeg"}.issubset(seen), "master, nested playlist and external VTT fetched"
assert {"/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"
print("PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
finally:
+115 -10
View File
@@ -37,7 +37,7 @@ struct ResolvedPlaybackCheck {
guard case .miruro(let mediaID, let episode) = try StreamURL.parse(input) else {
fatalError("FAIL: URL smoke requires a Miruro episode")
}
source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode).select().source
} else {
source = try ResolvedSource.load(from: file)
}
@@ -106,6 +106,7 @@ struct ResolvedPlaybackCheck {
if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) }
if !input.hasPrefix("https://") {
model.shutdown()
try await providerLifecycle(source: source)
try await queueMetadataLifecycle(source: source)
try await streamQueueLifecycle(source: source)
try await enqueueLifecycle(source: source)
@@ -113,6 +114,111 @@ struct ResolvedPlaybackCheck {
}
}
static func providerSources(_ source: ResolvedSource, alternate: Bool = false) 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]]))
config["alternate"] = ["player": "native", "proxy": false]
}
return try MiruroResolver.decode(JSONSerialization.data(withJSONObject: [
"episodeNumber": 1, "providerOrder": ["sun", "captions", "alternate"], "config": config,
"tracks": [["track": "ssub", "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" && model.selectedMiruroCaptionProvider == "captions",
"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")
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")
try await until("alternate host preserves paused position", model: model) {
model.canChangeMiruroProvider && model.state.status == PLAYER_PAUSED &&
model.state.revision != revision && abs(model.state.position - position) < 0.2
}
require(model.selectedMiruroVideoProvider == "alternate" && model.selectedMiruroCaptionProvider == "alternate",
"host and captions remain independently selected")
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"
}
require(model.selectedSubtitle == nil, "host changes preserve explicit subtitle Off")
model.setMiruroVideoProvider("alternate")
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" &&
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 queueMetadataLifecycle(source: ResolvedSource) async throws {
let suite = "terebi.queue-metadata-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
@@ -263,7 +369,7 @@ struct ResolvedPlaybackCheck {
return video
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in
episodeRequests += 1
return source
return try providerSources(source)
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() }
require(model.progressInitializationError == nil, "queue report registration")
@@ -281,7 +387,7 @@ struct ResolvedPlaybackCheck {
stale = nil
// Yield to the canceled resolver; it must not open or redirect its report.
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")
model.advanceQueue(-1)
try await until("previous resolves YouTube afresh", model: model) {
@@ -297,7 +403,7 @@ struct ResolvedPlaybackCheck {
require(model.setPlayback(playing: true), "play first stream to natural end")
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.fileName == source.title && episodeRequests == 2
!model.miruroVideoProviders.isEmpty && episodeRequests == 2
}
require(model.setPlayback(playing: false), "pause second stream for restoration")
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
@@ -311,7 +417,7 @@ struct ResolvedPlaybackCheck {
}
model.advanceQueue(1)
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
}
require(youtubeRequests == 3 && episodeRequests == 3, "every revisit resolves fresh transport metadata")
@@ -361,7 +467,7 @@ struct ResolvedPlaybackCheck {
return YouTubeVideo(title: url.absoluteString,
video: .init(url: source.url, headers: source.headers), audio: nil,
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: []) })
defer { model.shutdown() }
func deliver(_ items: [(URL, URL)]) throws {
@@ -415,7 +521,7 @@ struct ResolvedPlaybackCheck {
}
require(model.setPlayback(playing: true), "resume original through natural end")
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.advanceQueue(1)
@@ -424,7 +530,7 @@ struct ResolvedPlaybackCheck {
}
model.advanceQueue(-1)
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)
try await until("original single video remains navigable", model: model) {
@@ -510,7 +616,7 @@ struct ResolvedPlaybackCheck {
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
return video
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in source },
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in try providerSources(source) },
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() }
try await model.addStreamToQueue(first.absoluteString)
@@ -527,7 +633,6 @@ struct ResolvedPlaybackCheck {
try await model.addStreamToQueue(episode.absoluteString)
require(model.queue.items.map(\.url) == [first, second, episode] &&
model.queue.items[1].displayName == "Sidebar second" &&
model.queue.items[2].displayName == source.title &&
model.queue.currentID == selected && model.state.revision == revision &&
model.state.position == position && model.state.status == PLAYER_PAUSED,
"fetched YouTube and Miruro additions preserve paused playback, order and titles")
+94 -10
View File
@@ -40,7 +40,17 @@ struct StreamResolverChecks {
throw Failure(message: "Accepted \(message)")
}
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 {
@@ -84,6 +94,79 @@ struct StreamResolverChecks {
try require(source.subtitles.count == 2 && source.subtitles[0].isDefault, "only external VTT retained with default")
try require(source.subtitles[0].url.hasSuffix(".jpeg") && source.subtitles[1].label == "Spanish (Latin America)", "subtitle format and labels independent of file extension")
try require(source.subtitles.allSatisfy { $0.headers == source.headers }, "verified Sun subtitle headers retained")
let defaults = try MiruroResolver.decode(Data(fixture.utf8), mediaID: mediaID, episode: 1)
let defaultSelection = try defaults.select()
try require(defaults.videoProviders == ["sun"] && defaults.captionProviders == ["sun"], "menus contain usable native providers")
try require(defaultSelection.videoProvider == "sun" && defaultSelection.captionProvider == "sun", "existing Sun defaults")
let native: [String: Any] = ["player": "native", "proxy": false]
var document: [String: Any] = [
"episodeNumber": 1, "providerOrder": ["cloud", "river", "sun", "cloud", "missing"],
"config": ["sun": native, "river": native, "cloud": native],
"tracks": [["track": "ssub", "providers": [provider("river"), provider("sun"), provider("cloud")]]]
]
let choices = try catalog(document)
try require(choices.videoProviders == ["cloud", "river", "sun"] &&
choices.captionProviders == ["cloud", "river", "sun"], "runtime names follow deduplicated provider order")
let automatic = try choices.select()
try require(automatic.videoProvider == "sun" && automatic.captionProvider == "sun", "Auto prefers usable Sun")
let independent = try choices.select(videoProvider: "river", captionProvider: "cloud")
try require(independent.videoProvider == "river" && independent.captionProvider == "cloud", "explicit independent choices")
try require(independent.source.url == "https://river.example/master.m3u8" &&
independent.source.headers == "Referer: https://river.example/\r\n", "selected host retains its URL and headers")
try require(independent.source.subtitles.first?.url == "https://cloud.example/english.vtt" &&
independent.source.subtitles.first?.headers == "Referer: https://cloud.example/\r\n", "captions retain their own provider headers")
let sameHost = try choices.select(videoProvider: "river")
try require(sameHost.captionProvider == "river", "Auto captions prefer chosen video provider")
let absent = try choices.select(videoProvider: "absent", captionProvider: "unsupported")
let empty = try choices.select(videoProvider: "", captionProvider: "")
try require(absent.videoProvider == "sun" && absent.captionProvider == "sun" &&
empty.videoProvider == "sun" && empty.captionProvider == "sun", "absent and empty preferences fall back to Auto")
document["tracks"] = [["track": "ssub", "providers": [provider("river"), provider("cloud")]]]
let withoutSun = try catalog(document).select()
try require(withoutSun.videoProvider == "cloud" && withoutSun.captionProvider == "cloud", "provider order applies without Sun")
document["providerOrder"] = []
let returnedOrder = try catalog(document).select()
try require(returnedOrder.videoProvider == "river", "returned order is deterministic when no provider order applies")
document["tracks"] = [["track": "ssub", "providers": [provider("river", captions: false), provider("cloud")]]]
let otherCaptions = try catalog(document).select()
try require(otherCaptions.videoProvider == "river" && otherCaptions.captionProvider == "cloud", "Auto captions fall back independently")
document["tracks"] = [["track": "sub", "providers": [provider("river", captions: false)]]]
let noCaptions = try catalog(document)
let bare = try noCaptions.select(captionProvider: "cloud")
try require(noCaptions.captionProviders.isEmpty && bare.captionProvider == nil && bare.source.subtitles.isEmpty, "Japanese sub without captions remains playable")
var soft = provider("river")
soft["servers"] = [["streams": [["url": "https://soft.example/master.m3u8", "format": "m3u8"]]]]
document["tracks"] = [["track": "sub", "providers": [provider("river")]],
["track": "ssub", "providers": [soft]],
["track": "dub", "providers": [provider("cloud")]]]
let preferredVariant = try catalog(document)
let preferredSelection = try preferredVariant.select()
try require(preferredVariant.videoProviders == ["river"] &&
preferredSelection.source.url == "https://soft.example/master.m3u8", "ssub wins per provider without dub fallback")
var mixedConfigs: [String: Any] = ["river": native, "proxy": ["player": "native", "proxy": ["segments": true]],
"iframe": ["player": "iframe", "proxy": false], "broken": ["player": 42], "bad name": native]
mixedConfigs["rewrite"] = ["player": "native", "proxy": false, "hls": ["query": ["enabled": true]]]
document["config"] = mixedConfigs
var resilient = provider("river")
resilient["servers"] = [
["streams": [["url": "https://embed.example/master.m3u8", "format": "hls"]], "embed": ["url": "https://embed.example/"]],
["streams": [42, ["url": "file:///secret", "format": "hls"],
["url": "https://river.example/master.m3u8", "format": "application/vnd.apple.mpegurl"]] as [Any],
"headers": ["Referer": "https://river.example/"]]] as [[String: Any]]
document["tracks"] = [["track": "ssub", "providers": [
42, ["provider": "broken", "servers": "invalid"], provider("proxy"), provider("iframe"),
provider("rewrite"), provider("bad name"), resilient] as [Any]]]
let resilientSources = try catalog(document)
let resilientChoice = try resilientSources.select(videoProvider: "proxy", captionProvider: "iframe")
try require(resilientSources.videoProviders == ["river"] && resilientSources.captionProviders == ["river"] &&
resilientChoice.source.url == "https://river.example/master.m3u8", "malformed and unsupported entries do not poison usable alternatives")
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"))
try require(renewed.identity == source.identity && renewed.url != source.url, "token rotation does not change stable identity")
for (old, replacement) in [
@@ -92,14 +175,13 @@ struct StreamResolverChecks {
("\"enabled\": false", "\"enabled\": true"),
("\"player\": \"native\"", "\"player\": \"iframe\""),
("\"track\": \"ssub\"", "\"track\": \"dub\""),
("\"provider\": \"sun\"", "\"provider\": \"bee\""),
("\"proxy\": false", "\"proxy\": {\"segments\": true}"),
("\"format\": \"hls\"", "\"format\": \"mp4\""),
("\"embed\": null", "\"embed\": {\"url\": \"https://embed.example/secret\"}"),
("https://vault.example/master.m3u8?token=secret", "file:///secret"),
("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\""),
("\"Origin\":", "\"origin\": \"secret\", \"Origin\":"),
("\"default\": false", "\"default\": true")
("\"Origin\":", "\"origin\": \"secret\", \"Origin\":")
] {
try reject("unsafe/unsupported source projection") {
_ = try project(fixture.replacingOccurrences(of: old, with: replacement))
@@ -122,7 +204,7 @@ struct StreamResolverChecks {
_ = try await cancelled.value
throw Failure(message: "Cancelled resolution was accepted")
} catch is CancellationError {}
print("PASS: streaming URL security, Sun projection, shared validation, bounded responses and cancellation")
print("PASS: streaming URL security, runtime native providers, independent captions, bounded responses and cancellation")
}
static func main() async {
@@ -132,12 +214,14 @@ struct StreamResolverChecks {
guard case .miruro(let id, let episode) = try StreamURL.parse(args[1]) else {
throw Failure(message: "--live requires a Miruro episode URL")
}
let source = try await MiruroResolver.resolve(mediaID: id, episode: episode)
print("PASS: live \(source.identity); \(source.subtitles.count) VTT tracks")
let sources = try await MiruroResolver.resolve(mediaID: id, episode: episode)
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" {
let data = try Data(contentsOf: URL(fileURLWithPath: args[1]))
let source = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1)
print("PASS: fixture \(source.identity); \(source.subtitles.count) VTT tracks")
let sources = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1)
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 {
try await checks()
} else {
+16
View File
@@ -81,6 +81,22 @@ 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.
### Miruro providers
Choose **Playback → Miruro → Video Host** or **Caption Provider** in Terebi's menu bar, or use **gear → Miruro**, to change providers for the session. Video and caption providers are independent; available names come from the episode response. These menus do not write the config file.
Optional startup preferences:
```toml
[miruro]
video_provider = ""
caption_provider = ""
```
An empty or omitted preference means **Auto**. Automatic video selection prefers a usable **Sun** host, then the returned provider order. Automatic captions prefer the selected video's provider, then Sun, then the returned order. An unavailable or unsupported preference falls back to automatic selection; choosing captions does not change the video host. Provider names accept up to 128 ASCII letters, digits, underscores or hyphens; valid names need not be available for every episode. Unknown config keys and non-string values are rejected.
Only native-playable sources are offered: iframe embeds and sources requiring a proxy or HLS rewriting are excluded. Provider selection does not switch into dubbed audio.
## Limitations
- SDR output only; HDR is not supported.
+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
struct PlayerCommands: Commands {
@FocusedObject private var focusedModel: PlayerModel?
@@ -275,6 +310,7 @@ struct PlayerCommands: Commands {
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
}
}
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
model.performMenuAction(bufferInfo)
}
+130 -31
View File
@@ -41,10 +41,48 @@ enum StreamURL: Sendable {
}
}
struct MiruroSelection: Sendable {
let source: ResolvedSource
let videoProvider: String
let captionProvider: String?
}
struct MiruroSources: Sendable {
fileprivate struct Candidate: Sendable {
let provider: String
let source: ResolvedSource
}
fileprivate let videos: [Candidate]
fileprivate let captions: [Candidate]
let videoProviders: [String]
let captionProviders: [String]
fileprivate init(videos: [Candidate], captions: [Candidate]) {
self.videos = videos
self.captions = captions
videoProviders = videos.map(\.provider)
captionProviders = captions.map(\.provider)
}
func select(videoProvider: String? = nil, captionProvider: String? = nil) throws -> MiruroSelection {
guard let video = videos.first(where: { $0.provider == videoProvider }) ??
videos.first(where: { $0.provider == "sun" }) ?? videos.first else {
throw StreamResolutionError(message: "Direct Miruro Japanese source is unavailable.")
}
let caption = captions.first(where: { $0.provider == captionProvider }) ??
captions.first(where: { $0.provider == video.provider }) ??
captions.first(where: { $0.provider == "sun" }) ?? captions.first
let source = ResolvedSource(url: video.source.url, headers: video.source.headers,
identity: video.source.identity, title: video.source.title, subtitles: caption?.source.subtitles ?? [])
return MiruroSelection(source: source, videoProvider: video.provider, captionProvider: caption?.provider)
}
}
enum MiruroResolver {
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()
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
throw StreamResolutionError(message: "Invalid Miruro episode identity.")
@@ -100,51 +138,100 @@ enum MiruroResolver {
}
}
// Match Tubular's direct ssub/Sun projection, without provider or server retry.
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> ResolvedSource {
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> MiruroSources {
do {
guard data.count <= bodyLimit,
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
let response = try JSONDecoder().decode(Sources.self, from: data)
guard response.episodeNumber == episode else { throw InvalidProjection() }
let config = response.config.sun
guard config.player == "native", !config.proxy,
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else {
throw StreamResolutionError(message: "Miruro Sun requires unsupported proxy or HLS rewriting.")
}
guard let provider = response.tracks.first(where: { $0.track == "ssub" })?
.providers.first(where: { $0.provider == "sun" }),
let server = provider.servers.first(where: { !$0.streams.isEmpty }),
let stream = server.streams.first else {
throw StreamResolutionError(message: "Direct Miruro Sun soft-subtitle source is unavailable.")
}
guard ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
!stream.hasEmbed else { throw InvalidProjection() }
var videos: [MiruroSources.Candidate] = []
var captions: [MiruroSources.Candidate] = []
var videoNames = Set<String>()
var captionNames = Set<String>()
// Japanese variants only, preferring soft subtitles independently for each provider.
for variant in ["ssub", "sub"] {
for track in response.tracks.lazy.compactMap(\.value) where track.track == variant {
for provider in track.providers.lazy.compactMap(\.value) {
let name = provider.provider
guard validProviderName(name), let config = response.config[name]?.value,
config.player == "native", !config.proxy,
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else { continue }
let needsVideo = !videoNames.contains(name)
let needsCaptions = !captionNames.contains(name)
guard needsVideo || needsCaptions else { continue }
for server in provider.servers.lazy.compactMap(\.value) where !server.hasEmbed {
let headers = server.headers ?? [:]
var subtitles: [ResolvedSubtitle] = []
for subtitle in provider.subtitles where subtitle.format == "vtt" {
guard subtitles.count < 32 else { throw InvalidProjection() }
subtitles.append(ResolvedSubtitle(url: subtitle.file, format: subtitle.format, language: subtitle.language,
label: subtitle.label, isDefault: subtitle.default, headers: headers))
var found = false
for stream in server.streams.lazy.compactMap(\.value) {
guard !stream.hasEmbed,
["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
let video = try? ResolvedSource.validated(.init(version: 1, url: stream.url,
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
title: nil, subtitles: [])) else { continue }
if videoNames.insert(name).inserted {
videos.append(.init(provider: name, source: video))
}
return try ResolvedSource.validated(.init(version: 1, url: stream.url, headers: headers,
identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
title: nil, subtitles: subtitles))
// Caption requests retain this provider/server's headers, never the selected video's.
let subtitles = provider.subtitles.lazy.compactMap(\.value).filter { $0.format == "vtt" }.map {
ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language,
label: $0.label, isDefault: $0.default, headers: headers)
}
if needsCaptions, !subtitles.isEmpty,
let caption = try? ResolvedSource.validated(.init(version: 1, url: stream.url,
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
title: nil, subtitles: Array(subtitles))) {
captions.append(.init(provider: name, source: caption))
captionNames.insert(name)
}
found = true
break
}
if found { break }
}
}
}
}
// API order is advisory; retain response order for names omitted from it.
let order = response.providerOrder ?? []
func ordered(_ candidates: [MiruroSources.Candidate]) -> [MiruroSources.Candidate] {
var remaining = Dictionary(uniqueKeysWithValues: candidates.map { ($0.provider, $0) })
var result: [MiruroSources.Candidate] = []
for name in order {
if let candidate = remaining.removeValue(forKey: name) { result.append(candidate) }
}
result.append(contentsOf: candidates.filter { remaining[$0.provider] != nil })
return result
}
let sources = MiruroSources(videos: ordered(videos), captions: ordered(captions))
_ = try sources.select()
return sources
} catch {
if let error = error as? StreamResolutionError { throw error }
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 {}
// 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 {
let episodeNumber: Int
let tracks: [Track]
let config: Config
let tracks: [Entry<Track>]
let config: [String: Entry<ProviderConfig>]
let providerOrder: [String]?
}
private struct Config: Decodable { let sun: ProviderConfig }
private struct ProviderConfig: Decodable {
let player: String
// Objects describe proxy work this native player cannot perform.
let proxy: Bool
let hls: HLS?
}
@@ -155,16 +242,28 @@ enum MiruroResolver {
}
private struct Track: Decodable {
let track: String
let providers: [Provider]
let providers: [Entry<Provider>]
}
private struct Provider: Decodable {
let provider: String
let servers: [Server]
let subtitles: [Subtitle]
let servers: [Entry<Server>]
let subtitles: [Entry<Subtitle>]
}
private struct Server: Decodable {
let headers: [String: String]?
let streams: [Stream]
let streams: [Entry<Stream>]
let hasEmbed: Bool
enum CodingKeys: String, CodingKey { case headers, streams, embed }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
headers = try values.decodeIfPresent([String: String].self, forKey: .headers)
streams = try values.decode([Entry<Stream>].self, forKey: .streams)
if values.contains(.embed) {
hasEmbed = try !values.decodeNil(forKey: .embed)
} else {
hasEmbed = false
}
}
}
private struct Stream: Decodable {
let url: String
+90 -19
View File
@@ -121,6 +121,20 @@ final class PlayerModel: ObservableObject {
@Published private(set) var youtubeQuality: Int?
@Published private(set) var automaticYouTubeQuality = false
@Published private(set) var showsBufferInfo = false
@Published private(set) var miruroVideoProvider: String
@Published private(set) var miruroCaptionProvider: String
@Published private(set) var selectedMiruroVideoProvider: String?
@Published private(set) var selectedMiruroCaptionProvider: String?
private var miruroSources: MiruroSources? {
if case .resolved(_, let providers) = currentMedia { return providers }
return nil
}
var miruroVideoProviders: [String] { miruroSources?.videoProviders ?? [] }
var miruroCaptionProviders: [String] { miruroSources?.captionProviders ?? [] }
var canChangeMiruroProvider: Bool {
castDevice == nil && miruroSources != nil && canTogglePlayback &&
!isSeeking && !transition.seekPending && !state.seek_pending
}
@Published private(set) var subtitleTracks: [ResolvedSubtitle] = []
@Published private(set) var selectedSubtitle: Int?
@Published private(set) var subtitleText = ""
@@ -188,7 +202,7 @@ final class PlayerModel: ObservableObject {
private var reportingErrorBuffer = [CChar]()
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
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 var reportingErrorMessage: String {
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
@@ -198,7 +212,7 @@ final class PlayerModel: ObservableObject {
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
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) },
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) },
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
@@ -212,6 +226,8 @@ final class PlayerModel: ObservableObject {
compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality
showsBufferInfo = configuration.showBufferInfo
miruroVideoProvider = configuration.miruroVideoProvider
miruroCaptionProvider = configuration.miruroCaptionProvider
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
playbackProgress = PlaybackProgress(defaults: defaults)
recentMedia = playbackProgress.recentMedia
@@ -404,11 +420,13 @@ final class PlayerModel: ObservableObject {
title = video.title
source = .youtube(video, height: nil)
case .miruro(let mediaID, let episode):
let resolved = try await miruroResolver(mediaID, episode)
let providers = try await miruroResolver(mediaID, episode)
let resolved = try providers.select(videoProvider: miruroVideoProvider,
captionProvider: miruroCaptionProvider).source
guard resolved.identity == "miruro:\(mediaID):\(episode)" else { throw CocoaError(.fileReadCorruptFile) }
url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
title = resolved.title
source = .resolved(resolved)
source = .resolved(resolved, providers: providers)
}
try Task.checkCancellation()
guard canOpen else { throw CancellationError() }
@@ -578,6 +596,38 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: height, change: change)
}
func setMiruroVideoProvider(_ provider: String) {
guard canChangeMiruroProvider, let handle,
provider.isEmpty || miruroVideoProviders.contains(provider),
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 }
do {
let selection = try providers.select(videoProvider: selectedMiruroVideoProvider,
captionProvider: provider)
let language = selectedSubtitle.flatMap { subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil }
miruroCaptionProvider = provider
selectedMiruroCaptionProvider = selection.captionProvider
currentMedia = .resolved(selection.source, providers: providers)
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) {
guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) }
@@ -594,22 +644,25 @@ final class PlayerModel: ObservableObject {
commandError = nil
revealControls()
let resolver = miruroResolver
let videoProvider = miruroVideoProvider
let captionProvider = miruroCaptionProvider
let task = Task { [weak self] in
do {
let source = try await resolver(mediaID, episode)
let providers = try await resolver(mediaID, episode)
let selection = try providers.select(videoProvider: videoProvider, captionProvider: captionProvider)
let source = selection.source
guard source.identity == "miruro:\(mediaID):\(episode)" else {
throw CocoaError(.fileReadCorruptFile)
}
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return }
self.openMedia(.resolved(source), change: change)
self.openMedia(.resolved(source, providers: providers), change: change)
} catch {
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard self.transition.finishResolution(request) != nil else { return }
self.commandError = error.localizedDescription
self.revealControls()
self.failStreamChange(change, error: error)
}
}
transition.attachResolution(task, request: request)
@@ -652,7 +705,7 @@ final class PlayerModel: ObservableObject {
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return }
self.failYouTubeChange(change, error: error)
self.failStreamChange(change, error: error)
}
}
transition.attachResolution(task, request: request)
@@ -664,7 +717,7 @@ final class PlayerModel: ObservableObject {
transition.cancelResolution()
}
private func failYouTubeChange(_ change: MediaChange, error: Error? = nil) {
private func failStreamChange(_ change: MediaChange, error: Error? = nil) {
if change.automatic {
automaticQuality.extractionFailed(at: qualityNow)
return
@@ -791,7 +844,7 @@ final class PlayerModel: ObservableObject {
}
}
}
case .resolved(let source):
case .resolved(let source, _):
accepted = source.identity.withCString { identity in
source.url.withCString { url in
source.headers.withCString { headers in
@@ -808,9 +861,9 @@ final class PlayerModel: ObservableObject {
case .file:
commandError = "Could not queue the file to open. Please try again."
case .resolved:
commandError = "Could not queue the resolved stream to open."
failStreamChange(change, error: CocoaError(.fileReadUnknown))
case .youtube:
failYouTubeChange(change)
failStreamChange(change)
}
return
}
@@ -819,6 +872,15 @@ final class PlayerModel: ObservableObject {
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
queueCompletionHandled = false
currentMedia = source
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
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
switch source {
@@ -834,7 +896,7 @@ final class PlayerModel: ObservableObject {
youtubeHeights = video.availableHeights
youtubeHeight = video.height
youtubeQuality = height
case .resolved(let source):
case .resolved(let source, _):
fileName = source.title
fileIcon = nil
youtubeHeights = []
@@ -844,10 +906,16 @@ final class PlayerModel: ObservableObject {
if let fileName, !source.url.isFileURL, let id = queue.currentID {
queue.updateLabel(fileName, for: id)
}
let previousSubtitleLanguage = selectedSubtitle.flatMap {
subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil
}
selectSubtitle(nil)
if case .resolved(let resolved) = source {
if case .resolved(let resolved, _) = source {
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 {
subtitleTracks = []
}
@@ -1010,6 +1078,8 @@ final class PlayerModel: ObservableObject {
queueCompletionHandled = true
queueCastDevice = nil
currentMedia = nil
selectedMiruroVideoProvider = nil
selectedMiruroCaptionProvider = nil
replaceMetadata(for: nil)
shouldRecordRecentMedia = false
selectSubtitle(nil)
@@ -1818,7 +1888,7 @@ final class PlayerModel: ObservableObject {
audioURL: video.audio?.url, audioHeaders: video.audio?.headers,
local: false, restricted: false, live: video.isLive,
duration: duration, subtitles: [])
case .resolved(let resolved):
case .resolved(let resolved, _):
return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers,
audioURL: nil, audioHeaders: nil, local: false, restricted: true,
live: false, duration: duration, subtitles: resolved.subtitles)
@@ -1860,13 +1930,13 @@ final class PlayerModel: ObservableObject {
private enum MediaSource {
case file(URL)
case youtube(YouTubeVideo, height: Int?)
case resolved(ResolvedSource)
case resolved(ResolvedSource, providers: MiruroSources? = nil)
var url: URL {
switch self {
case .file(let url): return url
case .youtube(let video, _): return video.webpageURL
case .resolved(let source): return URL(string: source.identity)!
case .resolved(let source, _): return URL(string: source.identity)!
}
}
var live: Bool {
@@ -2940,6 +3010,7 @@ private struct PlayerContent: View {
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
Divider()
PlayerQualityMenu(model: model)
PlayerMiruroMenu(model: model)
Divider()
if model.isCompact {
Menu("Subtitles") {
@@ -10,14 +10,19 @@ public struct PlayerConfiguration: Equatable, Sendable {
public var automaticQuality: Bool
public var snapToEdges: Bool
public var compactTitleProgress: CompactTitleProgress
public var miruroVideoProvider: String
public var miruroCaptionProvider: String
public var keys: KeyBindings
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.automaticQuality = automaticQuality
self.snapToEdges = snapToEdges
self.compactTitleProgress = compactTitleProgress
self.miruroVideoProvider = miruroVideoProvider
self.miruroCaptionProvider = miruroCaptionProvider
self.keys = keys
}
@@ -28,6 +33,9 @@ public struct PlayerConfiguration: Equatable, Sendable {
var lines = [
"[playback]",
"automatic_quality = \(defaults.automaticQuality)",
"", "[miruro]",
"video_provider = \"\(defaults.miruroVideoProvider)\"",
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
"", "[ui]",
"buffer_info = \(defaults.showBufferInfo)",
"compact_snap_to_edges = \(defaults.snapToEdges)",
@@ -85,6 +93,8 @@ public struct PlayerConfiguration: Equatable, Sendable {
automaticQuality: values.playback?.automaticQuality ?? false,
snapToEdges: values.ui?.compactSnapToEdges ?? true,
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
miruroVideoProvider: values.miruro?.videoProvider ?? "",
miruroCaptionProvider: values.miruro?.captionProvider ?? "",
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
} catch {
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
@@ -143,10 +153,11 @@ public struct PlayerConfiguration: Equatable, Sendable {
let playback: PlaybackConfiguration?
let ui: UIConfiguration?
let keys: KeysConfiguration?
let miruro: MiruroConfiguration?
init(from decoder: Decoder) throws {
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")) {
playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback")))
} else { playback = nil }
@@ -156,6 +167,9 @@ public struct PlayerConfiguration: Equatable, Sendable {
if container.contains(SettingKey("keys")) {
keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys")))
} 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,29 @@ 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 {
guard value.utf8.count <= 128, value.utf8.allSatisfy({
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 95 || $0 == 45
}) else {
throw ConfigurationError(message: "miruro.\(name): Expected an empty string (Auto) or up to 128 ASCII letters, digits, underscores or hyphens.")
}
}
return value
}
}
private struct UIConfiguration {
let bufferInfo: Bool?
let compactSnapToEdges: Bool?
+4 -1
View File
@@ -11,7 +11,10 @@ Tubular launches native episode URLs. Terebi resolves fresh direct sources and i
- **Tubular:** catalog/detail browsing, actual returned episode selection, collections, private reporting sessions, cleanup and watched-state decisions.
- **Terebi:** episode-URL validation, fresh source resolution, headers/subtitles, titles, optional timeline bands, stable-identity resume and progress reporting.
Native source resolution selects direct **`ssub` / `sun`**. Other providers, iframe embedding, cookies, impersonation and protection-bypass machinery are not supported.
Native source resolution discovers validated direct Japanese `ssub`/`sub` providers at runtime, preferring soft subtitles per provider. **Playback → Miruro** and the gear menu offer independent **Video Host** / **Caption Provider** choices; optional `[miruro]` config strings `video_provider` and `caption_provider` set startup preferences (empty means Automatic). Missing/unsupported preferences fall back to Sun then returned order for video; captions prefer the chosen video's provider, then Sun and returned order. Dub is not an automatic fallback. Iframe/proxy/HLS-rewriting providers, cookies, impersonation and protection-bypass machinery remain unsupported.
Host changes resolve fresh sources and preserve acknowledged position and playing/paused intent; caption changes retain their own provider/server headers and do not reopen video. Caption language is retained when available, and explicit Off survives provider changes. Choices made in menus are session-only. Native source menus are disabled during opening, pending seeks and casting.
Provider-selection verification: resolver, configuration, CLI and metadata checks passed; app/CLI builds passed. Local native HLS/VTT playback exercised independent caption headers, in-place caption changes, missing-host fallback, paused/playing host changes, failed-fetch retention and rejection of late results after close. Existing mixed queues, report isolation and subtitle timing checks passed. A live probe found only Sun as a supported native video/caption provider for the sampled episode, with nine VTT tracks; other providers' live playback and visible menu/subtitle verification are not claimed.
## Launch contract