feat: select Miruro playback and caption providers

This commit is contained in:
2026-10-22 18:45:14 +02:00
parent 4576080955
commit 80d9e705e5
10 changed files with 561 additions and 76 deletions
+115 -10
View File
@@ -37,7 +37,7 @@ struct ResolvedPlaybackCheck {
guard case .miruro(let mediaID, let episode) = try StreamURL.parse(input) else {
fatalError("FAIL: URL smoke requires a Miruro episode")
}
source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode).select().source
} else {
source = try ResolvedSource.load(from: file)
}
@@ -106,6 +106,7 @@ struct ResolvedPlaybackCheck {
if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) }
if !input.hasPrefix("https://") {
model.shutdown()
try await providerLifecycle(source: source)
try await queueMetadataLifecycle(source: source)
try await streamQueueLifecycle(source: source)
try await enqueueLifecycle(source: source)
@@ -113,6 +114,111 @@ struct ResolvedPlaybackCheck {
}
}
static func providerSources(_ source: ResolvedSource, alternate: Bool = false) throws -> MiruroSources {
func headers(_ text: String) -> [String: String] {
Dictionary(uniqueKeysWithValues: text.components(separatedBy: "\r\n").filter { !$0.isEmpty }.map {
let parts = $0.split(separator: ":", maxSplits: 1)
return (String(parts[0]), String(parts[1]).trimmingCharacters(in: .whitespaces))
})
}
func provider(_ name: String, url: String, header: [String: String], subtitles: [[String: Any]]) -> [String: Any] {
["provider": name, "servers": [["headers": header, "streams": [["url": url, "format": "hls"]]]],
"subtitles": subtitles]
}
let subtitles: [[String: Any]] = source.subtitles.map {
["file": $0.url, "format": $0.format, "language": $0.language, "label": $0.label, "default": $0.isDefault]
}
// Source-file fixtures allow independent headers; Miruro returns each set on its own provider.
let video = provider("sun", url: source.url, header: headers(source.headers), subtitles: [])
let captions = provider("captions", url: source.url.replacingOccurrences(of: "/master.m3u8", with: "/captions/master.m3u8"),
header: headers(source.subtitles[0].headers), subtitles: subtitles)
var providers = [video, captions]
var config: [String: Any] = ["sun": ["player": "native", "proxy": false],
"captions": ["player": "native", "proxy": false]]
if alternate {
providers.append(provider("alternate", url: source.url.replacingOccurrences(of: "/master.m3u8", with: "/alternate/master.m3u8"),
header: ["X-Resource": "alternate-private"],
subtitles: [["file": source.subtitles[0].url.replacingOccurrences(of: "/english.jpeg", with: "/alternate/english.jpeg"),
"format": "vtt", "language": "en", "label": "Alternate English", "default": true]]))
config["alternate"] = ["player": "native", "proxy": false]
}
return try MiruroResolver.decode(JSONSerialization.data(withJSONObject: [
"episodeNumber": 1, "providerOrder": ["sun", "captions", "alternate"], "config": config,
"tracks": [["track": "ssub", "providers": providers]]
]), mediaID: "fixture-ID", episode: 1)
}
static func providerLifecycle(source: ResolvedSource) async throws {
let providers = try providerSources(source, alternate: true)
let suite = "terebi.provider-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var requests = 0
var pending: CheckedContinuation<MiruroSources, Never>?
var completed = 0
let model = PlayerModel(defaults: defaults,
configuration: .init(miruroVideoProvider: "unavailable", miruroCaptionProvider: "captions"),
miruroResolver: { _, _ in
requests += 1
defer { completed += 1 }
if requests == 4 { throw CocoaError(.fileReadCorruptFile) }
if requests == 5 { return await withCheckedContinuation { pending = $0 } }
return providers
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() }
let url = "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1"
model.openStream(url)
try await until("configured missing host falls back with independent captions", model: model) {
model.canChangeMiruroProvider && model.state.position > 0.2 && !model.subtitleText.isEmpty
}
require(model.selectedMiruroVideoProvider == "sun" && model.selectedMiruroCaptionProvider == "captions",
"startup preferences select independent providers with host fallback")
require(model.setPlayback(playing: false), "pause before provider switches")
try await until("provider pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
let position = model.state.position
let revision = model.state.revision
model.setMiruroCaptionProvider("alternate")
try await until("alternate provider's own VTT is loaded", model: model) { model.subtitleText == "Alternate English cue" }
require(model.state.revision == revision && model.state.position == position && requests == 1,
"caption selection does not reopen video, seek or refetch sources")
model.setMiruroVideoProvider("alternate")
try await until("alternate host preserves paused position", model: model) {
model.canChangeMiruroProvider && model.state.status == PLAYER_PAUSED &&
model.state.revision != revision && abs(model.state.position - position) < 0.2
}
require(model.selectedMiruroVideoProvider == "alternate" && model.selectedMiruroCaptionProvider == "alternate",
"host and captions remain independently selected")
model.selectSubtitle(nil)
model.setMiruroCaptionProvider("")
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "caption provider respects explicit Off")
require(model.setPlayback(playing: true), "resume before automatic host choice")
try await until("playback resumes before host switch", model: model) { model.state.status == PLAYER_PLAYING }
model.setMiruroVideoProvider("")
try await until("automatic host returns to Sun and preserves playing intent", model: model) {
model.canChangeMiruroProvider && model.state.status == PLAYER_PLAYING &&
model.selectedMiruroVideoProvider == "sun"
}
require(model.selectedSubtitle == nil, "host changes preserve explicit subtitle Off")
model.setMiruroVideoProvider("alternate")
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while (model.resolvingStream || model.state.status != PLAYER_PLAYING), ContinuousClock.now < deadline {
try await Task.sleep(for: .milliseconds(10))
}
require(model.commandError != nil && model.selectedMiruroVideoProvider == "sun" &&
model.state.status == PLAYER_PLAYING && model.canChangeMiruroProvider,
"failed host fetch retains previous source and resumes playback")
model.setMiruroVideoProvider("")
try await until("replacement provider request is held", model: model) { pending != nil }
model.closeFile()
pending!.resume(returning: providers)
try await until("closed player discards late provider results", model: model) {
completed == requests && model.state.status == PLAYER_EMPTY
}
require(model.miruroVideoProviders.isEmpty && model.selectedMiruroCaptionProvider == nil,
"closing clears episode provider state")
print("PASS: provider config fallback, independent caption headers, in-place captions, paused/playing host switches and failure retention")
}
static func queueMetadataLifecycle(source: ResolvedSource) async throws {
let suite = "terebi.queue-metadata-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
@@ -263,7 +369,7 @@ struct ResolvedPlaybackCheck {
return video
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in
episodeRequests += 1
return source
return try providerSources(source)
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() }
require(model.progressInitializationError == nil, "queue report registration")
@@ -281,7 +387,7 @@ struct ResolvedPlaybackCheck {
stale = nil
// Yield to the canceled resolver; it must not open or redirect its report.
for _ in 0..<10 { await Task.yield() }
require(model.queue.currentID == secondID && model.fileName == source.title &&
require(model.queue.currentID == secondID && !model.miruroVideoProviders.isEmpty &&
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
model.advanceQueue(-1)
try await until("previous resolves YouTube afresh", model: model) {
@@ -297,7 +403,7 @@ struct ResolvedPlaybackCheck {
require(model.setPlayback(playing: true), "play first stream to natural end")
try await until("natural end advances and freshly resolves second stream", model: model) {
model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING &&
model.fileName == source.title && episodeRequests == 2
!model.miruroVideoProviders.isEmpty && episodeRequests == 2
}
require(model.setPlayback(playing: false), "pause second stream for restoration")
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
@@ -311,7 +417,7 @@ struct ResolvedPlaybackCheck {
}
model.advanceQueue(1)
try await until("second revisit resumes acknowledged bookmark", model: model) {
!model.isBusy && model.fileName == source.title && model.state.status == PLAYER_PLAYING &&
!model.isBusy && model.queue.currentID == secondID && model.state.status == PLAYER_PLAYING &&
model.state.position >= 0.7
}
require(youtubeRequests == 3 && episodeRequests == 3, "every revisit resolves fresh transport metadata")
@@ -361,7 +467,7 @@ struct ResolvedPlaybackCheck {
return YouTubeVideo(title: url.absoluteString,
video: .init(url: source.url, headers: source.headers), audio: nil,
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in episodeRequests += 1; return source },
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in episodeRequests += 1; return try providerSources(source) },
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() }
func deliver(_ items: [(URL, URL)]) throws {
@@ -415,7 +521,7 @@ struct ResolvedPlaybackCheck {
}
require(model.setPlayback(playing: true), "resume original through natural end")
try await until("natural end advances to enqueued Miruro item", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == source.title &&
!model.isBusy && model.state.status == PLAYER_PLAYING && !model.miruroVideoProviders.isEmpty &&
model.queue.currentURL == second
}
model.advanceQueue(1)
@@ -424,7 +530,7 @@ struct ResolvedPlaybackCheck {
}
model.advanceQueue(-1)
try await until("enqueued Miruro report revisits", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == source.title
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second
}
model.advanceQueue(-1)
try await until("original single video remains navigable", model: model) {
@@ -510,7 +616,7 @@ struct ResolvedPlaybackCheck {
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
return video
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in source },
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in try providerSources(source) },
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
defer { model.shutdown() }
try await model.addStreamToQueue(first.absoluteString)
@@ -527,7 +633,6 @@ struct ResolvedPlaybackCheck {
try await model.addStreamToQueue(episode.absoluteString)
require(model.queue.items.map(\.url) == [first, second, episode] &&
model.queue.items[1].displayName == "Sidebar second" &&
model.queue.items[2].displayName == source.title &&
model.queue.currentID == selected && model.state.revision == revision &&
model.state.position == position && model.state.status == PLAYER_PAUSED,
"fetched YouTube and Miruro additions preserve paused playback, order and titles")