feat: select Miruro playback and caption providers
This commit is contained in:
@@ -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"]),
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user