feat: enqueue streams in running player
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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.; });
|
||||
|
||||
@@ -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