feat: open streaming URLs without changing resolved handoffs
This commit is contained in:
@@ -191,7 +191,7 @@ private enum WindowDragCheck {
|
||||
model.showsQueue = true
|
||||
case 13:
|
||||
model.showsQueue = false
|
||||
model.openYouTube("not a video URL")
|
||||
model.openStream("not a video URL")
|
||||
case 15: model.open(fixtures.appendingPathComponent("audio.wav"))
|
||||
case 16: model.open(fixtures.appendingPathComponent("cache.mp4"))
|
||||
case 19: model.hideControls(hideCursor: false)
|
||||
@@ -787,7 +787,7 @@ private enum CLIInstanceCheck {
|
||||
if let action = command["action"] as? String {
|
||||
switch action {
|
||||
case "youtube":
|
||||
model.openYouTube(command["url"] as! String)
|
||||
model.openStream(command["url"] as! String)
|
||||
case "queue":
|
||||
model.showsQueue = command["visible"] as! Bool
|
||||
model.revealControls()
|
||||
|
||||
@@ -59,9 +59,9 @@ struct AutomaticQualityPlaybackChecks {
|
||||
let normalFrame = window.frame
|
||||
let compactKey = try! KeyChord("p")
|
||||
mark("gate-open")
|
||||
model.openYouTube("https://www.youtube.com/watch?v=aqz-KE-bpKQ")
|
||||
model.openStream("https://www.youtube.com/watch?v=aqz-KE-bpKQ")
|
||||
until(model, "initial YouTube resolution reaches the controlled extraction gate") {
|
||||
model.resolvingYouTube && marked("entered-open")
|
||||
model.resolvingStream && marked("entered-open")
|
||||
}
|
||||
model.handleKey(compactKey)
|
||||
require(model.isCompact, "compact shortcut enters while the YouTube URL is unresolved")
|
||||
@@ -74,7 +74,7 @@ struct AutomaticQualityPlaybackChecks {
|
||||
until(model, "compact shortcut restores while YouTube resolution is still pending") {
|
||||
!model.isCompact && !model.compactWindow.isTransitioning
|
||||
}
|
||||
require(window.frame == normalFrame && model.resolvingYouTube,
|
||||
require(window.frame == normalFrame && model.resolvingStream,
|
||||
"resolution continues while restoring the original normal window")
|
||||
require(model.perform(.enterCompact), "compact control action is available during resolution")
|
||||
mark("release-open")
|
||||
@@ -98,7 +98,7 @@ struct AutomaticQualityPlaybackChecks {
|
||||
}
|
||||
clock.now = 2000
|
||||
polled(model)
|
||||
require(model.youtubeHeight == 1080 && !model.resolvingYouTube,
|
||||
require(model.youtubeHeight == 1080 && !model.resolvingStream,
|
||||
"disabled automatic quality leaves a stalled manual/default stream alone")
|
||||
model.setAutomaticYouTubeQuality(true)
|
||||
polled(model)
|
||||
@@ -146,12 +146,12 @@ struct AutomaticQualityPlaybackChecks {
|
||||
}
|
||||
clock.now = 3060
|
||||
until(model, "automatic upshift reaches the selected extraction gate") {
|
||||
marked("entered-1080") && model.resolvingYouTube
|
||||
marked("entered-1080") && model.resolvingStream
|
||||
}
|
||||
require(model.setPlayback(playing: false), "pause is accepted during automatic extraction")
|
||||
mark("release-1080")
|
||||
until(model, "cancelled extraction finishes and native pause is acknowledged") {
|
||||
marked("finished-1080") && !model.resolvingYouTube && model.canTogglePlayback &&
|
||||
marked("finished-1080") && !model.resolvingStream && model.canTogglePlayback &&
|
||||
model.state.status == PLAYER_PAUSED && !model.state.seek_pending
|
||||
}
|
||||
let cancelledPosition = model.state.position
|
||||
@@ -179,7 +179,7 @@ struct AutomaticQualityPlaybackChecks {
|
||||
let originalPosition = model.state.position
|
||||
model.setYouTubeQuality(1080)
|
||||
until(model, "failed manual quality switch restores the original native playback state") {
|
||||
model.errorText != nil && !model.resolvingYouTube && model.canTogglePlayback &&
|
||||
model.errorText != nil && !model.resolvingStream && model.canTogglePlayback &&
|
||||
model.state.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED) &&
|
||||
(!playing || model.state.position > originalPosition + 0.1)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ struct ConfigurationChecks {
|
||||
|
||||
static func checkBindings() throws {
|
||||
let bindings = KeyBindings()
|
||||
try require(PlayerAction(name: "open_stream") == .openStream && PlayerAction.openStream.name == "open_stream",
|
||||
"stream action uses the general config name")
|
||||
try require(PlayerAction(name: "open_youtube") == nil, "obsolete YouTube action has no alias")
|
||||
try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")],
|
||||
"stream action preserves its shortcut")
|
||||
try requireError(["open_youtube"]) { _ = try KeyBindings(overrides: ["open_youtube": []]) }
|
||||
let inversePairs: [(PlayerAction, PlayerAction)] = [
|
||||
(.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen),
|
||||
(.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop),
|
||||
|
||||
@@ -33,6 +33,9 @@ struct PlayerCommandChecks {
|
||||
require(try opened([colonFile.path]) == colonFile, "colons in filesystem paths are not URL schemes")
|
||||
let canonical = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
|
||||
require(try opened(["https://youtu.be/jNQXAC9IVRw"]) == canonical, "YouTube positional URLs canonicalize for resume identity")
|
||||
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1")!
|
||||
require(try opened([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity")
|
||||
require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution")
|
||||
require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media")
|
||||
require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie,
|
||||
"independent local launches preserve relative paths and spaces")
|
||||
|
||||
@@ -6,13 +6,13 @@ struct ResolvedPlaybackCheck {
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError("FAIL: " + message) }
|
||||
}
|
||||
static func until(_ message: String, model: PlayerModel, _ predicate: () -> Bool) {
|
||||
static func until(_ message: String, model: PlayerModel, _ predicate: () -> Bool) async throws {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(45))
|
||||
while !predicate(), ContinuousClock.now < deadline {
|
||||
if model.state.status == PLAYER_ERROR || model.commandError != nil {
|
||||
fatalError("FAIL: " + (model.errorText ?? "Playback failed"))
|
||||
}
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01))
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
require(predicate(), message)
|
||||
}
|
||||
@@ -24,21 +24,31 @@ struct ResolvedPlaybackCheck {
|
||||
static func run() async throws {
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
let file = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let source = try ResolvedSource.load(from: file)
|
||||
let input = CommandLine.arguments[1]
|
||||
let file = URL(fileURLWithPath: input)
|
||||
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)
|
||||
} else {
|
||||
source = try ResolvedSource.load(from: file)
|
||||
}
|
||||
let suite = "terebi.resolved-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil)
|
||||
defer { model.shutdown() }
|
||||
model.openResolvedSource(file)
|
||||
until("video/audio decode and advancing playback", model: model) {
|
||||
if input.hasPrefix("https://") { model.openStream(input) }
|
||||
else { model.openResolvedSource(file) }
|
||||
try await until("video/audio decode and advancing playback", model: model) {
|
||||
model.state.status == PLAYER_PLAYING && model.state.has_video && model.state.has_audio && model.state.position > 0.2
|
||||
}
|
||||
print("PASS: resolved HLS decodes video/audio and advances")
|
||||
fflush(stdout)
|
||||
require(model.setPlayback(playing: false), "pause accepted")
|
||||
until("pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
try await until("pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
require(model.state.can_seek && model.state.duration > 0, "seekable measured duration")
|
||||
guard let index = source.subtitles.firstIndex(where: \.isDefault) else {
|
||||
fatalError("FAIL: smoke requires a default subtitle")
|
||||
@@ -50,14 +60,14 @@ struct ResolvedPlaybackCheck {
|
||||
let target = playableCues[1].start + min(0.1, (playableCues[1].end - playableCues[1].start) / 2)
|
||||
require(!document.text(at: target).isEmpty, "forward seek targets a visible English cue")
|
||||
require(model.seekRelative(target - model.state.position), "seek accepted")
|
||||
until("seek acknowledged and subtitle synchronized", model: model) {
|
||||
try await until("seek acknowledged and subtitle synchronized", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - target) < 0.2 &&
|
||||
model.subtitleText == document.text(at: model.state.position) && model.subtitleError == nil
|
||||
}
|
||||
require(model.selectedSubtitle == index && model.subtitleTracks[index].label == track.label, "default selection and label retained")
|
||||
let backward = playableCues[0].start + min(0.1, (playableCues[0].end - playableCues[0].start) / 2)
|
||||
require(model.seekRelative(backward - model.state.position), "backward seek accepted")
|
||||
until("backward seek subtitle synchronized", model: model) {
|
||||
try await until("backward seek subtitle synchronized", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - backward) < 0.2 &&
|
||||
model.subtitleText == document.text(at: model.state.position)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct StreamResolverChecks {
|
||||
struct Failure: Error { let message: String }
|
||||
static let mediaID = "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw"
|
||||
static let page = "https://barelystarted.miruro.tv/watch/\(mediaID)/the-apothecary-diaries-season-3?ep=1"
|
||||
// Sanitized shape from Tubular's observed source response and projection fixture.
|
||||
static let fixture = #"""
|
||||
{
|
||||
"episodeNumber": 1,
|
||||
"config": {"sun": {"player": "native", "proxy": false,
|
||||
"hls": {"segments": {"enabled": false}, "query": {"enabled": false}}}},
|
||||
"providerOrder": ["sun", "bun"],
|
||||
"tracks": [{"track": "ssub", "providers": [{
|
||||
"provider": "sun",
|
||||
"servers": [
|
||||
{"server": "iframe", "headers": null, "streams": [], "embed": {"url": "https://embed.example/"}},
|
||||
{"server": "icarus-2-1", "headers": {"Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/"},
|
||||
"streams": [{"url": "https://vault.example/master.m3u8?token=secret", "format": "hls", "embed": null}]}
|
||||
],
|
||||
"subtitles": [
|
||||
{"file": "https://vault.example/english.jpeg", "format": "vtt", "language": "en", "label": "English", "default": true},
|
||||
{"file": "https://vault.example/spanish.jpeg", "format": "vtt", "language": "sp", "label": "Spanish (Latin America)", "default": false},
|
||||
{"file": "https://vault.example/unsupported.srt", "format": "srt", "language": "en", "label": "English SRT", "default": false}
|
||||
]
|
||||
}]}]
|
||||
}
|
||||
"""#
|
||||
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
||||
if !condition() { throw Failure(message: message) }
|
||||
}
|
||||
static func reject(_ message: String, _ operation: () throws -> Void) throws {
|
||||
do { try operation() }
|
||||
catch {
|
||||
try require(!error.localizedDescription.contains("secret"), "errors must not expose source secrets")
|
||||
return
|
||||
}
|
||||
throw Failure(message: "Accepted \(message)")
|
||||
}
|
||||
static func project(_ json: String) throws -> ResolvedSource {
|
||||
try MiruroResolver.decode(Data(json.utf8), mediaID: mediaID, episode: 1)
|
||||
}
|
||||
|
||||
static func checks() async throws {
|
||||
guard case .miruro(let id, let episode) = try StreamURL.parse(" \n" + page + "\n") else {
|
||||
throw Failure(message: "Miruro URL classification")
|
||||
}
|
||||
try require(id == mediaID && episode == 1, "opaque identity and route episode retained")
|
||||
guard case .youtube(let youtube) = try StreamURL.parse("https://youtu.be/jNQXAC9IVRw") else {
|
||||
throw Failure(message: "YouTube URL classification")
|
||||
}
|
||||
try require(youtube.absoluteString == "https://www.youtube.com/watch?v=jNQXAC9IVRw", "existing YouTube normalization")
|
||||
for invalid in [
|
||||
"https://example.com/video", "https://www.miruro.to/watch?id=21&ep=1",
|
||||
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv.evil.example"),
|
||||
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv@evil.example"),
|
||||
page.replacingOccurrences(of: "https://", with: "http://"),
|
||||
page.replacingOccurrences(of: "https://", with: "https://user:secret@"),
|
||||
page.replacingOccurrences(of: "/watch/", with: "/info/"),
|
||||
page.replacingOccurrences(of: mediaID, with: "invalid%2Fid"),
|
||||
page.replacingOccurrences(of: mediaID, with: "%2e%2e"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: ""),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=0"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=01"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=-1"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=2147483648"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=%31"),
|
||||
page + "&ep=2", page + "&provider=bee", page + "#secret",
|
||||
page.replacingOccurrences(of: ".tv/", with: ".tv:444/"),
|
||||
page.replacingOccurrences(of: "?ep", with: "/?ep"),
|
||||
"https://youtu.be/invalid", "file:///secret"
|
||||
] {
|
||||
try reject("unsupported or unsafe URL") { _ = try StreamURL.parse(invalid) }
|
||||
}
|
||||
let source = try project(fixture)
|
||||
try require(source.identity == "miruro:\(mediaID):1", "stable catalog identity")
|
||||
try require(source.url == "https://vault.example/master.m3u8?token=secret", "first direct HLS stream retained")
|
||||
try require(source.headers == "Origin: https://ultracloud.cc\r\nReferer: https://ultracloud.cc/\r\n", "all server headers retained")
|
||||
try require(source.subtitles.count == 2 && source.subtitles[0].isDefault, "only external VTT retained with default")
|
||||
try require(source.subtitles[0].url.hasSuffix(".jpeg") && source.subtitles[1].label == "Spanish (Latin America)", "subtitle format and labels independent of file extension")
|
||||
try require(source.subtitles.allSatisfy { $0.headers == source.headers }, "verified Sun subtitle headers retained")
|
||||
let renewed = try project(fixture.replacingOccurrences(of: "token=secret", with: "token=renewed"))
|
||||
try require(renewed.identity == source.identity && renewed.url != source.url, "token rotation does not change stable identity")
|
||||
for (old, replacement) in [
|
||||
("\"episodeNumber\": 1", "\"episodeNumber\": 49"),
|
||||
("\"proxy\": false", "\"proxy\": true"),
|
||||
("\"enabled\": false", "\"enabled\": true"),
|
||||
("\"player\": \"native\"", "\"player\": \"iframe\""),
|
||||
("\"track\": \"ssub\"", "\"track\": \"dub\""),
|
||||
("\"provider\": \"sun\"", "\"provider\": \"bee\""),
|
||||
("\"format\": \"hls\"", "\"format\": \"mp4\""),
|
||||
("\"embed\": null", "\"embed\": {\"url\": \"https://embed.example/secret\"}"),
|
||||
("https://vault.example/master.m3u8?token=secret", "file:///secret"),
|
||||
("https://vault.example/english.jpeg", "https://user:secret@vault.example/english.jpeg"),
|
||||
("https://ultracloud.cc/\"", "https://ultracloud.cc/\\r\\nX-Injected: secret\""),
|
||||
("\"Origin\":", "\"origin\": \"secret\", \"Origin\":"),
|
||||
("\"default\": false", "\"default\": true")
|
||||
] {
|
||||
try reject("unsafe/unsupported source projection") {
|
||||
_ = try project(fixture.replacingOccurrences(of: old, with: replacement))
|
||||
}
|
||||
}
|
||||
try reject("oversized metadata") {
|
||||
_ = try MiruroResolver.decode(Data(repeating: 32, count: MiruroResolver.bodyLimit + 1), mediaID: mediaID, episode: 1)
|
||||
}
|
||||
let responseURL = URL(string: "https://barelystarted.miruro.tv/api/sources")!
|
||||
for (status, type, length) in [(403, "text/html", "12"), (302, "application/json", "12"), (200, "text/html", "12"), (200, "application/json", "2097153")] {
|
||||
let response = HTTPURLResponse(url: responseURL, statusCode: status, httpVersion: nil,
|
||||
headerFields: ["Content-Type": type, "Content-Length": length])!
|
||||
try reject("protection/redirect/oversized response") { try MiruroResolver.validateResponse(response) }
|
||||
}
|
||||
let cancelled = Task {
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
return try await MiruroResolver.resolve(mediaID: mediaID, episode: 1)
|
||||
}
|
||||
do {
|
||||
_ = try await cancelled.value
|
||||
throw Failure(message: "Cancelled resolution was accepted")
|
||||
} catch is CancellationError {}
|
||||
print("PASS: streaming URL security, Sun projection, shared validation, bounded responses and cancellation")
|
||||
}
|
||||
|
||||
static func main() async {
|
||||
do {
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
if args.count == 2, args[0] == "--live" {
|
||||
guard case .miruro(let id, let episode) = try StreamURL.parse(args[1]) else {
|
||||
throw Failure(message: "--live requires a Miruro episode URL")
|
||||
}
|
||||
let source = try await MiruroResolver.resolve(mediaID: id, episode: episode)
|
||||
print("PASS: live \(source.identity); \(source.subtitles.count) VTT tracks")
|
||||
} else if args.count == 2, args[0] == "--fixture" {
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: args[1]))
|
||||
let source = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1)
|
||||
print("PASS: fixture \(source.identity); \(source.subtitles.count) VTT tracks")
|
||||
} else if args.isEmpty {
|
||||
try await checks()
|
||||
} else {
|
||||
throw Failure(message: "Usage: stream-resolver-check [--live EPISODE_URL | --fixture SOURCES_JSON]")
|
||||
}
|
||||
} catch {
|
||||
let message = (error as? Failure)?.message ?? error.localizedDescription
|
||||
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user