feat: enqueue streams in running player

This commit is contained in:
2026-10-22 16:03:56 +02:00
parent ac8e284d80
commit 6a4afc9f0e
17 changed files with 416 additions and 61 deletions
+129
View File
@@ -41,6 +41,10 @@ struct ResolvedPlaybackCheck {
} else {
source = try ResolvedSource.load(from: file)
}
if CommandLine.arguments.contains("--enqueue-only") {
try await enqueueLifecycle(source: source)
return
}
let suite = "terebi.resolved-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
@@ -97,6 +101,7 @@ struct ResolvedPlaybackCheck {
if !input.hasPrefix("https://") {
model.shutdown()
try await streamQueueLifecycle(source: source)
try await enqueueLifecycle(source: source)
}
}
@@ -203,6 +208,130 @@ struct ResolvedPlaybackCheck {
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, 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: [])
}, miruroResolver: { _, _ in episodeRequests += 1; return source })
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
}
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")
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 && requests == 1 && episodeRequests == 0,
"repeated native delivery appends while playing without reopening current media")
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: "https://example.com/unsupported")!, 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.fileName == source.title &&
model.queue.currentURL == second
}
model.advanceQueue(1)
try await until("enqueued YouTube item decodes", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == third.absoluteString
}
model.advanceQueue(-1)
try await until("enqueued Miruro report revisits", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == source.title
}
model.advanceQueue(-1)
try await until("original single video remains navigable", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == first.absoluteString
}
model.shutdown()
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: [])
})
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
}
print("PASS: repeated enqueue while paused/playing, standalone navigation, atomic conflicts, independent reports and empty-player start")
}
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)