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
+9
View File
@@ -118,6 +118,15 @@ struct PlaybackQueueChecks {
queue.move(fromOffsets: IndexSet(integer: 0), toOffset: 2)
require(queue.currentID == streamID && queue.items.map(\.url) == [youtube, miruro], "stream reorder preserves selection")
require(queue.remove(streamID) == nil && queue.items.map(\.url) == [youtube], "removing current final stream stops")
let retainedID = queue.currentID
queue.appendStreams([(url: miruro, label: "Last episode"), (url: youtube, label: "Last video")])
require(queue.items.map(\.url) == [youtube, miruro, youtube] && queue.currentID == retainedID,
"stream append retains stopped selection and exact caller order")
queue.select(queue.items[0].id)
let playingID = queue.currentID
queue.appendStreams([(url: miruro, label: "Again")])
require(queue.currentID == playingID && queue.items.last?.url == miruro,
"stream append preserves active identity")
print("playback queue checks passed")
}
}
+22
View File
@@ -154,6 +154,19 @@ struct PlayerCommandChecks {
fatalError("FAIL: expected stream queue launch")
}
require(deliveredQueue == queueFile && queueIndependent && queueBypass, "queue CLI preserves independent delivery")
guard case .enqueue(let appendedQueue, let enqueueBypass) =
try PlayerCommand.parse(["--enqueue", "--no-config", "--queue-file", queueFile.path]) else {
fatalError("FAIL: expected running-player queue delivery")
}
require(appendedQueue == queueFile && enqueueBypass, "enqueue forwards its manifest without requiring a new instance")
let escapedFile = privateDirectory.appendingPathComponent("queue & #?.json")
require(try StreamQueue.enqueueFile(from: StreamQueue.enqueueURL(for: escapedFile)) == escapedFile,
"native URL delivery round trips absolute paths and URL metacharacters")
for text in ["terebi-enqueue://queue?file=relative.json", "terebi-enqueue://other?file=/tmp/a",
"terebi-enqueue://queue?file=/tmp/a&file=/tmp/b", "terebi-enqueue://queue?file=/tmp/a#extra"] {
do { _ = try StreamQueue.enqueueFile(from: URL(string: text)!); fatalError("FAIL: invalid enqueue URL accepted") }
catch {}
}
let parsedQueue = try StreamQueue.load(from: queueFile)
require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
@@ -180,6 +193,10 @@ struct PlayerCommandChecks {
_ = try PlayerCommand.parse(["--new-instance", "--queue-file", queueFile.path])
fatalError("FAIL: malformed queue must never launch or degrade to single playback")
} catch {}
do {
_ = try PlayerCommand.parse(["--enqueue", "--queue-file", queueFile.path])
fatalError("FAIL: malformed enqueue must never launch or partially deliver")
} catch {}
}
try saveQueue(["version": 1, "items": entries])
for arguments in [
@@ -187,6 +204,11 @@ struct PlayerCommandChecks {
["--new-instance", "--queue-file", "queue.json"],
["--new-instance", "--queue-file", queueFile.path, "--url", canonical.absoluteString],
["--new-instance", "--queue-file", queueFile.path, "--progress-file", report.path],
["--enqueue"], ["--enqueue", "--url", canonical.absoluteString],
["--enqueue", "--new-instance", "--queue-file", queueFile.path],
["--enqueue", "--enqueue", "--queue-file", queueFile.path],
["--enqueue", "--queue-file", queueFile.path, "--progress-file", report.path],
["--enqueue", "--queue-file", "relative.json"],
["--new-instance", "--queue-file", queueFile.path, "--queue-file", queueFile.path]
] {
do { _ = try PlayerCommand.parse(arguments); fatalError("FAIL: invalid queue option combination") }
+32
View File
@@ -335,6 +335,38 @@ int main(int argc, char **argv) {
expect(firstRows[0], @"progress", firstURL, 12, true);
expect(firstRows[2], @"progress", firstURL, 24, true);
expect(secondRows[0], @"ended", secondURL, 100, true);
const auto liveSingle = root + "/live-single.jsonl", liveNext = root + "/live-next.jsonl";
const auto rollbackPath = root + "/rollback.jsonl";
{
playback::ProgressReports reports(liveSingle);
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 7));
const char *identities[] = {secondURL.c_str()}, *destinations[] = {liveNext.c_str()};
reports.registerFiles(identities, destinations, 1);
reports.observe(sample(PLAYER_PAUSED, 8));
const char *conflictingIDs[] = {"miruro:new:1", secondURL.c_str()};
const auto unused = root + "/unused.jsonl";
const char *conflictingPaths[] = {rollbackPath.c_str(), unused.c_str()};
bool refused = false;
try { reports.registerFiles(conflictingIDs, conflictingPaths, 2); } catch (...) { refused = true; }
require(refused, "conflicting later entry rejects the complete registration batch");
reports.registerFile("miruro:new:1", rollbackPath);
reports.begin(secondURL);
reports.observe(sample(PLAYER_PAUSED, 3));
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 9));
reports.begin("miruro:unregistered:1");
reports.observe(sample(PLAYER_PAUSED, 55));
refused = false;
try { reports.registerFile(firstURL, unused); } catch (...) { refused = true; }
require(refused, "enqueue cannot rebind the original single identity after its queue row is removed");
reports.finish();
require(reports.error().empty(), "enqueue preserves single reporting and supports owned report revisits");
}
auto singleRows = records(liveSingle), nextRows = records(liveNext);
require(singleRows.count >= 4 && nextRows.count == 2, "active single report survives registration and queued report stays isolated");
for (NSDictionary *row in singleRows) require([row[@"media_url"] isEqual:nativePath(firstURL)], "queued identity never leaks into original single report");
for (NSDictionary *row in nextRows) require([row[@"media_url"] isEqual:nativePath(secondURL)], "queued report has only its registered identity");
const auto replacedPath = root + "/replaced.jsonl";
{
ProgressReporter reporter(replacedPath, [] { return 100.; });
+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)