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