feat: support Miruro provider tracks, proxies, and native embeds
This commit is contained in:
@@ -32,12 +32,13 @@ struct ResolvedPlaybackCheck {
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
let input = CommandLine.arguments[1]
|
||||
let file = URL(fileURLWithPath: input)
|
||||
let preference = CommandLine.arguments.first(where: { $0.hasPrefix("--provider=") }).map { String($0.dropFirst(11)) } ?? ""
|
||||
let source: ResolvedSource
|
||||
if input.hasPrefix("https://") {
|
||||
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).select().source
|
||||
source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode).select(videoProvider: preference).source
|
||||
} else {
|
||||
source = try ResolvedSource.load(from: file)
|
||||
}
|
||||
@@ -49,7 +50,8 @@ struct ResolvedPlaybackCheck {
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let clock = Clock()
|
||||
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil,
|
||||
let report = CommandLine.arguments.count > 2 && !CommandLine.arguments[2].hasPrefix("--") ? CommandLine.arguments[2] : nil
|
||||
let model = PlayerModel(defaults: defaults, configuration: .init(miruroVideoProvider: preference), progressFile: report,
|
||||
miruroMetadataResolver: { id, episode in
|
||||
if input.hasPrefix("https://") {
|
||||
return try await MiruroMetadataResolver.resolve(mediaID: id, episode: episode)
|
||||
@@ -85,7 +87,8 @@ struct ResolvedPlaybackCheck {
|
||||
fatalError("FAIL: smoke requires a default subtitle")
|
||||
}
|
||||
let track = source.subtitles[index]
|
||||
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
||||
let preparedTrack = try await MiruroTransport.subtitle(track)
|
||||
let document = try await WebVTTDocument.load(url: preparedTrack.url, headers: preparedTrack.headers)
|
||||
let playableCues = document.cues.filter { !$0.text.isEmpty && $0.start + 0.1 < model.state.duration }
|
||||
require(playableCues.count >= 2, "smoke requires two visible subtitle cues")
|
||||
let target = playableCues[1].start + min(0.1, (playableCues[1].end - playableCues[1].start) / 2)
|
||||
@@ -105,8 +108,10 @@ struct ResolvedPlaybackCheck {
|
||||
print("PASS: default VTT selected, forward/backward seek and acknowledged cue timing")
|
||||
if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) }
|
||||
if !input.hasPrefix("https://") {
|
||||
try await transportProtocol(source: source)
|
||||
model.shutdown()
|
||||
try await providerLifecycle(source: source)
|
||||
try await dubOnlySelection(source: source)
|
||||
try await queueMetadataLifecycle(source: source)
|
||||
try await streamQueueLifecycle(source: source)
|
||||
try await enqueueLifecycle(source: source)
|
||||
@@ -114,7 +119,35 @@ struct ResolvedPlaybackCheck {
|
||||
}
|
||||
}
|
||||
|
||||
static func providerSources(_ source: ResolvedSource, alternate: Bool = false) throws -> MiruroSources {
|
||||
static func transportProtocol(source: ResolvedSource) async throws {
|
||||
let original = URLComponents(string: source.url)!
|
||||
let base = "\(original.scheme!)://\(original.host!):\(original.port!)"
|
||||
let deployment = MiruroProxyDeployment(base: URL(string: base.replacingOccurrences(of: "127.0.0.1", with: "localhost") + "/")!, key: [1, 2, 3])
|
||||
var input = ResolvedSource(url: base + "/transport-master.m3u8?token=private", headers:
|
||||
"Referer: https://transport.example/\r\nX-Secret: private\r\n",
|
||||
identity: source.identity, title: source.title, subtitles: [])
|
||||
input.requirements = .init(proxy: .init(rotate: false, segments: true),
|
||||
segmentOrigin: base, playlistQuery: ["skip": "fixture"])
|
||||
let prepared = try await MiruroTransport.prepare(input, deployment: deployment)
|
||||
let (master, _) = try await URLSession.shared.data(from: URL(string: prepared.url)!)
|
||||
let nested = String(data: master, encoding: .utf8)!.components(separatedBy: .newlines)
|
||||
.first { $0.hasPrefix("http://") }!
|
||||
let (variant, _) = try await URLSession.shared.data(from: URL(string: nested)!)
|
||||
let lines = String(data: variant, encoding: .utf8)!.components(separatedBy: .newlines)
|
||||
let segment = lines.first { $0.hasPrefix("http://") }!
|
||||
let (body, response) = try await URLSession.shared.data(from: URL(string: segment)!)
|
||||
require((response as? HTTPURLResponse)?.statusCode == 200 && body == Data((0..<256).map(UInt8.init)),
|
||||
"proxied nested playlists decode once; query and segment-origin rules fetch real resource bytes")
|
||||
let keyLine = lines.first { $0.hasPrefix("#EXT-X-KEY:") }!
|
||||
let key = keyLine.components(separatedBy: "\"")[1]
|
||||
let (_, blocked) = try await URLSession.shared.data(from: URL(string: key)!)
|
||||
require((blocked as? HTTPURLResponse)?.statusCode == 502,
|
||||
"manifest key cannot make the relay fetch a foreign loopback destination")
|
||||
withExtendedLifetime(prepared) {}
|
||||
print("PASS: proxy codec, already-proxied nested HLS, query/origin rules, credential isolation and private-destination rejection")
|
||||
}
|
||||
|
||||
static func providerSources(_ source: ResolvedSource, alternate: Bool = false, track: String = "ssub") 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)
|
||||
@@ -140,11 +173,15 @@ struct ResolvedPlaybackCheck {
|
||||
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]
|
||||
let origin = URLComponents(string: source.url)!
|
||||
let segmentOrigin = "\(origin.scheme!)://\(origin.host!):\(origin.port!)"
|
||||
config["alternate"] = ["player": "native", "proxy": false,
|
||||
"hls": ["query": ["enabled": true, "params": ["rewrite": "1"]],
|
||||
"segments": ["enabled": true, "origin": segmentOrigin]]]
|
||||
}
|
||||
return try MiruroResolver.decode(JSONSerialization.data(withJSONObject: [
|
||||
"episodeNumber": 1, "providerOrder": ["sun", "captions", "alternate"], "config": config,
|
||||
"tracks": [["track": "ssub", "providers": providers]]
|
||||
"tracks": [["track": track, "providers": providers]]
|
||||
]), mediaID: "fixture-ID", episode: 1)
|
||||
}
|
||||
|
||||
@@ -171,23 +208,29 @@ struct ResolvedPlaybackCheck {
|
||||
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",
|
||||
require(model.selectedMiruroVideoProvider == "sun:ssub" && model.selectedMiruroCaptionProvider == "captions:ssub",
|
||||
"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")
|
||||
model.setMiruroCaptionProvider("alternate:ssub")
|
||||
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")
|
||||
model.setMiruroVideoProvider("alternate:ssub")
|
||||
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",
|
||||
require(model.selectedMiruroVideoProvider == "alternate:ssub" && model.selectedMiruroCaptionProvider == "alternate:ssub",
|
||||
"host and captions remain independently selected")
|
||||
let preparedRevision = model.state.revision
|
||||
model.setMiruroCaptionProvider("captions:ssub")
|
||||
try await until("caption change retains prepared video transport", model: model) {
|
||||
!model.subtitleText.isEmpty && model.selectedMiruroCaptionProvider == "captions:ssub"
|
||||
}
|
||||
require(model.state.revision == preparedRevision, "prepared video is not reopened for captions")
|
||||
model.selectSubtitle(nil)
|
||||
model.setMiruroCaptionProvider("")
|
||||
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "caption provider respects explicit Off")
|
||||
@@ -196,15 +239,15 @@ struct ResolvedPlaybackCheck {
|
||||
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"
|
||||
model.selectedMiruroVideoProvider == "sun:ssub"
|
||||
}
|
||||
require(model.selectedSubtitle == nil, "host changes preserve explicit subtitle Off")
|
||||
model.setMiruroVideoProvider("alternate")
|
||||
model.setMiruroVideoProvider("alternate:ssub")
|
||||
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" &&
|
||||
require(model.commandError != nil && model.selectedMiruroVideoProvider == "sun:ssub" &&
|
||||
model.state.status == PLAYER_PLAYING && model.canChangeMiruroProvider,
|
||||
"failed host fetch retains previous source and resumes playback")
|
||||
model.setMiruroVideoProvider("")
|
||||
@@ -219,6 +262,27 @@ struct ResolvedPlaybackCheck {
|
||||
print("PASS: provider config fallback, independent caption headers, in-place captions, paused/playing host switches and failure retention")
|
||||
}
|
||||
|
||||
static func dubOnlySelection(source: ResolvedSource) async throws {
|
||||
let providers = try providerSources(source, track: "dub")
|
||||
let suite = "terebi.dub-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults, miruroResolver: { _, _ in providers },
|
||||
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||
defer { model.shutdown() }
|
||||
model.openStream("https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
while model.resolvingStream, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
|
||||
require(model.commandError != nil && model.miruroVideoProviders.contains("sun:dub") && model.canChangeMiruroProvider,
|
||||
"dub-only episode exposes explicit choices without automatic language fallback")
|
||||
model.setMiruroVideoProvider("sun:dub")
|
||||
try await until("explicit dub-only choice decodes", model: model) {
|
||||
model.state.has_video && model.state.position > 0.2 && model.selectedMiruroVideoProvider == "sun:dub"
|
||||
}
|
||||
require(model.selectedMiruroCaptionProvider == "captions:dub", "automatic captions follow explicit dub track")
|
||||
print("PASS: dub-only choices remain accessible and explicit selection plays")
|
||||
}
|
||||
|
||||
static func queueMetadataLifecycle(source: ResolvedSource) async throws {
|
||||
let suite = "terebi.queue-metadata-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
|
||||
Reference in New Issue
Block a user