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")
|
"printed TOML is directly usable and preserves all default behavior and bindings")
|
||||||
try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges,
|
try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges,
|
||||||
"built-in defaults hide buffer info, disable automatic quality, and enable snapping")
|
"built-in defaults hide buffer info, disable automatic quality, and enable snapping")
|
||||||
|
try require(defaults.miruroVideoProvider.isEmpty && defaults.miruroCaptionProvider.isEmpty,
|
||||||
|
"Miruro defaults select automatic video and captions independently")
|
||||||
|
let miruro = root.appendingPathComponent("miruro.toml")
|
||||||
|
for (settings, video, captions) in [
|
||||||
|
("video_provider = \"new-Host_2\"", "new-Host_2", ""),
|
||||||
|
("caption_provider = \"moon\"", "", "moon"),
|
||||||
|
("video_provider = \"sun\"\ncaption_provider = \"moon\"", "sun", "moon"),
|
||||||
|
("video_provider = \"\"\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)] {
|
for (value, expected) in [("time", CompactTitleProgress.time), ("percentage", .percentage), ("none", .none)] {
|
||||||
let progress = root.appendingPathComponent("compact-progress-\(value).toml")
|
let progress = root.appendingPathComponent("compact-progress-\(value).toml")
|
||||||
try Data("[ui]\ncompact_title_progress = \"\(value)\"\n".utf8).write(to: progress)
|
try Data("[ui]\ncompact_title_progress = \"\(value)\"\n".utf8).write(to: progress)
|
||||||
@@ -204,7 +229,7 @@ struct ConfigurationChecks {
|
|||||||
try require(try PlayerConfiguration.load(from: nested).keys == expectedKeys && keySession.keys == defaults.keys,
|
try require(try PlayerConfiguration.load(from: nested).keys == expectedKeys && keySession.keys == defaults.keys,
|
||||||
"key session overrides never persist")
|
"key session overrides never persist")
|
||||||
let emptySections = root.appendingPathComponent("empty-sections.toml")
|
let emptySections = root.appendingPathComponent("empty-sections.toml")
|
||||||
try Data("[ui]\n[keys]\n[playback]\n".utf8).write(to: emptySections)
|
try Data("[ui]\n[keys]\n[playback]\n[miruro]\n".utf8).write(to: emptySections)
|
||||||
try require(try PlayerConfiguration.load(from: emptySections) == defaults, "empty nested sections use defaults")
|
try require(try PlayerConfiguration.load(from: emptySections) == defaults, "empty nested sections use defaults")
|
||||||
let reordered = root.appendingPathComponent("reordered.toml")
|
let reordered = root.appendingPathComponent("reordered.toml")
|
||||||
try Data("""
|
try Data("""
|
||||||
@@ -226,6 +251,9 @@ struct ConfigurationChecks {
|
|||||||
("syntax", "[playback]\nautomatic_quality = [\n", ["automatic_quality"]),
|
("syntax", "[playback]\nautomatic_quality = [\n", ["automatic_quality"]),
|
||||||
("duplicate", "[playback]\nautomatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
|
("duplicate", "[playback]\nautomatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
|
||||||
("unknown", "unknown_setting = true\n", ["unknown_setting"]),
|
("unknown", "unknown_setting = true\n", ["unknown_setting"]),
|
||||||
|
("miruro-scalar", "miruro = true\n", ["miruro"]),
|
||||||
|
("miruro-unknown", "[miruro]\nprovider = \"sun\"\n", ["miruro.provider"]),
|
||||||
|
("miruro-nested", "[miruro.video_provider]\nname = \"sun\"\n", []),
|
||||||
("legacy-buffer", "show_buffer_info = true\n", ["show_buffer_info"]),
|
("legacy-buffer", "show_buffer_info = true\n", ["show_buffer_info"]),
|
||||||
("legacy-snap", "snap_to_edges = false\n", ["snap_to_edges"]),
|
("legacy-snap", "snap_to_edges = false\n", ["snap_to_edges"]),
|
||||||
("legacy-quality", "automatic_quality = true\n", ["automatic_quality"]),
|
("legacy-quality", "automatic_quality = true\n", ["automatic_quality"]),
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
path = urlsplit(self.path).path
|
path = urlsplit(self.path).path
|
||||||
seen.append(path)
|
seen.append(path)
|
||||||
expected = "subtitle-private" if path == "/english.jpeg" else "stream-private"
|
alternate = path.startswith("/alternate/")
|
||||||
|
captions = path.startswith("/captions/")
|
||||||
|
if alternate or captions:
|
||||||
|
path = "/" + path.split("/", 2)[2]
|
||||||
|
expected = "alternate-private" if alternate else "subtitle-private" if captions or path == "/english.jpeg" else "stream-private"
|
||||||
if self.headers.get("X-Resource") != expected:
|
if self.headers.get("X-Resource") != expected:
|
||||||
errors.append("wrong resource headers: " + path)
|
errors.append("wrong resource headers: " + path)
|
||||||
self.send_error(403)
|
self.send_error(403)
|
||||||
@@ -34,7 +38,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
errors.append("variant token lost")
|
errors.append("variant token lost")
|
||||||
data = (fixtures / "resolved-vod.m3u8").read_bytes()
|
data = (fixtures / "resolved-vod.m3u8").read_bytes()
|
||||||
elif path == "/english.jpeg":
|
elif path == "/english.jpeg":
|
||||||
data = b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n'
|
data = (b'WEBVTT\n\n00:00.000 --> 00:10.000\nAlternate English cue\n' if alternate else
|
||||||
|
b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n')
|
||||||
elif path.startswith("/resolved-vod-") and path.endswith(".ts"):
|
elif path.startswith("/resolved-vod-") and path.endswith(".ts"):
|
||||||
data = (fixtures / path[1:]).read_bytes()
|
data = (fixtures / path[1:]).read_bytes()
|
||||||
else:
|
else:
|
||||||
@@ -71,6 +76,7 @@ try:
|
|||||||
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
|
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
|
||||||
assert not errors, errors
|
assert not errors, errors
|
||||||
assert {"/master.m3u8", "/variant.m3u8", "/english.jpeg"}.issubset(seen), "master, nested playlist and external VTT fetched"
|
assert {"/master.m3u8", "/variant.m3u8", "/english.jpeg"}.issubset(seen), "master, nested playlist and external VTT fetched"
|
||||||
|
assert {"/alternate/master.m3u8", "/alternate/english.jpeg"}.issubset(seen), "alternate video host and independent caption provider fetched"
|
||||||
assert any(path.endswith(".ts") for path in seen), "media segment fetched"
|
assert any(path.endswith(".ts") for path in seen), "media segment fetched"
|
||||||
print("PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
|
print("PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+115
-10
@@ -37,7 +37,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
guard case .miruro(let mediaID, let episode) = try StreamURL.parse(input) else {
|
guard case .miruro(let mediaID, let episode) = try StreamURL.parse(input) else {
|
||||||
fatalError("FAIL: URL smoke requires a Miruro episode")
|
fatalError("FAIL: URL smoke requires a Miruro episode")
|
||||||
}
|
}
|
||||||
source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
|
source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode).select().source
|
||||||
} else {
|
} else {
|
||||||
source = try ResolvedSource.load(from: file)
|
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://") { try await segmentSkips(model, clock: clock, file: file) }
|
||||||
if !input.hasPrefix("https://") {
|
if !input.hasPrefix("https://") {
|
||||||
model.shutdown()
|
model.shutdown()
|
||||||
|
try await providerLifecycle(source: source)
|
||||||
try await queueMetadataLifecycle(source: source)
|
try await queueMetadataLifecycle(source: source)
|
||||||
try await streamQueueLifecycle(source: source)
|
try await streamQueueLifecycle(source: source)
|
||||||
try await enqueueLifecycle(source: source)
|
try await enqueueLifecycle(source: source)
|
||||||
@@ -113,6 +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 {
|
static func queueMetadataLifecycle(source: ResolvedSource) async throws {
|
||||||
let suite = "terebi.queue-metadata-check." + UUID().uuidString
|
let suite = "terebi.queue-metadata-check." + UUID().uuidString
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
@@ -263,7 +369,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
return video
|
return video
|
||||||
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in
|
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in
|
||||||
episodeRequests += 1
|
episodeRequests += 1
|
||||||
return source
|
return try providerSources(source)
|
||||||
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
require(model.progressInitializationError == nil, "queue report registration")
|
require(model.progressInitializationError == nil, "queue report registration")
|
||||||
@@ -281,7 +387,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
stale = nil
|
stale = nil
|
||||||
// Yield to the canceled resolver; it must not open or redirect its report.
|
// Yield to the canceled resolver; it must not open or redirect its report.
|
||||||
for _ in 0..<10 { await Task.yield() }
|
for _ in 0..<10 { await Task.yield() }
|
||||||
require(model.queue.currentID == secondID && model.fileName == source.title &&
|
require(model.queue.currentID == secondID && !model.miruroVideoProviders.isEmpty &&
|
||||||
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
|
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
|
||||||
model.advanceQueue(-1)
|
model.advanceQueue(-1)
|
||||||
try await until("previous resolves YouTube afresh", model: model) {
|
try await until("previous resolves YouTube afresh", model: model) {
|
||||||
@@ -297,7 +403,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
require(model.setPlayback(playing: true), "play first stream to natural end")
|
require(model.setPlayback(playing: true), "play first stream to natural end")
|
||||||
try await until("natural end advances and freshly resolves second stream", model: model) {
|
try await until("natural end advances and freshly resolves second stream", model: model) {
|
||||||
model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING &&
|
model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING &&
|
||||||
model.fileName == source.title && episodeRequests == 2
|
!model.miruroVideoProviders.isEmpty && episodeRequests == 2
|
||||||
}
|
}
|
||||||
require(model.setPlayback(playing: false), "pause second stream for restoration")
|
require(model.setPlayback(playing: false), "pause second stream for restoration")
|
||||||
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
@@ -311,7 +417,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
}
|
}
|
||||||
model.advanceQueue(1)
|
model.advanceQueue(1)
|
||||||
try await until("second revisit resumes acknowledged bookmark", model: model) {
|
try await until("second revisit resumes acknowledged bookmark", model: model) {
|
||||||
!model.isBusy && model.fileName == source.title && model.state.status == PLAYER_PLAYING &&
|
!model.isBusy && model.queue.currentID == secondID && model.state.status == PLAYER_PLAYING &&
|
||||||
model.state.position >= 0.7
|
model.state.position >= 0.7
|
||||||
}
|
}
|
||||||
require(youtubeRequests == 3 && episodeRequests == 3, "every revisit resolves fresh transport metadata")
|
require(youtubeRequests == 3 && episodeRequests == 3, "every revisit resolves fresh transport metadata")
|
||||||
@@ -361,7 +467,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
return YouTubeVideo(title: url.absoluteString,
|
return YouTubeVideo(title: url.absoluteString,
|
||||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
||||||
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in episodeRequests += 1; return source },
|
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in episodeRequests += 1; return try providerSources(source) },
|
||||||
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
func deliver(_ items: [(URL, URL)]) throws {
|
func deliver(_ items: [(URL, URL)]) throws {
|
||||||
@@ -415,7 +521,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
}
|
}
|
||||||
require(model.setPlayback(playing: true), "resume original through natural end")
|
require(model.setPlayback(playing: true), "resume original through natural end")
|
||||||
try await until("natural end advances to enqueued Miruro item", model: model) {
|
try await until("natural end advances to enqueued Miruro item", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == source.title &&
|
!model.isBusy && model.state.status == PLAYER_PLAYING && !model.miruroVideoProviders.isEmpty &&
|
||||||
model.queue.currentURL == second
|
model.queue.currentURL == second
|
||||||
}
|
}
|
||||||
model.advanceQueue(1)
|
model.advanceQueue(1)
|
||||||
@@ -424,7 +530,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
}
|
}
|
||||||
model.advanceQueue(-1)
|
model.advanceQueue(-1)
|
||||||
try await until("enqueued Miruro report revisits", model: model) {
|
try await until("enqueued Miruro report revisits", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == source.title
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second
|
||||||
}
|
}
|
||||||
model.advanceQueue(-1)
|
model.advanceQueue(-1)
|
||||||
try await until("original single video remains navigable", model: model) {
|
try await until("original single video remains navigable", model: model) {
|
||||||
@@ -510,7 +616,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
||||||
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
|
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
|
||||||
return video
|
return video
|
||||||
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in source },
|
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in try providerSources(source) },
|
||||||
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
try await model.addStreamToQueue(first.absoluteString)
|
try await model.addStreamToQueue(first.absoluteString)
|
||||||
@@ -527,7 +633,6 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await model.addStreamToQueue(episode.absoluteString)
|
try await model.addStreamToQueue(episode.absoluteString)
|
||||||
require(model.queue.items.map(\.url) == [first, second, episode] &&
|
require(model.queue.items.map(\.url) == [first, second, episode] &&
|
||||||
model.queue.items[1].displayName == "Sidebar second" &&
|
model.queue.items[1].displayName == "Sidebar second" &&
|
||||||
model.queue.items[2].displayName == source.title &&
|
|
||||||
model.queue.currentID == selected && model.state.revision == revision &&
|
model.queue.currentID == selected && model.state.revision == revision &&
|
||||||
model.state.position == position && model.state.status == PLAYER_PAUSED,
|
model.state.position == position && model.state.status == PLAYER_PAUSED,
|
||||||
"fetched YouTube and Miruro additions preserve paused playback, order and titles")
|
"fetched YouTube and Miruro additions preserve paused playback, order and titles")
|
||||||
|
|||||||
@@ -40,7 +40,17 @@ struct StreamResolverChecks {
|
|||||||
throw Failure(message: "Accepted \(message)")
|
throw Failure(message: "Accepted \(message)")
|
||||||
}
|
}
|
||||||
static func project(_ json: String) throws -> ResolvedSource {
|
static func project(_ json: String) throws -> ResolvedSource {
|
||||||
try MiruroResolver.decode(Data(json.utf8), mediaID: mediaID, episode: 1)
|
try MiruroResolver.decode(Data(json.utf8), mediaID: mediaID, episode: 1).select().source
|
||||||
|
}
|
||||||
|
static func catalog(_ document: [String: Any]) throws -> MiruroSources {
|
||||||
|
try MiruroResolver.decode(JSONSerialization.data(withJSONObject: document), mediaID: mediaID, episode: 1)
|
||||||
|
}
|
||||||
|
static func provider(_ name: String, captions: Bool = true) -> [String: Any] {
|
||||||
|
["provider": name,
|
||||||
|
"servers": [["headers": ["Referer": "https://\(name).example/"],
|
||||||
|
"streams": [["url": "https://\(name).example/master.m3u8", "format": "hls"]]]],
|
||||||
|
"subtitles": captions ? [["file": "https://\(name).example/english.vtt", "format": "vtt",
|
||||||
|
"language": "en", "label": "English", "default": true]] : []]
|
||||||
}
|
}
|
||||||
|
|
||||||
static func checks() async throws {
|
static func checks() async throws {
|
||||||
@@ -84,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.count == 2 && source.subtitles[0].isDefault, "only external VTT retained with default")
|
||||||
try require(source.subtitles[0].url.hasSuffix(".jpeg") && source.subtitles[1].label == "Spanish (Latin America)", "subtitle format and labels independent of file extension")
|
try require(source.subtitles[0].url.hasSuffix(".jpeg") && source.subtitles[1].label == "Spanish (Latin America)", "subtitle format and labels independent of file extension")
|
||||||
try require(source.subtitles.allSatisfy { $0.headers == source.headers }, "verified Sun subtitle headers retained")
|
try require(source.subtitles.allSatisfy { $0.headers == source.headers }, "verified Sun subtitle headers retained")
|
||||||
|
let 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"))
|
let renewed = try project(fixture.replacingOccurrences(of: "token=secret", with: "token=renewed"))
|
||||||
try require(renewed.identity == source.identity && renewed.url != source.url, "token rotation does not change stable identity")
|
try require(renewed.identity == source.identity && renewed.url != source.url, "token rotation does not change stable identity")
|
||||||
for (old, replacement) in [
|
for (old, replacement) in [
|
||||||
@@ -92,14 +175,13 @@ struct StreamResolverChecks {
|
|||||||
("\"enabled\": false", "\"enabled\": true"),
|
("\"enabled\": false", "\"enabled\": true"),
|
||||||
("\"player\": \"native\"", "\"player\": \"iframe\""),
|
("\"player\": \"native\"", "\"player\": \"iframe\""),
|
||||||
("\"track\": \"ssub\"", "\"track\": \"dub\""),
|
("\"track\": \"ssub\"", "\"track\": \"dub\""),
|
||||||
("\"provider\": \"sun\"", "\"provider\": \"bee\""),
|
("\"proxy\": false", "\"proxy\": {\"segments\": true}"),
|
||||||
("\"format\": \"hls\"", "\"format\": \"mp4\""),
|
("\"format\": \"hls\"", "\"format\": \"mp4\""),
|
||||||
("\"embed\": null", "\"embed\": {\"url\": \"https://embed.example/secret\"}"),
|
("\"embed\": null", "\"embed\": {\"url\": \"https://embed.example/secret\"}"),
|
||||||
("https://vault.example/master.m3u8?token=secret", "file:///secret"),
|
("https://vault.example/master.m3u8?token=secret", "file:///secret"),
|
||||||
("https://vault.example/english.jpeg", "https://user:secret@vault.example/english.jpeg"),
|
("\"embed\": null", "\"embed\": false"),
|
||||||
("https://ultracloud.cc/\"", "https://ultracloud.cc/\\r\\nX-Injected: secret\""),
|
("https://ultracloud.cc/\"", "https://ultracloud.cc/\\r\\nX-Injected: secret\""),
|
||||||
("\"Origin\":", "\"origin\": \"secret\", \"Origin\":"),
|
("\"Origin\":", "\"origin\": \"secret\", \"Origin\":")
|
||||||
("\"default\": false", "\"default\": true")
|
|
||||||
] {
|
] {
|
||||||
try reject("unsafe/unsupported source projection") {
|
try reject("unsafe/unsupported source projection") {
|
||||||
_ = try project(fixture.replacingOccurrences(of: old, with: replacement))
|
_ = try project(fixture.replacingOccurrences(of: old, with: replacement))
|
||||||
@@ -122,7 +204,7 @@ struct StreamResolverChecks {
|
|||||||
_ = try await cancelled.value
|
_ = try await cancelled.value
|
||||||
throw Failure(message: "Cancelled resolution was accepted")
|
throw Failure(message: "Cancelled resolution was accepted")
|
||||||
} catch is CancellationError {}
|
} catch is CancellationError {}
|
||||||
print("PASS: streaming URL security, Sun projection, shared validation, bounded responses and cancellation")
|
print("PASS: streaming URL security, runtime native providers, independent captions, bounded responses and cancellation")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func main() async {
|
static func main() async {
|
||||||
@@ -132,12 +214,14 @@ struct StreamResolverChecks {
|
|||||||
guard case .miruro(let id, let episode) = try StreamURL.parse(args[1]) else {
|
guard case .miruro(let id, let episode) = try StreamURL.parse(args[1]) else {
|
||||||
throw Failure(message: "--live requires a Miruro episode URL")
|
throw Failure(message: "--live requires a Miruro episode URL")
|
||||||
}
|
}
|
||||||
let source = try await MiruroResolver.resolve(mediaID: id, episode: episode)
|
let sources = try await MiruroResolver.resolve(mediaID: id, episode: episode)
|
||||||
print("PASS: live \(source.identity); \(source.subtitles.count) VTT tracks")
|
let selection = try sources.select()
|
||||||
|
print("PASS: live video=\(selection.videoProvider); captions=\(selection.captionProvider ?? "none"); \(sources.videoProviders.count) hosts; \(sources.captionProviders.count) caption providers; \(selection.source.subtitles.count) VTT tracks")
|
||||||
} else if args.count == 2, args[0] == "--fixture" {
|
} else if args.count == 2, args[0] == "--fixture" {
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: args[1]))
|
let data = try Data(contentsOf: URL(fileURLWithPath: args[1]))
|
||||||
let source = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1)
|
let sources = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1)
|
||||||
print("PASS: fixture \(source.identity); \(source.subtitles.count) VTT tracks")
|
let selection = try sources.select()
|
||||||
|
print("PASS: fixture video=\(selection.videoProvider); captions=\(selection.captionProvider ?? "none"); \(sources.videoProviders.count) hosts; \(sources.captionProviders.count) caption providers; \(selection.source.subtitles.count) VTT tracks")
|
||||||
} else if args.isEmpty {
|
} else if args.isEmpty {
|
||||||
try await checks()
|
try await checks()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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.
|
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
|
## Limitations
|
||||||
|
|
||||||
- SDR output only; HDR is not supported.
|
- 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
|
@MainActor
|
||||||
struct PlayerCommands: Commands {
|
struct PlayerCommands: Commands {
|
||||||
@FocusedObject private var focusedModel: PlayerModel?
|
@FocusedObject private var focusedModel: PlayerModel?
|
||||||
@@ -275,6 +310,7 @@ struct PlayerCommands: Commands {
|
|||||||
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
|
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
|
||||||
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
||||||
model.performMenuAction(bufferInfo)
|
model.performMenuAction(bufferInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
enum MiruroResolver {
|
||||||
static let bodyLimit = 2 * 1024 * 1024
|
static let bodyLimit = 2 * 1024 * 1024
|
||||||
|
|
||||||
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource {
|
static func resolve(mediaID: String, episode: Int) async throws -> MiruroSources {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
|
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
|
||||||
throw StreamResolutionError(message: "Invalid Miruro episode identity.")
|
throw StreamResolutionError(message: "Invalid Miruro episode identity.")
|
||||||
@@ -100,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 -> MiruroSources {
|
||||||
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> ResolvedSource {
|
|
||||||
do {
|
do {
|
||||||
guard data.count <= bodyLimit,
|
guard data.count <= bodyLimit,
|
||||||
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
|
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
|
||||||
let response = try JSONDecoder().decode(Sources.self, from: data)
|
let response = try JSONDecoder().decode(Sources.self, from: data)
|
||||||
guard response.episodeNumber == episode else { throw InvalidProjection() }
|
guard response.episodeNumber == episode else { throw InvalidProjection() }
|
||||||
let config = response.config.sun
|
var videos: [MiruroSources.Candidate] = []
|
||||||
guard config.player == "native", !config.proxy,
|
var captions: [MiruroSources.Candidate] = []
|
||||||
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else {
|
var videoNames = Set<String>()
|
||||||
throw StreamResolutionError(message: "Miruro Sun requires unsupported proxy or HLS rewriting.")
|
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 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" })?
|
// API order is advisory; retain response order for names omitted from it.
|
||||||
.providers.first(where: { $0.provider == "sun" }),
|
let order = response.providerOrder ?? []
|
||||||
let server = provider.servers.first(where: { !$0.streams.isEmpty }),
|
func ordered(_ candidates: [MiruroSources.Candidate]) -> [MiruroSources.Candidate] {
|
||||||
let stream = server.streams.first else {
|
var remaining = Dictionary(uniqueKeysWithValues: candidates.map { ($0.provider, $0) })
|
||||||
throw StreamResolutionError(message: "Direct Miruro Sun soft-subtitle source is unavailable.")
|
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),
|
let sources = MiruroSources(videos: ordered(videos), captions: ordered(captions))
|
||||||
!stream.hasEmbed else { throw InvalidProjection() }
|
_ = try sources.select()
|
||||||
let headers = server.headers ?? [:]
|
return sources
|
||||||
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))
|
|
||||||
} catch {
|
} catch {
|
||||||
if let error = error as? StreamResolutionError { throw error }
|
if let error = error as? StreamResolutionError { throw error }
|
||||||
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
|
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func validProviderName(_ name: String) -> Bool {
|
||||||
|
!name.isEmpty && name.utf8.count <= 128 && name.utf8.allSatisfy {
|
||||||
|
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct InvalidProjection: Error {}
|
private struct InvalidProjection: Error {}
|
||||||
|
// A malformed entry must not discard valid neighboring providers, servers or streams.
|
||||||
|
private struct Entry<Value: Decodable>: Decodable {
|
||||||
|
let value: Value?
|
||||||
|
init(from decoder: Decoder) throws { value = try? Value(from: decoder) }
|
||||||
|
}
|
||||||
private struct Sources: Decodable {
|
private struct Sources: Decodable {
|
||||||
let episodeNumber: Int
|
let episodeNumber: Int
|
||||||
let tracks: [Track]
|
let tracks: [Entry<Track>]
|
||||||
let config: Config
|
let config: [String: Entry<ProviderConfig>]
|
||||||
|
let providerOrder: [String]?
|
||||||
}
|
}
|
||||||
private struct Config: Decodable { let sun: ProviderConfig }
|
|
||||||
private struct ProviderConfig: Decodable {
|
private struct ProviderConfig: Decodable {
|
||||||
let player: String
|
let player: String
|
||||||
|
// Objects describe proxy work this native player cannot perform.
|
||||||
let proxy: Bool
|
let proxy: Bool
|
||||||
let hls: HLS?
|
let hls: HLS?
|
||||||
}
|
}
|
||||||
@@ -155,16 +242,28 @@ enum MiruroResolver {
|
|||||||
}
|
}
|
||||||
private struct Track: Decodable {
|
private struct Track: Decodable {
|
||||||
let track: String
|
let track: String
|
||||||
let providers: [Provider]
|
let providers: [Entry<Provider>]
|
||||||
}
|
}
|
||||||
private struct Provider: Decodable {
|
private struct Provider: Decodable {
|
||||||
let provider: String
|
let provider: String
|
||||||
let servers: [Server]
|
let servers: [Entry<Server>]
|
||||||
let subtitles: [Subtitle]
|
let subtitles: [Entry<Subtitle>]
|
||||||
}
|
}
|
||||||
private struct Server: Decodable {
|
private struct Server: Decodable {
|
||||||
let headers: [String: String]?
|
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 {
|
private struct Stream: Decodable {
|
||||||
let url: String
|
let url: String
|
||||||
|
|||||||
+90
-19
@@ -121,6 +121,20 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Published private(set) var youtubeQuality: Int?
|
@Published private(set) var youtubeQuality: Int?
|
||||||
@Published private(set) var automaticYouTubeQuality = false
|
@Published private(set) var automaticYouTubeQuality = false
|
||||||
@Published private(set) var showsBufferInfo = false
|
@Published private(set) var showsBufferInfo = false
|
||||||
|
@Published private(set) var miruroVideoProvider: String
|
||||||
|
@Published private(set) var miruroCaptionProvider: String
|
||||||
|
@Published private(set) var selectedMiruroVideoProvider: String?
|
||||||
|
@Published private(set) var selectedMiruroCaptionProvider: String?
|
||||||
|
private var 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 subtitleTracks: [ResolvedSubtitle] = []
|
||||||
@Published private(set) var selectedSubtitle: Int?
|
@Published private(set) var selectedSubtitle: Int?
|
||||||
@Published private(set) var subtitleText = ""
|
@Published private(set) var subtitleText = ""
|
||||||
@@ -188,7 +202,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
private var reportingErrorBuffer = [CChar]()
|
private var reportingErrorBuffer = [CChar]()
|
||||||
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
|
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
|
||||||
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
|
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
|
||||||
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource
|
private let miruroResolver: @MainActor (String, Int) async throws -> MiruroSources
|
||||||
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
|
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
|
||||||
private var reportingErrorMessage: String {
|
private var reportingErrorMessage: String {
|
||||||
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
|
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
|
||||||
@@ -198,7 +212,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
|
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
|
||||||
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
|
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
|
||||||
youtubeTitleResolver: @escaping @MainActor (URL) async throws -> String? = { try await YouTubeResolver.resolveTitle($0) },
|
youtubeTitleResolver: @escaping @MainActor (URL) async throws -> String? = { try await YouTubeResolver.resolveTitle($0) },
|
||||||
miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
|
miruroResolver: @escaping @MainActor (String, Int) async throws -> MiruroSources = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
|
||||||
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
|
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
|
||||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
||||||
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||||
@@ -212,6 +226,8 @@ final class PlayerModel: ObservableObject {
|
|||||||
compactTitleProgress = configuration.compactTitleProgress
|
compactTitleProgress = configuration.compactTitleProgress
|
||||||
automaticYouTubeQuality = configuration.automaticQuality
|
automaticYouTubeQuality = configuration.automaticQuality
|
||||||
showsBufferInfo = configuration.showBufferInfo
|
showsBufferInfo = configuration.showBufferInfo
|
||||||
|
miruroVideoProvider = configuration.miruroVideoProvider
|
||||||
|
miruroCaptionProvider = configuration.miruroCaptionProvider
|
||||||
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
|
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
|
||||||
playbackProgress = PlaybackProgress(defaults: defaults)
|
playbackProgress = PlaybackProgress(defaults: defaults)
|
||||||
recentMedia = playbackProgress.recentMedia
|
recentMedia = playbackProgress.recentMedia
|
||||||
@@ -404,11 +420,13 @@ final class PlayerModel: ObservableObject {
|
|||||||
title = video.title
|
title = video.title
|
||||||
source = .youtube(video, height: nil)
|
source = .youtube(video, height: nil)
|
||||||
case .miruro(let mediaID, let episode):
|
case .miruro(let mediaID, let episode):
|
||||||
let resolved = try await miruroResolver(mediaID, episode)
|
let providers = try await miruroResolver(mediaID, episode)
|
||||||
|
let resolved = try providers.select(videoProvider: miruroVideoProvider,
|
||||||
|
captionProvider: miruroCaptionProvider).source
|
||||||
guard resolved.identity == "miruro:\(mediaID):\(episode)" else { throw CocoaError(.fileReadCorruptFile) }
|
guard resolved.identity == "miruro:\(mediaID):\(episode)" else { throw CocoaError(.fileReadCorruptFile) }
|
||||||
url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
|
url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
|
||||||
title = resolved.title
|
title = resolved.title
|
||||||
source = .resolved(resolved)
|
source = .resolved(resolved, providers: providers)
|
||||||
}
|
}
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
guard canOpen else { throw CancellationError() }
|
guard canOpen else { throw CancellationError() }
|
||||||
@@ -578,6 +596,38 @@ final class PlayerModel: ObservableObject {
|
|||||||
resolveYouTube(url, height: height, change: change)
|
resolveYouTube(url, height: height, change: change)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setMiruroVideoProvider(_ provider: String) {
|
||||||
|
guard canChangeMiruroProvider, let handle,
|
||||||
|
provider.isEmpty || miruroVideoProviders.contains(provider),
|
||||||
|
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) {
|
private func resolveMiruro(mediaID: String, episode: Int, change: MediaChange = .open) {
|
||||||
guard let handle else { return }
|
guard let handle else { return }
|
||||||
if castDevice != nil { stopCasting(resume: false) }
|
if castDevice != nil { stopCasting(resume: false) }
|
||||||
@@ -594,22 +644,25 @@ final class PlayerModel: ObservableObject {
|
|||||||
commandError = nil
|
commandError = nil
|
||||||
revealControls()
|
revealControls()
|
||||||
let resolver = miruroResolver
|
let resolver = miruroResolver
|
||||||
|
let videoProvider = miruroVideoProvider
|
||||||
|
let captionProvider = miruroCaptionProvider
|
||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
do {
|
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 {
|
guard source.identity == "miruro:\(mediaID):\(episode)" else {
|
||||||
throw CocoaError(.fileReadCorruptFile)
|
throw CocoaError(.fileReadCorruptFile)
|
||||||
}
|
}
|
||||||
guard !Task.isCancelled, let self else { return }
|
guard !Task.isCancelled, let self else { return }
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
guard let change = self.transition.finishResolution(request) else { return }
|
guard let change = self.transition.finishResolution(request) else { return }
|
||||||
self.openMedia(.resolved(source), change: change)
|
self.openMedia(.resolved(source, providers: providers), change: change)
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled, let self else { return }
|
guard !Task.isCancelled, let self else { return }
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
guard self.transition.finishResolution(request) != nil else { return }
|
guard self.transition.finishResolution(request) != nil else { return }
|
||||||
self.commandError = error.localizedDescription
|
self.failStreamChange(change, error: error)
|
||||||
self.revealControls()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
transition.attachResolution(task, request: request)
|
transition.attachResolution(task, request: request)
|
||||||
@@ -652,7 +705,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
guard !Task.isCancelled, let self else { return }
|
guard !Task.isCancelled, let self else { return }
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
guard let change = self.transition.finishResolution(request) else { return }
|
guard let change = self.transition.finishResolution(request) else { return }
|
||||||
self.failYouTubeChange(change, error: error)
|
self.failStreamChange(change, error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
transition.attachResolution(task, request: request)
|
transition.attachResolution(task, request: request)
|
||||||
@@ -664,7 +717,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
transition.cancelResolution()
|
transition.cancelResolution()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func failYouTubeChange(_ change: MediaChange, error: Error? = nil) {
|
private func failStreamChange(_ change: MediaChange, error: Error? = nil) {
|
||||||
if change.automatic {
|
if change.automatic {
|
||||||
automaticQuality.extractionFailed(at: qualityNow)
|
automaticQuality.extractionFailed(at: qualityNow)
|
||||||
return
|
return
|
||||||
@@ -791,7 +844,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .resolved(let source):
|
case .resolved(let source, _):
|
||||||
accepted = source.identity.withCString { identity in
|
accepted = source.identity.withCString { identity in
|
||||||
source.url.withCString { url in
|
source.url.withCString { url in
|
||||||
source.headers.withCString { headers in
|
source.headers.withCString { headers in
|
||||||
@@ -808,9 +861,9 @@ final class PlayerModel: ObservableObject {
|
|||||||
case .file:
|
case .file:
|
||||||
commandError = "Could not queue the file to open. Please try again."
|
commandError = "Could not queue the file to open. Please try again."
|
||||||
case .resolved:
|
case .resolved:
|
||||||
commandError = "Could not queue the resolved stream to open."
|
failStreamChange(change, error: CocoaError(.fileReadUnknown))
|
||||||
case .youtube:
|
case .youtube:
|
||||||
failYouTubeChange(change)
|
failStreamChange(change)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -819,6 +872,15 @@ final class PlayerModel: ObservableObject {
|
|||||||
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
|
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
|
||||||
queueCompletionHandled = false
|
queueCompletionHandled = false
|
||||||
currentMedia = source
|
currentMedia = source
|
||||||
|
if case .resolved(_, let providers) = source,
|
||||||
|
let selection = try? providers?.select(videoProvider: miruroVideoProvider,
|
||||||
|
captionProvider: miruroCaptionProvider) {
|
||||||
|
selectedMiruroVideoProvider = selection.videoProvider
|
||||||
|
selectedMiruroCaptionProvider = selection.captionProvider
|
||||||
|
} else {
|
||||||
|
selectedMiruroVideoProvider = nil
|
||||||
|
selectedMiruroCaptionProvider = nil
|
||||||
|
}
|
||||||
shouldRecordRecentMedia = !change.isQuality
|
shouldRecordRecentMedia = !change.isQuality
|
||||||
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
|
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
|
||||||
switch source {
|
switch source {
|
||||||
@@ -834,7 +896,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
youtubeHeights = video.availableHeights
|
youtubeHeights = video.availableHeights
|
||||||
youtubeHeight = video.height
|
youtubeHeight = video.height
|
||||||
youtubeQuality = height
|
youtubeQuality = height
|
||||||
case .resolved(let source):
|
case .resolved(let source, _):
|
||||||
fileName = source.title
|
fileName = source.title
|
||||||
fileIcon = nil
|
fileIcon = nil
|
||||||
youtubeHeights = []
|
youtubeHeights = []
|
||||||
@@ -844,10 +906,16 @@ final class PlayerModel: ObservableObject {
|
|||||||
if let fileName, !source.url.isFileURL, let id = queue.currentID {
|
if let fileName, !source.url.isFileURL, let id = queue.currentID {
|
||||||
queue.updateLabel(fileName, for: id)
|
queue.updateLabel(fileName, for: id)
|
||||||
}
|
}
|
||||||
|
let previousSubtitleLanguage = selectedSubtitle.flatMap {
|
||||||
|
subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil
|
||||||
|
}
|
||||||
selectSubtitle(nil)
|
selectSubtitle(nil)
|
||||||
if case .resolved(let resolved) = source {
|
if case .resolved(let resolved, _) = source {
|
||||||
subtitleTracks = resolved.subtitles
|
subtitleTracks = resolved.subtitles
|
||||||
selectSubtitle(subtitleTracks.firstIndex(where: \.isDefault))
|
let defaultIndex = subtitleTracks.firstIndex(where: \.isDefault)
|
||||||
|
selectSubtitle(change.isQuality ? previousSubtitleLanguage.flatMap { language in
|
||||||
|
subtitleTracks.firstIndex { $0.language == language } ?? defaultIndex
|
||||||
|
} : defaultIndex)
|
||||||
} else {
|
} else {
|
||||||
subtitleTracks = []
|
subtitleTracks = []
|
||||||
}
|
}
|
||||||
@@ -1010,6 +1078,8 @@ final class PlayerModel: ObservableObject {
|
|||||||
queueCompletionHandled = true
|
queueCompletionHandled = true
|
||||||
queueCastDevice = nil
|
queueCastDevice = nil
|
||||||
currentMedia = nil
|
currentMedia = nil
|
||||||
|
selectedMiruroVideoProvider = nil
|
||||||
|
selectedMiruroCaptionProvider = nil
|
||||||
replaceMetadata(for: nil)
|
replaceMetadata(for: nil)
|
||||||
shouldRecordRecentMedia = false
|
shouldRecordRecentMedia = false
|
||||||
selectSubtitle(nil)
|
selectSubtitle(nil)
|
||||||
@@ -1818,7 +1888,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
audioURL: video.audio?.url, audioHeaders: video.audio?.headers,
|
audioURL: video.audio?.url, audioHeaders: video.audio?.headers,
|
||||||
local: false, restricted: false, live: video.isLive,
|
local: false, restricted: false, live: video.isLive,
|
||||||
duration: duration, subtitles: [])
|
duration: duration, subtitles: [])
|
||||||
case .resolved(let resolved):
|
case .resolved(let resolved, _):
|
||||||
return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers,
|
return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers,
|
||||||
audioURL: nil, audioHeaders: nil, local: false, restricted: true,
|
audioURL: nil, audioHeaders: nil, local: false, restricted: true,
|
||||||
live: false, duration: duration, subtitles: resolved.subtitles)
|
live: false, duration: duration, subtitles: resolved.subtitles)
|
||||||
@@ -1860,13 +1930,13 @@ final class PlayerModel: ObservableObject {
|
|||||||
private enum MediaSource {
|
private enum MediaSource {
|
||||||
case file(URL)
|
case file(URL)
|
||||||
case youtube(YouTubeVideo, height: Int?)
|
case youtube(YouTubeVideo, height: Int?)
|
||||||
case resolved(ResolvedSource)
|
case resolved(ResolvedSource, providers: MiruroSources? = nil)
|
||||||
|
|
||||||
var url: URL {
|
var url: URL {
|
||||||
switch self {
|
switch self {
|
||||||
case .file(let url): return url
|
case .file(let url): return url
|
||||||
case .youtube(let video, _): return video.webpageURL
|
case .youtube(let video, _): return video.webpageURL
|
||||||
case .resolved(let source): return URL(string: source.identity)!
|
case .resolved(let source, _): return URL(string: source.identity)!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var live: Bool {
|
var live: Bool {
|
||||||
@@ -2940,6 +3010,7 @@ private struct PlayerContent: View {
|
|||||||
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
|
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
|
||||||
Divider()
|
Divider()
|
||||||
PlayerQualityMenu(model: model)
|
PlayerQualityMenu(model: model)
|
||||||
|
PlayerMiruroMenu(model: model)
|
||||||
Divider()
|
Divider()
|
||||||
if model.isCompact {
|
if model.isCompact {
|
||||||
Menu("Subtitles") {
|
Menu("Subtitles") {
|
||||||
|
|||||||
@@ -10,14 +10,19 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
public var automaticQuality: Bool
|
public var automaticQuality: Bool
|
||||||
public var snapToEdges: Bool
|
public var snapToEdges: Bool
|
||||||
public var compactTitleProgress: CompactTitleProgress
|
public var compactTitleProgress: CompactTitleProgress
|
||||||
|
public var miruroVideoProvider: String
|
||||||
|
public var miruroCaptionProvider: String
|
||||||
public var keys: KeyBindings
|
public var keys: KeyBindings
|
||||||
|
|
||||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
|
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
|
||||||
compactTitleProgress: CompactTitleProgress = .time, keys: KeyBindings = KeyBindings()) {
|
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
|
||||||
|
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
|
||||||
self.showBufferInfo = showBufferInfo
|
self.showBufferInfo = showBufferInfo
|
||||||
self.automaticQuality = automaticQuality
|
self.automaticQuality = automaticQuality
|
||||||
self.snapToEdges = snapToEdges
|
self.snapToEdges = snapToEdges
|
||||||
self.compactTitleProgress = compactTitleProgress
|
self.compactTitleProgress = compactTitleProgress
|
||||||
|
self.miruroVideoProvider = miruroVideoProvider
|
||||||
|
self.miruroCaptionProvider = miruroCaptionProvider
|
||||||
self.keys = keys
|
self.keys = keys
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +33,9 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
var lines = [
|
var lines = [
|
||||||
"[playback]",
|
"[playback]",
|
||||||
"automatic_quality = \(defaults.automaticQuality)",
|
"automatic_quality = \(defaults.automaticQuality)",
|
||||||
|
"", "[miruro]",
|
||||||
|
"video_provider = \"\(defaults.miruroVideoProvider)\"",
|
||||||
|
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
|
||||||
"", "[ui]",
|
"", "[ui]",
|
||||||
"buffer_info = \(defaults.showBufferInfo)",
|
"buffer_info = \(defaults.showBufferInfo)",
|
||||||
"compact_snap_to_edges = \(defaults.snapToEdges)",
|
"compact_snap_to_edges = \(defaults.snapToEdges)",
|
||||||
@@ -85,6 +93,8 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
automaticQuality: values.playback?.automaticQuality ?? false,
|
automaticQuality: values.playback?.automaticQuality ?? false,
|
||||||
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
||||||
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
||||||
|
miruroVideoProvider: values.miruro?.videoProvider ?? "",
|
||||||
|
miruroCaptionProvider: values.miruro?.captionProvider ?? "",
|
||||||
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
|
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
|
||||||
} catch {
|
} catch {
|
||||||
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
|
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
|
||||||
@@ -143,10 +153,11 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
let playback: PlaybackConfiguration?
|
let playback: PlaybackConfiguration?
|
||||||
let ui: UIConfiguration?
|
let ui: UIConfiguration?
|
||||||
let keys: KeysConfiguration?
|
let keys: KeysConfiguration?
|
||||||
|
let miruro: MiruroConfiguration?
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: SettingKey.self)
|
let container = try decoder.container(keyedBy: SettingKey.self)
|
||||||
try validate(container, allowed: ["playback", "ui", "keys"])
|
try validate(container, allowed: ["playback", "ui", "keys", "miruro"])
|
||||||
if container.contains(SettingKey("playback")) {
|
if container.contains(SettingKey("playback")) {
|
||||||
playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback")))
|
playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback")))
|
||||||
} else { playback = nil }
|
} else { playback = nil }
|
||||||
@@ -156,6 +167,9 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
if container.contains(SettingKey("keys")) {
|
if container.contains(SettingKey("keys")) {
|
||||||
keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys")))
|
keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys")))
|
||||||
} else { keys = nil }
|
} else { keys = nil }
|
||||||
|
if container.contains(SettingKey("miruro")) {
|
||||||
|
miruro = try MiruroConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("miruro")))
|
||||||
|
} else { miruro = nil }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +182,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 {
|
private struct UIConfiguration {
|
||||||
let bufferInfo: Bool?
|
let bufferInfo: Bool?
|
||||||
let compactSnapToEdges: 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.
|
- **Tubular:** catalog/detail browsing, actual returned episode selection, collections, private reporting sessions, cleanup and watched-state decisions.
|
||||||
- **Terebi:** episode-URL validation, fresh source resolution, headers/subtitles, titles, optional timeline bands, stable-identity resume and progress reporting.
|
- **Terebi:** episode-URL validation, fresh source resolution, headers/subtitles, titles, optional timeline bands, stable-identity resume and progress reporting.
|
||||||
|
|
||||||
Native source resolution selects direct **`ssub` / `sun`**. Other providers, iframe embedding, cookies, impersonation and protection-bypass machinery are not supported.
|
Native 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
|
## Launch contract
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user