1065 lines
72 KiB
Swift
1065 lines
72 KiB
Swift
import AppKit
|
|
|
|
@main
|
|
@MainActor
|
|
struct ResolvedPlaybackCheck {
|
|
final class Clock { var now = 100.0 }
|
|
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
|
if !condition() { fatalError("FAIL: " + message) }
|
|
}
|
|
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: \(message): " + (model.errorText ?? "Playback failed"))
|
|
}
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
require(predicate(), message)
|
|
}
|
|
static func main() async {
|
|
do { try await run() }
|
|
catch { fatalError("FAIL: " + error.localizedDescription) }
|
|
}
|
|
|
|
static func run() async throws {
|
|
PlayerModel.metadataLifecycleCheck()
|
|
if CommandLine.arguments[1] == "--metadata-lifecycle" {
|
|
print("PASS: metadata lifecycle replacement, A-B-A stale results, and actual-duration changes")
|
|
return
|
|
}
|
|
_ = NSApplication.shared
|
|
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(videoProvider: preference).source
|
|
} else {
|
|
source = try ResolvedSource.load(from: file)
|
|
}
|
|
if CommandLine.arguments.contains("--enqueue-only") {
|
|
try await enqueueLifecycle(source: source)
|
|
return
|
|
}
|
|
if CommandLine.arguments.contains("--sponsors-only") {
|
|
try await chapterLifecycle(source: source, file: file)
|
|
try await sponsorLifecycle(source: source)
|
|
try await streamQueueLifecycle(source: source)
|
|
try await enqueueLifecycle(source: source)
|
|
return
|
|
}
|
|
let suite = "terebi.resolved-check." + UUID().uuidString
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let clock = Clock()
|
|
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)
|
|
}
|
|
return MiruroMetadata(title: nil, intervals: [])
|
|
},
|
|
skipClock: { clock.now })
|
|
defer { model.shutdown() }
|
|
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)
|
|
if input.hasPrefix("https://") {
|
|
try await until("descriptive title and measured-duration metadata bands", model: model) {
|
|
model.fileName != source.title && !model.visibleIntervals.isEmpty
|
|
}
|
|
let intervals = model.visibleIntervals
|
|
require(intervals.allSatisfy {
|
|
$0.start.isFinite && $0.end.isFinite && $0.start >= 0 &&
|
|
$0.end > $0.start && $0.end <= model.state.duration
|
|
}, "bands use finite in-bounds actual media duration")
|
|
require(intervals.filter { $0.kind == .intro }.count <= 1 &&
|
|
intervals.filter { $0.kind == .outro }.count <= 1, "at most one band of each kind")
|
|
print("PASS: Miruro title '\(model.fileName!)' and \(intervals.count) measured-duration bands")
|
|
}
|
|
require(model.setPlayback(playing: false), "pause accepted")
|
|
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")
|
|
}
|
|
let track = source.subtitles[index]
|
|
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)
|
|
require(!document.text(at: target).isEmpty, "forward seek targets a visible English cue")
|
|
require(model.seekRelative(target - model.state.position), "seek accepted")
|
|
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")
|
|
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)
|
|
}
|
|
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 chapterLifecycle(source: source, file: file)
|
|
try await sponsorLifecycle(source: source)
|
|
try await streamQueueLifecycle(source: source)
|
|
try await enqueueLifecycle(source: source)
|
|
try await sidebarQueueLifecycle(source: source)
|
|
}
|
|
}
|
|
|
|
static var fixtureSponsors: [SponsorSegment] {
|
|
[.init(start: 0.5, end: 1.5, videoDuration: 0)]
|
|
}
|
|
|
|
static func sponsorLifecycle(source: ResolvedSource) async throws {
|
|
let suite = "terebi.sponsor-check." + UUID().uuidString
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let url = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
|
var extraction: CheckedContinuation<YouTubeVideo, Never>?
|
|
var held: [CheckedContinuation<[SponsorSegment], Never>] = []
|
|
var extractionCount = 0
|
|
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
|
extractionCount += 1
|
|
let video = YouTubeVideo(title: "Sponsor fixture", video: .init(url: source.url, headers: source.headers),
|
|
audio: nil, duration: 0, isLive: false, webpageURL: url, height: height ?? 1080,
|
|
availableHeights: [1080, 720], chapters: fixtureChapters)
|
|
if extractionCount == 1 { return await withCheckedContinuation { extraction = $0 } }
|
|
return video
|
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in
|
|
if id == "BBBBBBBBBBB" { throw URLError(.timedOut) }
|
|
return await withCheckedContinuation { held.append($0) }
|
|
})
|
|
defer { model.shutdown() }
|
|
model.open(url)
|
|
try await until("playback extraction held with SponsorBlock disabled", model: model) { extraction != nil }
|
|
require(held.isEmpty, "opted-out playback makes no SponsorBlock request")
|
|
model.setSponsorBlockEnabled(true)
|
|
try await until("SponsorBlock starts independently of held playback extraction", model: model) { held.count == 1 }
|
|
held[0].resume(returning: fixtureSponsors)
|
|
extraction!.resume(returning: YouTubeVideo(title: "Sponsor fixture",
|
|
video: .init(url: source.url, headers: source.headers), audio: nil, duration: 0, isLive: false,
|
|
webpageURL: url, height: 1080, availableHeights: [1080, 720], chapters: fixtureChapters))
|
|
extraction = nil
|
|
try await until("sponsor metadata appears against native measured duration", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.sponsoredRanges == [0.5...1.5]
|
|
}
|
|
requireYouTubeChapters(model, [1.25, 2.5], "sponsor paint crossing chapter boundary")
|
|
require(model.setPlayback(playing: false), "pause sponsored playback")
|
|
try await until("sponsor playback paused", model: model) { model.state.status == PLAYER_PAUSED }
|
|
require(model.seekRelative(0.75 - model.state.position), "seek within sponsor range")
|
|
try await until("sponsor seek acknowledged without automatic skipping", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
|
}
|
|
require(model.segmentToSkip == nil, "sponsors never offer Miruro skip actions")
|
|
model.setSponsorBlockEnabled(false)
|
|
require(model.sponsoredRanges.isEmpty, "disabling immediately clears paint")
|
|
model.setSponsorBlockEnabled(true)
|
|
try await until("re-enabled lookup held", model: model) { held.count == 2 }
|
|
model.setYouTubeQuality(720)
|
|
try await until("quality changes without awaiting optional metadata", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PAUSED && model.youtubeHeight == 720
|
|
}
|
|
require(held.count == 2, "quality replacement coalesces the in-flight current-video lookup")
|
|
held[1].resume(returning: fixtureSponsors)
|
|
try await until("pending metadata survives quality replacement", model: model) { model.sponsoredRanges == [0.5...1.5] }
|
|
model.setYouTubeQuality(1080)
|
|
try await until("completed metadata survives quality replacement", model: model) {
|
|
!model.isBusy && model.youtubeHeight == 1080 && model.sponsoredRanges == [0.5...1.5]
|
|
}
|
|
require(held.count == 2, "quality replacement reuses completed metadata")
|
|
model.open(url)
|
|
try await until("same-video reload has its own lookup", model: model) {
|
|
held.count == 3 && !model.isBusy && model.state.status == PLAYER_PLAYING
|
|
}
|
|
model.open(url)
|
|
try await until("second same-video reload has a new generation", model: model) {
|
|
held.count == 4 && !model.isBusy && model.state.status == PLAYER_PLAYING
|
|
}
|
|
held[2].resume(returning: [.init(start: 2, end: 3, videoDuration: 0)])
|
|
for _ in 0..<10 { await Task.yield() }
|
|
require(model.sponsoredRanges.isEmpty, "late same-video response cannot paint a newer load")
|
|
held[3].resume(returning: fixtureSponsors)
|
|
try await until("current reload accepts its own metadata", model: model) { model.sponsoredRanges == [0.5...1.5] }
|
|
model.open(url)
|
|
try await until("lookup pending before close", model: model) {
|
|
held.count == 5 && !model.isBusy && model.state.status == PLAYER_PLAYING
|
|
}
|
|
model.closeFile()
|
|
held[4].resume(returning: fixtureSponsors)
|
|
try await until("close clears sponsorship", model: model) { model.state.status == PLAYER_EMPTY }
|
|
require(model.sponsoredRanges.isEmpty, "closed player rejects late metadata")
|
|
model.open(URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!)
|
|
try await until("SponsorBlock timeout never blocks native playback", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_audio
|
|
}
|
|
require(model.commandError == nil && model.sponsoredRanges.isEmpty, "optional failure leaves unmarked playback")
|
|
model.open(URL(string: "https://example.org/generic-video")!)
|
|
try await until("generic extractor site remains playable without SponsorBlock", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_audio
|
|
}
|
|
require(held.count == 5 && model.sponsoredRanges.isEmpty, "generic sites never send a SponsorBlock lookup")
|
|
print("PASS: SponsorBlock opt-in, independent lookup, crimson-only ranges, quality reuse, same-video cancellation, close, generic sites and timeout isolation")
|
|
}
|
|
|
|
static var fixtureChapters: [YouTubeChapter] {
|
|
[.init(start: 0, end: 1.25, title: "Opening"),
|
|
.init(start: 1.25, end: 2.5, title: "Ending")]
|
|
}
|
|
|
|
static func requireYouTubeChapters(_ model: PlayerModel, _ expected: [Double], _ context: String) {
|
|
require(!model.isBusy && model.state.has_video && model.state.has_audio && model.state.duration > 2.5,
|
|
"\(context): boundaries belong to decoded native media with measured duration")
|
|
require(model.sliderBoundaries == expected, "\(context): exact fractional chapter boundaries")
|
|
require(model.visibleIntervals.isEmpty && model.segmentToSkip == nil,
|
|
"\(context): YouTube chapters never become Miruro bands or skip offers")
|
|
}
|
|
|
|
static func chapterLifecycle(source: ResolvedSource, file: URL) async throws {
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("terebi.chapter-check." + UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let report = directory.appendingPathComponent("single-launch.jsonl")
|
|
let suite = "terebi.chapter-check." + UUID().uuidString
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let chaptered = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
|
let chapterless = URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!
|
|
var requests: [URL] = []
|
|
// The single-video Tubular launch uses this progress-file initialization plus openStream.
|
|
let model = PlayerModel(defaults: defaults, configuration: .init(sponsorBlock: true),
|
|
progressFile: report.path, youtubeResolver: { url, height in
|
|
requests.append(url)
|
|
return YouTubeVideo(title: url == chaptered ? "Chaptered launch" : "Chapterless launch",
|
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
|
duration: 0, isLive: false, webpageURL: url, height: height ?? 1080, availableHeights: [1080, 720],
|
|
chapters: url == chaptered ? fixtureChapters + [.init(start: 3, end: 500, title: nil)] : [])
|
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in id == "AAAAAAAAAAA" ? fixtureSponsors : [] },
|
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
|
defer { model.shutdown() }
|
|
require(model.progressInitializationError == nil, "single Tubular launch initializes progress reporting")
|
|
for (url, expected) in [(chaptered, [1.25, 2.5]), (chapterless, [])] {
|
|
model.openStream(url.absoluteString)
|
|
require(model.sliderBoundaries.isEmpty, "direct open hides prior chapters while resolving")
|
|
try await until("direct YouTube stream decodes", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
|
}
|
|
requireYouTubeChapters(model, expected, "direct openStream")
|
|
try await until("direct launch sponsorship metadata", model: model) {
|
|
url != chaptered || model.sponsoredRanges == [0.5...1.5]
|
|
}
|
|
require(model.sponsoredRanges == (url == chaptered ? [0.5...1.5] : []), "single launch sponsorship")
|
|
require(model.sliderSegmentTitle(at: 0.5) == (url == chaptered ? "Opening" : nil) &&
|
|
model.sliderSegmentTitle(at: 1.25) == (url == chaptered ? "Ending" : nil) &&
|
|
model.sliderSegmentTitle(at: 2.5) == nil,
|
|
"hover names match the chapter under the pointer, with no invented gap names")
|
|
require(model.state.duration < 500, "native duration rejects chapter edges beyond measured media")
|
|
require(model.setPlayback(playing: false), "pause before saving chapter playback")
|
|
try await until("chapter playback paused", model: model) { model.state.status == PLAYER_PAUSED }
|
|
require(model.seekRelative(0.8 - model.state.position), "save chapter playback bookmark")
|
|
try await until("chapter bookmark acknowledged", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - 0.8) < 0.1
|
|
}
|
|
model.closeFile()
|
|
require(model.sliderBoundaries.isEmpty, "close immediately removes chapter boundaries")
|
|
try await until("chapter playback closed", model: model) { model.state.status == PLAYER_EMPTY }
|
|
require(model.savedPosition(for: url).map { abs($0 - 0.8) < 0.1 } == true,
|
|
"acknowledged bookmark persists for canonical YouTube URL")
|
|
require(model.recentMedia.first?.url == url, "recent entry retains original YouTube URL")
|
|
model.open(model.recentMedia.first!.url)
|
|
try await until("recent YouTube reopen restores native bookmark", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position >= 0.7
|
|
}
|
|
requireYouTubeChapters(model, expected, "saved/recent open(url)")
|
|
try await until("saved-item sponsorship metadata", model: model) {
|
|
url != chaptered || model.sponsoredRanges == [0.5...1.5]
|
|
}
|
|
require(model.setPlayback(playing: false), "pause before chapter quality replacement")
|
|
try await until("chapter quality pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
|
let position = model.state.position
|
|
model.setYouTubeQuality(720)
|
|
require(model.sliderBoundaries.isEmpty, "quality extraction hides prior chapter boundaries")
|
|
try await until("chapter quality replacement preserves pause and position", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PAUSED && model.youtubeHeight == 720 &&
|
|
abs(model.state.position - position) < 0.2
|
|
}
|
|
requireYouTubeChapters(model, expected, "chaptered/chapterless quality replacement")
|
|
require(model.sponsoredRanges == (url == chaptered ? [0.5...1.5] : []), "quality replacement retains paint")
|
|
}
|
|
model.open(chaptered)
|
|
try await until("chaptered media replaces chapterless media", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == "Chaptered launch"
|
|
}
|
|
requireYouTubeChapters(model, [1.25, 2.5], "chaptered replacement")
|
|
model.openResolvedSource(file)
|
|
require(model.sliderBoundaries.isEmpty, "non-YouTube replacement hides prior chapters while opening")
|
|
try await until("resolved replacement decodes without YouTube chapters", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_video && model.state.has_audio
|
|
}
|
|
require(model.sliderBoundaries.isEmpty, "resolved media does not inherit YouTube chapters")
|
|
require(model.sponsoredRanges.isEmpty, "resolved replacement clears sponsorship")
|
|
model.closeFile()
|
|
try await until("resolved replacement closes", model: model) { model.state.status == PLAYER_EMPTY }
|
|
require(model.sliderBoundaries.isEmpty, "empty player has no chapter boundaries")
|
|
require(requests == [chaptered, chaptered, chaptered, chapterless, chapterless, chapterless, chaptered],
|
|
"direct, recent, saved and quality opens each resolve fresh chapter metadata")
|
|
model.shutdown()
|
|
let rows = try String(contentsOf: report, encoding: .utf8).split(separator: "\n").map {
|
|
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
|
}
|
|
require(rows.contains {
|
|
$0["version"] as? Int == 1 && $0["media_url"] as? String == chaptered.absoluteString &&
|
|
$0["event"] as? String == "progress"
|
|
}, "single Tubular launch reports real playback under its canonical YouTube identity")
|
|
print("PASS: chaptered/chapterless direct and single Tubular launch, saved/recent reopen, quality, measured duration, replacement and close")
|
|
}
|
|
|
|
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)
|
|
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]]))
|
|
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": track, "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: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: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: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: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")
|
|
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:ssub"
|
|
}
|
|
require(model.selectedSubtitle == nil, "host changes preserve explicit subtitle Off")
|
|
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:ssub" &&
|
|
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 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)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let urls = ["AAAAAAAAAAA", "BBBBBBBBBBB", "CCCCCCCCCCC"].map {
|
|
URL(string: "https://www.youtube.com/watch?v=\($0)")!
|
|
} + [URL(string: "https://video.example/watch/fixture")!]
|
|
let episode = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
|
|
var foreground: CheckedContinuation<YouTubeVideo, Never>?
|
|
var background: CheckedContinuation<String?, Never>?
|
|
var holdFirst = true
|
|
var episodeRequests = 0
|
|
var titleRequests: [URL] = []
|
|
func video(_ url: URL, title: String, height: Int = 1080) -> YouTubeVideo {
|
|
YouTubeVideo(title: title, video: .init(url: source.url, headers: source.headers), audio: nil,
|
|
duration: 0, isLive: false, webpageURL: url, height: height, availableHeights: [1080, 720],
|
|
chapters: height == 720 ? [.init(start: 0.75, end: 1.75, title: nil)] : fixtureChapters)
|
|
}
|
|
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
|
if holdFirst || height != nil {
|
|
holdFirst = false
|
|
return await withCheckedContinuation { foreground = $0 }
|
|
}
|
|
return video(url, title: url == urls[0] ? "Playing title" : "Fresh playback title")
|
|
}, youtubeTitleResolver: { url in
|
|
require(Task.currentPriority <= .utility, "title-only lookups use lower priority")
|
|
titleRequests.append(url)
|
|
if url == urls[1] { return await withCheckedContinuation { background = $0 } }
|
|
if url == urls[2] { throw CocoaError(.fileReadCorruptFile) }
|
|
return url == urls[0] ? "Early current title" : "Generic page title"
|
|
}, miruroResolver: { _, _ in
|
|
fatalError("FAIL: sidebar enrichment must not fetch Miruro playback sources")
|
|
}, miruroMetadataResolver: { _, _ in
|
|
episodeRequests += 1
|
|
return MiruroMetadata(title: "Queued episode", intervals: [])
|
|
})
|
|
defer { model.shutdown() }
|
|
func queue(_ entries: [URL]) -> StreamQueue {
|
|
StreamQueue(entries: entries.map { url in
|
|
.init(url: url, identity: url == episode ? source.identity : url.absoluteString,
|
|
progressFile: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString),
|
|
label: url.absoluteString)
|
|
})
|
|
}
|
|
model.openStreamQueue(queue([urls[0], urls[1], episode, urls[2], urls[3]]))
|
|
try await until("early current title and next title request while full extraction is held", model: model) {
|
|
foreground != nil && background != nil && model.queue.currentItem?.displayName == "Early current title"
|
|
}
|
|
require(model.resolvingStream && model.fileName == nil && episodeRequests == 0 &&
|
|
titleRequests == [urls[0], urls[1]], "serial sidebar work is independent of foreground extraction")
|
|
let selected = model.queue.currentID
|
|
let pendingID = model.queue.items[1].id
|
|
model.moveQueueItems(fromOffsets: IndexSet(integer: 1), toOffset: 5)
|
|
background!.resume(returning: "Queued title")
|
|
background = nil
|
|
try await until("mixed queue titles continue before foreground extraction finishes", model: model) {
|
|
model.queue.items.last?.displayName == "Queued title" &&
|
|
model.queue.items.first { $0.url == episode }?.displayName == "Queued episode" &&
|
|
model.queue.items.first { $0.url == urls[3] }?.displayName == "Generic page title"
|
|
}
|
|
require(model.queue.items.last?.id == pendingID && model.queue.currentID == selected &&
|
|
model.resolvingStream && model.fileName == nil, "reordered metadata updates only its original row")
|
|
require(model.queue.items.first { $0.url == urls[2] }?.displayName == urls[2].absoluteString &&
|
|
model.queue.items.first { $0.url == urls[2] }?.metadataAttempted == true &&
|
|
model.commandError == nil, "failed title preserves fallback and does not block later titles")
|
|
foreground!.resume(returning: video(urls[0], title: "Playing title"))
|
|
foreground = nil
|
|
try await until("foreground title takes precedence and native playback starts", model: model) {
|
|
model.state.status == PLAYER_PLAYING && model.queue.currentItem?.displayName == "Playing title"
|
|
}
|
|
requireYouTubeChapters(model, [1.25, 2.5], "foreground queued metadata")
|
|
require(model.setPlayback(playing: false), "pause for metadata preservation")
|
|
try await until("metadata smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
|
let position = model.state.position
|
|
for _ in 0..<10 { await Task.yield() }
|
|
require(model.queue.currentID == selected && model.state.position == position &&
|
|
model.fileName == "Playing title", "title-only metadata preserves native selection, position and current title")
|
|
|
|
model.openStreamQueue(queue([urls[0], urls[1]]))
|
|
try await until("next title-only lookup starts", model: model) { background != nil }
|
|
let selectedLookup = background!
|
|
background = nil
|
|
model.selectQueueItem(model.queue.items[1].id)
|
|
try await until("selection resolves fresh playback independently", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == "Fresh playback title"
|
|
}
|
|
selectedLookup.resume(returning: "Stale title")
|
|
for _ in 0..<10 { await Task.yield() }
|
|
require(model.queue.currentItem?.displayName == "Fresh playback title" &&
|
|
model.fileName == "Fresh playback title", "late title-only result cannot overwrite foreground metadata")
|
|
|
|
model.openStreamQueue(queue([urls[0], urls[1]]))
|
|
try await until("removable title-only lookup starts", model: model) { background != nil }
|
|
let removed = background!
|
|
background = nil
|
|
model.removeQueueItem(model.queue.items[1].id)
|
|
model.openStreamQueue(queue([urls[0], urls[1]]))
|
|
try await until("replacement title-only lookup starts", model: model) { background != nil }
|
|
removed.resume(returning: "Removed title")
|
|
for _ in 0..<10 { await Task.yield() }
|
|
require(model.queue.items[1].displayName == urls[1].absoluteString,
|
|
"removed/replaced item cannot rename a new row for the same URL")
|
|
try await until("replacement native media ready for quality change", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == "Playing title"
|
|
}
|
|
require(model.setPlayback(playing: false), "pause before quality extraction")
|
|
try await until("quality smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
|
model.setYouTubeQuality(720)
|
|
try await until("quality extraction is held", model: model) { foreground != nil }
|
|
require(model.sliderBoundaries.isEmpty, "held quality replacement hides previous chapter boundaries")
|
|
background!.resume(returning: "Replacement title")
|
|
background = nil
|
|
try await until("title arrives while quality extraction is held", model: model) {
|
|
model.queue.items[1].displayName == "Replacement title"
|
|
}
|
|
require(model.resolvingStream && model.fileName == "Playing title" &&
|
|
model.queue.currentItem?.displayName == "Playing title", "title work does not replace current quality metadata")
|
|
foreground!.resume(returning: video(urls[0], title: "Quality title", height: 720))
|
|
foreground = nil
|
|
try await until("quality extraction resumes paused native playback", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PAUSED && model.fileName == "Quality title"
|
|
}
|
|
requireYouTubeChapters(model, [0.75, 1.75], "quality replacement accepts fresh chapters and preserves pause")
|
|
print("PASS: early current/queued titles during held playback and quality extraction; serial utility lookup, generic/Miruro titles, reorder, failure and stale replacement/foreground precedence")
|
|
}
|
|
|
|
static func streamQueueLifecycle(source: ResolvedSource) async throws {
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("terebi.queue-check." + UUID().uuidString).resolvingSymlinksInPath()
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true,
|
|
attributes: [.posixPermissions: 0o700])
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let youtubeURL = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
|
let episodeURL = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
|
|
let firstReport = directory.appendingPathComponent("youtube.jsonl")
|
|
let secondReport = directory.appendingPathComponent("miruro.jsonl")
|
|
let queue = StreamQueue(entries: [
|
|
.init(url: youtubeURL, identity: youtubeURL.absoluteString, progressFile: firstReport, label: "First"),
|
|
.init(url: episodeURL, identity: source.identity, progressFile: secondReport, label: "Second")
|
|
])
|
|
let suite = "terebi.queue-check." + UUID().uuidString
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
var youtubeRequests = 0
|
|
var episodeRequests = 0
|
|
var stale: CheckedContinuation<YouTubeVideo, Never>?
|
|
let video = YouTubeVideo(title: "Queue YouTube fixture",
|
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
|
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [],
|
|
chapters: fixtureChapters)
|
|
let model = PlayerModel(defaults: defaults, configuration: .init(sponsorBlock: true),
|
|
streamQueue: queue, youtubeResolver: { _, _ in
|
|
youtubeRequests += 1
|
|
if youtubeRequests == 1 {
|
|
return await withCheckedContinuation { stale = $0 }
|
|
}
|
|
return video
|
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { _ in fixtureSponsors }, miruroResolver: { _, _ in
|
|
episodeRequests += 1
|
|
return try providerSources(source)
|
|
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
|
defer { model.shutdown() }
|
|
require(model.progressInitializationError == nil, "queue report registration")
|
|
model.openStreamQueue(queue)
|
|
try await until("only selected first stream resolves", model: model) { stale != nil }
|
|
require(episodeRequests == 0 && model.queue.items.map(\.url) == [youtubeURL, episodeURL],
|
|
"stream queue is lazy and preserves supplied order")
|
|
let firstID = model.queue.items[0].id
|
|
let secondID = model.queue.items[1].id
|
|
model.selectQueueItem(secondID)
|
|
try await until("manual stream selection decodes native media", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
|
}
|
|
require(model.sliderBoundaries.isEmpty, "selected Miruro media has no stale YouTube chapters")
|
|
stale!.resume(returning: video)
|
|
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.miruroVideoProviders.isEmpty &&
|
|
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
|
|
require(model.sliderBoundaries.isEmpty, "canceled chaptered extraction cannot add boundaries to Miruro media")
|
|
model.advanceQueue(-1)
|
|
try await until("previous resolves YouTube afresh", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == video.title
|
|
}
|
|
require(youtubeRequests == 2 && model.queue.currentID == firstID, "manual previous preserves queue and resolves current item")
|
|
requireYouTubeChapters(model, [1.25, 2.5], "batch queue previous")
|
|
try await until("batch launch sponsor paint", model: model) { model.sponsoredRanges == [0.5...1.5] }
|
|
require(model.setPlayback(playing: false), "pause first queued stream")
|
|
try await until("queue pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
|
require(model.seekRelative(max(0, model.duration - 0.3) - model.state.position), "seek first stream near natural end")
|
|
try await until("end seek acknowledged", model: model) {
|
|
!model.state.seek_pending && !model.isBusy && abs(model.state.position - max(0, model.duration - 0.3)) < 0.1
|
|
}
|
|
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.miruroVideoProviders.isEmpty && episodeRequests == 2
|
|
}
|
|
require(model.sliderBoundaries.isEmpty, "natural queue advance clears YouTube chapters for Miruro")
|
|
require(model.sponsoredRanges.isEmpty, "natural advance clears sponsor paint for Miruro")
|
|
require(model.setPlayback(playing: false), "pause second stream for restoration")
|
|
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
|
|
require(model.seekRelative(0.8 - model.state.position), "save an acknowledged second-stream bookmark")
|
|
try await until("second bookmark acknowledged", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - 0.8) < 0.1
|
|
}
|
|
model.advanceQueue(-1)
|
|
try await until("revisit first report session", model: model) {
|
|
!model.isBusy && model.fileName == video.title && model.state.status == PLAYER_PLAYING
|
|
}
|
|
requireYouTubeChapters(model, [1.25, 2.5], "batch queue revisit")
|
|
model.advanceQueue(1)
|
|
try await until("second revisit resumes acknowledged bookmark", model: model) {
|
|
!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")
|
|
model.shutdown()
|
|
func records(_ file: URL) throws -> [[String: Any]] {
|
|
try String(contentsOf: file, encoding: .utf8).split(separator: "\n").map {
|
|
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
|
}
|
|
}
|
|
let youtubeRows = try records(firstReport)
|
|
let episodeRows = try records(secondReport)
|
|
require(youtubeRows.count >= 4 && youtubeRows.allSatisfy {
|
|
$0["version"] as? Int == 1 && $0["media_url"] as? String == youtubeURL.absoluteString
|
|
}, "YouTube report appends on revisit without another identity")
|
|
require(youtubeRows.contains { $0["event"] as? String == "ended" }, "natural end retains its acknowledged completion snapshot")
|
|
require(episodeRows.count >= 6 && episodeRows.allSatisfy {
|
|
let identity = $0["media_identity"] as? [String: Any]
|
|
return $0["version"] as? Int == 2 && identity?["media_id"] as? String == "fixture-ID" &&
|
|
identity?["episode"] as? Int == 1
|
|
}, "Miruro report is isolated through selection, advance and revisits")
|
|
print("PASS: ordered lazy mixed stream queue, stale cancellation, native end advance, fresh revisits/resume and report isolation")
|
|
}
|
|
|
|
static func enqueueLifecycle(source: ResolvedSource) async throws {
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("terebi.enqueue-check." + UUID().uuidString).resolvingSymlinksInPath()
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true,
|
|
attributes: [.posixPermissions: 0o700])
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let first = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
|
let second = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
|
|
let third = URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!
|
|
let fourth = URL(string: "https://www.youtube.com/watch?v=CCCCCCCCCCC")!
|
|
let originalReport = directory.appendingPathComponent("original.jsonl")
|
|
let secondReport = directory.appendingPathComponent("second.jsonl")
|
|
let thirdReport = directory.appendingPathComponent("third.jsonl")
|
|
let fourthReport = directory.appendingPathComponent("fourth.jsonl")
|
|
let manifest = directory.appendingPathComponent("queue & #.json")
|
|
var requests = 0
|
|
var episodeRequests = 0
|
|
let suite = "terebi.enqueue-check." + UUID().uuidString
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let model = PlayerModel(defaults: defaults, configuration: .init(sponsorBlock: true), progressFile: originalReport.path,
|
|
youtubeResolver: { url, _ in
|
|
requests += 1
|
|
return YouTubeVideo(title: url.absoluteString,
|
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [],
|
|
chapters: url == first ? fixtureChapters : [])
|
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in id == "AAAAAAAAAAA" ? fixtureSponsors : [] },
|
|
miruroResolver: { _, _ in episodeRequests += 1; return try providerSources(source) },
|
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
|
defer { model.shutdown() }
|
|
func deliver(_ items: [(URL, URL)]) throws {
|
|
let document: [String: Any] = ["version": 1, "items": items.map {
|
|
["url": $0.0.absoluteString, "progress_file": $0.1.path]
|
|
}]
|
|
try JSONSerialization.data(withJSONObject: document).write(to: manifest)
|
|
model.receiveEnqueueURL(StreamQueue.enqueueURL(for: manifest))
|
|
}
|
|
model.openStream(first.absoluteString)
|
|
try await until("standalone stream begins before enqueue", model: model) {
|
|
model.state.status == PLAYER_PLAYING && !model.isBusy && model.state.position > 0.2
|
|
}
|
|
requireYouTubeChapters(model, [1.25, 2.5], "single stream before enqueue")
|
|
try await until("initial single launch sponsor paint", model: model) { model.sponsoredRanges == [0.5...1.5] }
|
|
require(model.setPlayback(playing: false), "pause original single-video playback")
|
|
try await until("standalone pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
|
let selected = model.queue.currentID
|
|
let position = model.state.position
|
|
let revision = model.state.revision
|
|
try deliver([(second, secondReport), (third, thirdReport)])
|
|
require(model.queue.items.map(\.url) == [first, second, third] &&
|
|
model.queue.currentID == selected && model.state.status == PLAYER_PAUSED &&
|
|
model.state.position == position && model.state.revision == revision &&
|
|
model.fileName == first.absoluteString && requests == 1 && episodeRequests == 0,
|
|
"paused enqueue preserves original media, selection and decoder timeline; new items resolve lazily in supplied order")
|
|
requireYouTubeChapters(model, [1.25, 2.5], "paused enqueue preserves current chapters")
|
|
require(model.sponsoredRanges == [0.5...1.5], "paused enqueue preserves current sponsor paint")
|
|
require(model.setPlayback(playing: true), "resume original playback")
|
|
try await until("original playing again", model: model) { model.state.status == PLAYER_PLAYING }
|
|
let playingRevision = model.state.revision
|
|
try deliver([(fourth, fourthReport)])
|
|
require(model.queue.items.map(\.url) == [first, second, third, fourth] &&
|
|
model.queue.currentID == selected && model.state.revision == playingRevision &&
|
|
model.state.status == PLAYER_PLAYING,
|
|
"repeated native delivery appends while playing without reopening current media")
|
|
requireYouTubeChapters(model, [1.25, 2.5], "playing enqueue preserves current chapters")
|
|
require(model.sponsoredRanges == [0.5...1.5], "playing enqueue preserves current sponsor paint")
|
|
try deliver([(third, directory.appendingPathComponent("conflict.jsonl"))])
|
|
require(model.commandError != nil && model.queue.items.count == 4 && model.queue.currentID == selected,
|
|
"conflicting identity rejects complete enqueue without changing playback")
|
|
try deliver([(URL(string: "https://www.youtube.com/watch?v=DDDDDDDDDDD")!, fourthReport)])
|
|
require(model.commandError != nil && model.queue.items.count == 4,
|
|
"registered report-path conflicts reject complete enqueue before files exist")
|
|
try deliver([(URL(string: "https://www.youtube.com/watch?v=DDDDDDDDDDD")!, directory.appendingPathComponent("valid.jsonl")),
|
|
(URL(string: "file:///not-a-stream")!, directory.appendingPathComponent("invalid.jsonl"))])
|
|
require(model.commandError != nil && model.queue.items.count == 4,
|
|
"invalid later manifest entry never partially appends or registers reports")
|
|
try deliver([(URL(string: "https://www.youtube.com/watch?v=DDDDDDDDDDD")!, directory.appendingPathComponent("valid.jsonl"))])
|
|
require(model.commandError == nil && model.queue.items.count == 5, "failed batches leave no registrations behind")
|
|
require(model.setPlayback(playing: false), "pause original before natural-end advance")
|
|
try await until("original pause before end seek", model: model) { model.state.status == PLAYER_PAUSED }
|
|
let ending = max(0, model.duration - 0.3)
|
|
require(model.seekRelative(ending - model.state.position), "seek original near natural end")
|
|
try await until("original end seek acknowledged", model: model) {
|
|
!model.state.seek_pending && !model.isBusy && abs(model.state.position - ending) < 0.1
|
|
}
|
|
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.miruroVideoProviders.isEmpty &&
|
|
model.queue.currentURL == second
|
|
}
|
|
require(model.sliderBoundaries.isEmpty, "enqueued Miruro advance clears current chapters")
|
|
require(model.sponsoredRanges.isEmpty, "enqueue advance to Miruro clears sponsor paint")
|
|
model.advanceQueue(1)
|
|
try await until("enqueued YouTube item decodes", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == third.absoluteString
|
|
}
|
|
requireYouTubeChapters(model, [], "chapterless enqueued YouTube advance")
|
|
require(model.sponsoredRanges.isEmpty, "enqueued no-result video has no sponsor paint")
|
|
model.advanceQueue(-1)
|
|
try await until("enqueued Miruro report revisits", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second
|
|
}
|
|
model.advanceQueue(-1)
|
|
try await until("original single video remains navigable", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == first.absoluteString
|
|
}
|
|
requireYouTubeChapters(model, [1.25, 2.5], "enqueued revisit restores chaptered YouTube")
|
|
var retiredReports: [(URL, Data, URL, URL)] = []
|
|
for (url, oldReport) in [(first, originalReport), (third, thirdReport)] {
|
|
let oldID = model.queue.items.first(where: { $0.url == url })!.id
|
|
model.removeQueueItem(oldID)
|
|
let currentID = model.queue.currentID
|
|
let freshReport = directory.appendingPathComponent(oldReport.lastPathComponent + ".fresh")
|
|
try deliver([(url, freshReport)])
|
|
require(model.commandError == nil && model.queue.items.count == 5 &&
|
|
model.queue.currentID == currentID, "removed video re-enqueues without interrupting current playback")
|
|
let retiredBytes = try Data(contentsOf: oldReport)
|
|
retiredReports.append((oldReport, retiredBytes, freshReport, url))
|
|
require(!FileManager.default.fileExists(atPath: freshReport.path), "re-added report remains lazy")
|
|
model.selectQueueItem(model.queue.items.last!.id)
|
|
try await until("re-added video decodes with a fresh session", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING &&
|
|
model.queue.currentURL == url && model.state.position > 0.2
|
|
}
|
|
require(model.setPlayback(playing: false), "pause re-added video")
|
|
try await until("re-added pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
|
}
|
|
model.shutdown()
|
|
for (oldReport, retiredBytes, freshReport, url) in retiredReports {
|
|
let finalBytes = try Data(contentsOf: oldReport)
|
|
require(finalBytes == retiredBytes, "re-added playback leaves the retired report unchanged")
|
|
let freshRows = try String(contentsOf: freshReport, encoding: .utf8).split(separator: "\n").map {
|
|
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
|
}
|
|
require(freshRows.contains { $0["event"] as? String == "closed" } &&
|
|
freshRows.contains { $0["event"] as? String == "progress" } &&
|
|
freshRows.allSatisfy { $0["media_url"] as? String == url.absoluteString },
|
|
"re-added video writes its acknowledged progress and close to the fresh report only")
|
|
}
|
|
for (file, expected) in [(originalReport, first.absoluteString), (thirdReport, third.absoluteString)] {
|
|
let rows = try String(contentsOf: file, encoding: .utf8).split(separator: "\n").map {
|
|
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
|
}
|
|
require(!rows.isEmpty && rows.allSatisfy { $0["media_url"] as? String == expected },
|
|
"original and appended YouTube reports retain independent identities")
|
|
}
|
|
let episodeRows = try String(contentsOf: secondReport, encoding: .utf8).split(separator: "\n").map {
|
|
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
|
}
|
|
require(!episodeRows.isEmpty && episodeRows.allSatisfy {
|
|
let identity = $0["media_identity"] as? [String: Any]
|
|
return identity?["media_id"] as? String == "fixture-ID" && identity?["episode"] as? Int == 1
|
|
}, "appended Miruro report remains isolated on revisit")
|
|
require(!FileManager.default.fileExists(atPath: fourthReport.path), "unselected enqueued item never owns a writer")
|
|
let empty = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
|
YouTubeVideo(title: "Cold enqueue", video: .init(url: source.url, headers: source.headers), audio: nil,
|
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [], chapters: [])
|
|
}, youtubeTitleResolver: { _ in nil })
|
|
defer { empty.shutdown() }
|
|
let coldReport = directory.appendingPathComponent("cold.jsonl")
|
|
empty.enqueueStreamQueue(StreamQueue(entries: [.init(url: fourth, identity: fourth.absoluteString,
|
|
progressFile: coldReport, label: "Cold enqueue")]))
|
|
try await until("empty player starts first enqueued item", model: empty) {
|
|
!empty.isBusy && empty.state.status == PLAYER_PLAYING && empty.queue.currentURL == fourth
|
|
}
|
|
requireYouTubeChapters(empty, [], "cold chapterless enqueue")
|
|
print("PASS: repeated enqueue while paused/playing, standalone navigation, atomic conflicts, independent reports and empty-player start")
|
|
}
|
|
|
|
static func sidebarQueueLifecycle(source: ResolvedSource) async throws {
|
|
let suite = "terebi.sidebar-queue-check." + UUID().uuidString
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let first = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
|
let second = URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!
|
|
let episode = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
|
|
let failedURL = URL(string: "https://example.invalid/failure")!
|
|
let cancelledURL = URL(string: "https://example.invalid/cancelled")!
|
|
var requests = 0
|
|
var pending: CheckedContinuation<YouTubeVideo, Never>?
|
|
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
|
requests += 1
|
|
if url == failedURL { throw CocoaError(.fileReadCorruptFile) }
|
|
let video = YouTubeVideo(title: "Sidebar \(url == first ? "first" : "second")",
|
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [],
|
|
chapters: url == first ? fixtureChapters : [])
|
|
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
|
|
return video
|
|
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in try providerSources(source) },
|
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
|
defer { model.shutdown() }
|
|
try await model.addStreamToQueue(first.absoluteString)
|
|
try await until("empty sidebar addition starts fetched video", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
|
}
|
|
require(requests == 1 && model.queue.currentURL == first, "empty addition uses fetched source without re-extracting")
|
|
requireYouTubeChapters(model, [1.25, 2.5], "sidebar first native playback")
|
|
require(model.setPlayback(playing: false), "pause before sidebar addition")
|
|
try await until("sidebar pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
|
let selected = model.queue.currentID
|
|
let revision = model.state.revision
|
|
let position = model.state.position
|
|
try await model.addStreamToQueue(" https://youtu.be/BBBBBBBBBBB \n")
|
|
try await model.addStreamToQueue(episode.absoluteString)
|
|
require(model.queue.items.map(\.url) == [first, second, episode] &&
|
|
model.queue.items[1].displayName == "Sidebar second" &&
|
|
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")
|
|
for text in ["not a URL", "file:///tmp/video.mp4"] {
|
|
do {
|
|
try await model.addStreamToQueue(text)
|
|
fatalError("FAIL: invalid sidebar URL accepted")
|
|
} catch {}
|
|
}
|
|
do {
|
|
try await model.addStreamToQueue(failedURL.absoluteString)
|
|
fatalError("FAIL: failed extraction accepted")
|
|
} catch {}
|
|
require(model.queue.items.map(\.url) == [first, second, episode] && model.queue.currentID == selected,
|
|
"invalid URLs and failed extraction leave queue untouched")
|
|
require(model.setPlayback(playing: true), "resume before sidebar addition")
|
|
try await until("sidebar resume acknowledged", model: model) { model.state.status == PLAYER_PLAYING }
|
|
let playingRevision = model.state.revision
|
|
try await model.addStreamToQueue(second.absoluteString)
|
|
require(model.state.revision == playingRevision && model.queue.currentID == selected &&
|
|
model.state.status == PLAYER_PLAYING, "addition does not restart playing media")
|
|
let cancelled = Task { try await model.addStreamToQueue(cancelledURL.absoluteString) }
|
|
while pending == nil { await Task.yield() }
|
|
cancelled.cancel()
|
|
pending!.resume(returning: YouTubeVideo(title: "Cancelled", video: .init(url: source.url, headers: source.headers),
|
|
audio: nil, duration: 0, isLive: false, webpageURL: cancelledURL, height: nil, availableHeights: [],
|
|
chapters: [.init(start: 0.75, end: 1.75, title: "Canceled metadata")]))
|
|
do {
|
|
try await cancelled.value
|
|
fatalError("FAIL: cancelled sidebar addition accepted")
|
|
} catch is CancellationError {}
|
|
require(model.queue.items.count == 4 && model.queue.currentID == selected,
|
|
"dismissed in-flight addition cannot append a late result")
|
|
requireYouTubeChapters(model, [1.25, 2.5], "canceled sidebar metadata leaves accepted chapters unchanged")
|
|
model.selectQueueItem(model.queue.items[1].id)
|
|
try await until("sidebar-added item plays through native queue selection", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second &&
|
|
model.fileName == "Sidebar second"
|
|
}
|
|
requireYouTubeChapters(model, [], "sidebar chapterless native selection")
|
|
print("PASS: sidebar URL fetch/add, empty-player decode, paused/playing preservation, validation, failure, cancellation and selection")
|
|
}
|
|
|
|
static func segmentSkips(_ model: PlayerModel, clock: Clock, file: URL) async throws {
|
|
let opening = MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1)
|
|
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
|
|
let metadata = MiruroMetadata(title: nil, intervals: [opening, ending])
|
|
require(model.segmentToSkip == nil && !model.skipSegment(), "absent metadata cannot skip")
|
|
require(model.sliderBoundaries.isEmpty, "absent Miruro metadata leaves slider unsegmented")
|
|
require(model.seekRelative(0.75 - model.state.position), "enter opening")
|
|
try await until("opening position acknowledged", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
|
}
|
|
model.supplyMetadataForCheck(metadata, episode: 1)
|
|
require(model.segmentToSkip == opening, "late metadata offers opening at acknowledged paused position")
|
|
require(model.visibleIntervals == [opening, ending] && model.sliderBoundaries == [0.5, 1.5, 2],
|
|
"Miruro retains intro/outro bands and interior slider boundaries without the media endpoint")
|
|
require(model.skipOfferRemainingFraction == 1, "late entry starts with a full countdown background")
|
|
clock.now = 105
|
|
require(model.skipOfferRemainingFraction == 0.5, "paused countdown drains by elapsed offer time, not media position")
|
|
clock.now = 109.999
|
|
require(model.segmentToSkip == opening, "opening remains available before ten seconds")
|
|
clock.now = 110
|
|
require(model.segmentToSkip == nil && !model.skipSegment(), "offer expires at ten seconds while paused")
|
|
require(model.skipOfferRemainingFraction == 0, "expired offer has no remaining background")
|
|
clock.now = 111
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
require(model.segmentToSkip == nil, "polling does not reoffer ignored opening")
|
|
require(model.seekRelative(2.25 - model.state.position), "enter ending")
|
|
require(model.segmentToSkip == nil, "pending seek cannot offer stale opening")
|
|
try await until("ending offer", model: model) {
|
|
!model.state.seek_pending && model.segmentToSkip == ending
|
|
}
|
|
require(model.skipSegment(), "ending skip accepted")
|
|
require(model.segmentToSkip == nil, "accepted skip immediately removes button")
|
|
try await until("ending skip reaches exact media end", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - ending.end) < 0.1
|
|
}
|
|
require(!model.effectivePlaying, "paused skip does not start playback")
|
|
require(model.seekRelative(0.75 - model.state.position), "revisit ignored opening")
|
|
try await until("opening revisit acknowledged", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
|
}
|
|
require(model.segmentToSkip == nil, "ignored opening stays dismissed when revisited")
|
|
model.closeFile()
|
|
require(model.segmentToSkip == nil, "close removes offer")
|
|
require(model.sliderBoundaries.isEmpty, "closing Miruro media removes slider boundaries")
|
|
model.openResolvedSource(file)
|
|
try await until("reopened media playing", model: model) {
|
|
model.state.status == PLAYER_PLAYING && !model.state.seek_pending && !model.isBusy
|
|
}
|
|
require(model.setPlayback(playing: false), "pause reopened media")
|
|
try await until("reopened media paused", model: model) { model.state.status == PLAYER_PAUSED }
|
|
require(model.seekRelative(0.75 - model.state.position), "reenter opening after reopen")
|
|
try await until("reopened opening acknowledged", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
|
}
|
|
require(model.sliderBoundaries.isEmpty, "reopened Miruro media waits for fresh metadata")
|
|
model.supplyMetadataForCheck(metadata, episode: 1)
|
|
try await until("new open resets dismissal after seek settles", model: model) {
|
|
model.segmentToSkip == opening
|
|
}
|
|
require(model.setPlayback(playing: true), "resume inside opening")
|
|
try await until("playing opening offer", model: model) {
|
|
model.state.status == PLAYER_PLAYING && !model.state.buffering &&
|
|
model.state.position > 0.85 && model.segmentToSkip == opening
|
|
}
|
|
require(model.skipSegment(), "opening skip accepted")
|
|
try await until("opening skip reaches segment end", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - opening.end) < 0.1
|
|
}
|
|
require(model.segmentToSkip == nil, "segment end is outside opening")
|
|
require(model.effectivePlaying, "opening skip preserves playing intent")
|
|
print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset")
|
|
}
|
|
}
|