feat: enqueue streams in running player
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user