diff --git a/Checks/configuration.swift b/Checks/configuration.swift index 7b1b101..c800013 100644 --- a/Checks/configuration.swift +++ b/Checks/configuration.swift @@ -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"]), diff --git a/Checks/resolved-playback.py b/Checks/resolved-playback.py index dab0567..06df001 100644 --- a/Checks/resolved-playback.py +++ b/Checks/resolved-playback.py @@ -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: diff --git a/Checks/resolved-playback.swift b/Checks/resolved-playback.swift index e96a1b3..5806de1 100644 --- a/Checks/resolved-playback.swift +++ b/Checks/resolved-playback.swift @@ -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? + 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") diff --git a/Checks/stream-resolver.swift b/Checks/stream-resolver.swift index aa6177b..a7dda06 100644 --- a/Checks/stream-resolver.swift +++ b/Checks/stream-resolver.swift @@ -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 { diff --git a/README.md b/README.md index 2414fda..8e3e426 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/App/PlayerActions.swift b/Sources/App/PlayerActions.swift index 8bc7a59..153ed3c 100644 --- a/Sources/App/PlayerActions.swift +++ b/Sources/App/PlayerActions.swift @@ -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) } diff --git a/Sources/App/StreamResolver.swift b/Sources/App/StreamResolver.swift index 26644c2..e45bc06 100644 --- a/Sources/App/StreamResolver.swift +++ b/Sources/App/StreamResolver.swift @@ -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.") + var videos: [MiruroSources.Candidate] = [] + var captions: [MiruroSources.Candidate] = [] + var videoNames = Set() + var captionNames = Set() + // Japanese variants only, preferring soft subtitles independently for each provider. + for variant in ["ssub", "sub"] { + for track in response.tracks.lazy.compactMap(\.value) where track.track == variant { + for provider in track.providers.lazy.compactMap(\.value) { + let name = provider.provider + guard validProviderName(name), let config = response.config[name]?.value, + config.player == "native", !config.proxy, + config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else { continue } + let needsVideo = !videoNames.contains(name) + let needsCaptions = !captionNames.contains(name) + guard needsVideo || needsCaptions else { continue } + for server in provider.servers.lazy.compactMap(\.value) where !server.hasEmbed { + let headers = server.headers ?? [:] + var found = false + for stream in server.streams.lazy.compactMap(\.value) { + guard !stream.hasEmbed, + ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format), + let video = try? ResolvedSource.validated(.init(version: 1, url: stream.url, + headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode), + title: nil, subtitles: [])) else { continue } + if videoNames.insert(name).inserted { + videos.append(.init(provider: name, source: video)) + } + // Caption requests retain this provider/server's headers, never the selected video's. + let subtitles = provider.subtitles.lazy.compactMap(\.value).filter { $0.format == "vtt" }.map { + ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language, + label: $0.label, isDefault: $0.default, headers: headers) + } + if needsCaptions, !subtitles.isEmpty, + let caption = try? ResolvedSource.validated(.init(version: 1, url: stream.url, + headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode), + title: nil, subtitles: Array(subtitles))) { + captions.append(.init(provider: name, source: caption)) + captionNames.insert(name) + } + found = true + break + } + if found { break } + } + } + } } - 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.") + // 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 } - guard ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format), - !stream.hasEmbed else { throw InvalidProjection() } - 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)) - } - 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)) + 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: 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] + let config: [String: Entry] + 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] } private struct Provider: Decodable { let provider: String - let servers: [Server] - let subtitles: [Subtitle] + let servers: [Entry] + let subtitles: [Entry] } private struct Server: Decodable { let headers: [String: String]? - let streams: [Stream] + let streams: [Entry] + 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].self, forKey: .streams) + if values.contains(.embed) { + hasEmbed = try !values.decodeNil(forKey: .embed) + } else { + hasEmbed = false + } + } } private struct Stream: Decodable { let url: String diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index f87da99..14213a8 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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") { diff --git a/Sources/Configuration/PlayerConfiguration.swift b/Sources/Configuration/PlayerConfiguration.swift index c7476fd..3916837 100644 --- a/Sources/Configuration/PlayerConfiguration.swift +++ b/Sources/Configuration/PlayerConfiguration.swift @@ -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) 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) 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? diff --git a/docs/miruro-playback-handoff.md b/docs/miruro-playback-handoff.md index 7215738..a70171a 100644 --- a/docs/miruro-playback-handoff.md +++ b/docs/miruro-playback-handoff.md @@ -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