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)
|
||||
|
||||
@@ -23,7 +23,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
|
||||
|
||||
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
|
||||
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop a supported link. YouTube supports individual videos, not playlists or channels. Miruro links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number; arbitrary streaming sites are not supported.
|
||||
- **Stream queues:** integrations can supply an ordered YouTube/Miruro queue with `--new-instance --queue-file /absolute/queue.json`. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Only the selected item resolves, including fresh resolution on revisits; local-file batches still use natural filename order.
|
||||
- **Stream queues:** integrations can supply an ordered YouTube/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Only the selected item resolves, including fresh resolution on revisits; local-file batches still use natural filename order.
|
||||
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints.
|
||||
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically.
|
||||
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
||||
@@ -60,6 +60,7 @@ Cast output is limited to 1080p/30 fps; frame stepping and YouTube quality chang
|
||||
terebi "/path/to/video.mp4"
|
||||
terebi 'https://youtu.be/jNQXAC9IVRw'
|
||||
terebi --new-instance --no-config "/path/to/video.mp4"
|
||||
terebi --enqueue --queue-file "/absolute/private/session/queue.json"
|
||||
terebi --help
|
||||
```
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
<array><string>public.movie</string><string>public.audio</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array><dict>
|
||||
<key>CFBundleURLName</key><string>Stream queue handoff</string>
|
||||
<key>CFBundleURLSchemes</key><array><string>terebi-enqueue</string></array>
|
||||
</dict></array>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
<key>NSPrincipalClass</key><string>NSApplication</string>
|
||||
<key>NSLocalNetworkUsageDescription</key><string>Discover Cast devices and stream your selected media to a TV on your local network.</string>
|
||||
|
||||
@@ -27,6 +27,10 @@ struct PlaybackQueue {
|
||||
currentID = items.first?.id
|
||||
}
|
||||
|
||||
mutating func appendStreams(_ entries: [(url: URL, label: String)]) {
|
||||
items.append(contentsOf: entries.map { Item(url: $0.url, label: $0.label) })
|
||||
}
|
||||
|
||||
mutating func updateCurrentLabel(_ label: String) {
|
||||
guard let index = currentIndex else { return }
|
||||
items[index].label = label
|
||||
|
||||
@@ -18,6 +18,26 @@ struct StreamQueue: Sendable {
|
||||
private struct InvalidQueue: LocalizedError {
|
||||
var errorDescription: String? { "Invalid stream queue: expected version 1, 1–4096 unique supported videos and new absolute report files in private owned directories." }
|
||||
}
|
||||
// Explicit-app URL delivery reaches both cold launches and already running players.
|
||||
static func enqueueURL(for file: URL) -> URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = "terebi-enqueue"
|
||||
components.host = "queue"
|
||||
components.queryItems = [URLQueryItem(name: "file", value: file.path)]
|
||||
return components.url!
|
||||
}
|
||||
|
||||
static func enqueueFile(from url: URL) throws -> URL {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
components.scheme == "terebi-enqueue", components.host == "queue",
|
||||
components.path.isEmpty, components.user == nil, components.password == nil,
|
||||
components.port == nil, components.fragment == nil,
|
||||
let query = components.queryItems, query.count == 1, query[0].name == "file",
|
||||
let path = query[0].value, path.hasPrefix("/"), !path.contains("\0") else {
|
||||
throw InvalidQueue()
|
||||
}
|
||||
return URL(fileURLWithPath: path).standardizedFileURL
|
||||
}
|
||||
|
||||
static func load(from file: URL) throws -> StreamQueue {
|
||||
do {
|
||||
|
||||
+85
-21
@@ -50,6 +50,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var queuePlaybackRevision: UInt64?
|
||||
private var queueCompletionHandled = false
|
||||
private var queueCastDevice: CastDevice?
|
||||
private var resolvedQueueSource: (id: UUID, source: ResolvedSource)?
|
||||
var canPreviousQueueItem: Bool { canOpen && queue.canPrevious }
|
||||
var canNextQueueItem: Bool { canOpen && queue.canNext }
|
||||
private var transition = PlaybackTransition()
|
||||
@@ -218,24 +219,8 @@ final class PlayerModel: ObservableObject {
|
||||
progressInitializationError = "Could not create the media player to initialize progress reporting."
|
||||
}
|
||||
}
|
||||
if let streamQueue {
|
||||
reportingErrorBuffer = [CChar](repeating: 0, count: 512)
|
||||
for entry in streamQueue.entries {
|
||||
guard let player else {
|
||||
progressInitializationError = "Could not create the media player to initialize queue reports."
|
||||
break
|
||||
}
|
||||
let registered = entry.identity.withCString { identity in
|
||||
entry.progressFile.path.withCString { path in
|
||||
player_register_progress_file(player, identity, path, &reportingErrorBuffer)
|
||||
}
|
||||
}
|
||||
if !registered {
|
||||
progressInitializationError = reportingErrorMessage
|
||||
break
|
||||
}
|
||||
reportingEnabled = true
|
||||
}
|
||||
if let streamQueue, !registerQueueReports(streamQueue) {
|
||||
progressInitializationError = reportingErrorMessage
|
||||
}
|
||||
compactWindow.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -380,6 +365,12 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
showsYouTubeInput = false
|
||||
clearQueue()
|
||||
let url: URL
|
||||
switch stream {
|
||||
case .youtube(let canonical): url = canonical
|
||||
case .miruro: url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
|
||||
}
|
||||
queue.replaceStreams([(url: url, label: url.absoluteString)])
|
||||
resolveStream(stream, change: .open)
|
||||
}
|
||||
|
||||
@@ -393,6 +384,69 @@ final class PlayerModel: ObservableObject {
|
||||
openQueueSelection(keepingReceiver: false)
|
||||
}
|
||||
|
||||
private func registerQueueReports(_ streamQueue: StreamQueue) -> Bool {
|
||||
reportingErrorBuffer = [CChar](repeating: 0, count: 512)
|
||||
guard let handle else { return false }
|
||||
let identities = streamQueue.entries.map { strdup($0.identity) }
|
||||
let paths = streamQueue.entries.map { strdup($0.progressFile.path) }
|
||||
defer {
|
||||
for value in identities { free(value) }
|
||||
for value in paths { free(value) }
|
||||
}
|
||||
let identityPointers = identities.map { $0.map { UnsafePointer($0) } }
|
||||
let pathPointers = paths.map { $0.map { UnsafePointer($0) } }
|
||||
let accepted = player_register_progress_files(handle, identityPointers, pathPointers,
|
||||
Int32(streamQueue.entries.count), &reportingErrorBuffer)
|
||||
if accepted { reportingEnabled = true }
|
||||
return accepted
|
||||
}
|
||||
|
||||
func enqueueStreamQueue(_ streamQueue: StreamQueue) {
|
||||
guard canOpen else {
|
||||
commandError = "Could not create the media player to enqueue streams."
|
||||
return
|
||||
}
|
||||
let existing = Set(queue.items.compactMap { item -> String? in
|
||||
guard !item.url.isFileURL else { return nil }
|
||||
if let stream = try? StreamURL.parse(item.url.absoluteString) {
|
||||
switch stream {
|
||||
case .youtube(let url): return url.absoluteString
|
||||
case .miruro(let id, let episode): return "miruro:\(id):\(episode)"
|
||||
}
|
||||
}
|
||||
return item.url.absoluteString
|
||||
})
|
||||
guard !streamQueue.entries.contains(where: { existing.contains($0.identity) }) else {
|
||||
commandError = "Cannot enqueue a video already present in this queue; reports cannot be rebound."
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
guard registerQueueReports(streamQueue) else {
|
||||
commandError = reportingErrorMessage
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
let needsStart = queue.currentID == nil && currentMedia == nil && !resolvingStream
|
||||
queue.appendStreams(streamQueue.entries.map { (url: $0.url, label: $0.label) })
|
||||
commandError = nil
|
||||
showsQueue = queue.items.count > 1
|
||||
if needsStart, let first = queue.items.first {
|
||||
queue.select(first.id)
|
||||
if fileName == nil { configureStartWindow(showing: false) }
|
||||
openQueueSelection(keepingReceiver: false)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveEnqueueURL(_ url: URL) {
|
||||
do {
|
||||
let file = try StreamQueue.enqueueFile(from: url)
|
||||
enqueueStreamQueue(try StreamQueue.load(from: file))
|
||||
} catch {
|
||||
commandError = error.localizedDescription
|
||||
revealControls()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveStream(_ stream: StreamURL, change: MediaChange) {
|
||||
replaceMetadata(for: nil)
|
||||
switch stream {
|
||||
@@ -405,6 +459,8 @@ final class PlayerModel: ObservableObject {
|
||||
do {
|
||||
let source = try ResolvedSource.load(from: file)
|
||||
clearQueue()
|
||||
queue.replaceStreams([(url: URL(string: source.identity)!, label: source.title)])
|
||||
resolvedQueueSource = (queue.currentID!, source)
|
||||
cancelStreamResolution()
|
||||
openMedia(.resolved(source))
|
||||
} catch {
|
||||
@@ -761,7 +817,9 @@ final class PlayerModel: ObservableObject {
|
||||
queueCompletionHandled = true
|
||||
cancelStreamResolution()
|
||||
let change = MediaChange.queue(playing: receiver == nil)
|
||||
if url.isFileURL {
|
||||
if let original = resolvedQueueSource, queue.currentID == original.id {
|
||||
openMedia(.resolved(original.source), change: change)
|
||||
} else if url.isFileURL {
|
||||
openMedia(.file(url), change: change)
|
||||
} else {
|
||||
do { resolveStream(try StreamURL.parse(url.absoluteString), change: change) }
|
||||
@@ -773,6 +831,7 @@ final class PlayerModel: ObservableObject {
|
||||
private func clearQueue() {
|
||||
queue.replace([])
|
||||
queuePlaybackRevision = nil
|
||||
resolvedQueueSource = nil
|
||||
queueCompletionHandled = false
|
||||
queueCastDevice = nil
|
||||
}
|
||||
@@ -3542,7 +3601,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
@objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||||
guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return }
|
||||
PlayerModel.shared.openStream(text)
|
||||
guard let url = URL(string: text) else { return }
|
||||
if url.scheme == "terebi-enqueue" { PlayerModel.shared.receiveEnqueueURL(url) }
|
||||
else { PlayerModel.shared.openStream(text) }
|
||||
}
|
||||
|
||||
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||||
@@ -3568,7 +3629,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
// Let SwiftUI create the initial window for a Finder/document launch.
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
if urls.allSatisfy(\.isFileURL) { PlayerModel.shared.openFiles(urls) }
|
||||
if urls.contains(where: { $0.scheme == "terebi-enqueue" }) {
|
||||
guard urls.count == 1, let url = urls.first else { return }
|
||||
PlayerModel.shared.receiveEnqueueURL(url)
|
||||
} else if urls.allSatisfy(\.isFileURL) { PlayerModel.shared.openFiles(urls) }
|
||||
else if let url = urls.first { PlayerModel.shared.open(url) }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ enum PlayerCommand {
|
||||
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
case launchQueue(queueFile: URL, newInstance: Bool, noConfig: Bool)
|
||||
case enqueue(queueFile: URL, noConfig: Bool)
|
||||
|
||||
static let usage = """
|
||||
Usage: terebi [--new-instance] [--no-config] [FILE | STREAM_URL]
|
||||
@@ -13,6 +14,7 @@ enum PlayerCommand {
|
||||
terebi --new-instance --url STREAM_URL --progress-file PATH
|
||||
terebi --new-instance --source-file PATH [--progress-file PATH]
|
||||
terebi --new-instance [--no-config] --queue-file PATH
|
||||
terebi --enqueue [--no-config] --queue-file PATH
|
||||
terebi [--new-instance] [--no-config] -- FILE
|
||||
terebi --help
|
||||
terebi --default-config
|
||||
@@ -26,6 +28,8 @@ enum PlayerCommand {
|
||||
--url URL Open a YouTube video or Miruro episode URL.
|
||||
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
|
||||
--queue-file PATH Open an ordered version-1 stream queue with per-item reports.
|
||||
--enqueue Append a queue to the running player without interrupting playback;
|
||||
launch and start its first item if no player is running.
|
||||
--progress-file PATH Write JSONL playback facts to a new private file; requires
|
||||
--new-instance. The GUI owns reporting after this CLI exits.
|
||||
-- Treat the following argument as a filename, even with a leading '-'.
|
||||
@@ -58,16 +62,23 @@ enum PlayerCommand {
|
||||
}
|
||||
var newInstance = false
|
||||
var noConfig = false
|
||||
while let option = inputs.first, option == "--new-instance" || option == "--no-config" {
|
||||
var enqueue = false
|
||||
while let option = inputs.first, ["--new-instance", "--no-config", "--enqueue"].contains(option) {
|
||||
if option == "--new-instance" {
|
||||
guard !newInstance else { throw ArgumentError("--new-instance may only be supplied once.") }
|
||||
newInstance = true
|
||||
} else if option == "--enqueue" {
|
||||
guard !enqueue else { throw ArgumentError("--enqueue may only be supplied once.") }
|
||||
enqueue = true
|
||||
} else {
|
||||
guard !noConfig else { throw ArgumentError("--no-config may only be supplied once.") }
|
||||
noConfig = true
|
||||
}
|
||||
inputs.removeFirst()
|
||||
}
|
||||
guard !enqueue || (!newInstance && progressFile == nil && inputs.first == "--queue-file") else {
|
||||
throw ArgumentError("--enqueue requires --queue-file and cannot combine with --new-instance, --progress-file or other media.")
|
||||
}
|
||||
func launch(_ url: URL?) throws -> PlayerCommand {
|
||||
guard progressFile == nil || newInstance else {
|
||||
throw ArgumentError("--progress-file requires --new-instance.")
|
||||
@@ -94,12 +105,13 @@ enum PlayerCommand {
|
||||
return .printDefaultConfig
|
||||
}
|
||||
if inputs.first == "--queue-file" {
|
||||
guard newInstance, progressFile == nil, inputs.count == 2,
|
||||
guard newInstance || enqueue, progressFile == nil, inputs.count == 2,
|
||||
inputs[1].hasPrefix("/"), !inputs[1].contains("\0") else {
|
||||
throw ArgumentError("--queue-file requires --new-instance and one absolute manifest path; it cannot be combined with --progress-file.")
|
||||
throw ArgumentError("--queue-file requires --new-instance or --enqueue and one absolute manifest path; it cannot be combined with --progress-file.")
|
||||
}
|
||||
let queueFile = URL(fileURLWithPath: inputs[1]).standardizedFileURL
|
||||
_ = try StreamQueue.load(from: queueFile)
|
||||
if enqueue { return .enqueue(queueFile: queueFile, noConfig: noConfig) }
|
||||
return .launchQueue(queueFile: queueFile, newInstance: newInstance, noConfig: noConfig)
|
||||
}
|
||||
if inputs.first == "--source-file" {
|
||||
|
||||
@@ -24,6 +24,9 @@ enum TerebiCLI {
|
||||
case .launchQueue(let queueFile, let newInstance, let noConfig):
|
||||
await launch(url: nil, sourceFile: nil, newInstance: newInstance, noConfig: noConfig,
|
||||
progressFile: nil, queueFile: queueFile)
|
||||
case .enqueue(let queueFile, let noConfig):
|
||||
await launch(url: StreamQueue.enqueueURL(for: queueFile), sourceFile: nil,
|
||||
newInstance: false, noConfig: noConfig, progressFile: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-10
@@ -197,7 +197,7 @@ struct Player {
|
||||
PlayerStatus status = PLAYER_EMPTY;
|
||||
bool previewShown = false;
|
||||
std::array<char, 512> error{};
|
||||
std::unique_ptr<playback::ProgressReports> reporter;
|
||||
const std::unique_ptr<playback::ProgressReports> reporter = std::make_unique<playback::ProgressReports>();
|
||||
bool remoteReporting = false;
|
||||
std::thread worker;
|
||||
|
||||
@@ -1429,10 +1429,10 @@ bool player_set_progress_file(Player *player, const char *path, char *error) {
|
||||
try {
|
||||
if (!player || !path || !*path) throw std::runtime_error("A progress file path is required");
|
||||
std::lock_guard<std::mutex> lock(player->mutex);
|
||||
if (player->reporter || player->stopping.load(std::memory_order_relaxed) ||
|
||||
if (player->stopping.load(std::memory_order_relaxed) ||
|
||||
player->state.status != PLAYER_EMPTY || !player->commands.empty())
|
||||
throw std::runtime_error("Progress reporting must be initialized once, before opening media");
|
||||
player->reporter = std::make_unique<playback::ProgressReports>(path);
|
||||
player->reporter->setSingleFile(path);
|
||||
return true;
|
||||
} catch (const std::exception &failure) {
|
||||
if (error) copyError(error, failure.what());
|
||||
@@ -1441,16 +1441,15 @@ bool player_set_progress_file(Player *player, const char *path, char *error) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool player_register_progress_file(Player *player, const char *identity, const char *path, char *error) {
|
||||
bool player_register_progress_files(Player *player, const char *const *identities,
|
||||
const char *const *paths, int count, char *error) {
|
||||
if (error) error[0] = '\0';
|
||||
try {
|
||||
if (!player || !identity || !path) throw std::runtime_error("Queue progress identity and path are required");
|
||||
if (!player) throw std::runtime_error("A player is required for queue progress registration");
|
||||
std::lock_guard<std::mutex> lock(player->mutex);
|
||||
if (player->stopping.load(std::memory_order_relaxed) ||
|
||||
player->state.status != PLAYER_EMPTY || !player->commands.empty())
|
||||
throw std::runtime_error("Queue reports must be registered before opening media");
|
||||
if (!player->reporter) player->reporter = std::make_unique<playback::ProgressReports>();
|
||||
player->reporter->registerFile(identity, path);
|
||||
if (player->stopping.load(std::memory_order_relaxed))
|
||||
throw std::runtime_error("Cannot register queue reports while the player is stopping");
|
||||
player->reporter->registerFiles(identities, paths, count);
|
||||
return true;
|
||||
} catch (const std::exception &failure) {
|
||||
if (error) copyError(error, failure.what());
|
||||
|
||||
@@ -40,9 +40,11 @@ Player *player_create(void);
|
||||
// Opt-in reporting must be initialized before opening media. Creates a new 0600
|
||||
// file exclusively; errors never change playback state. Error buffers hold 512 bytes.
|
||||
bool player_set_progress_file(Player *player, const char *path, char *error);
|
||||
// Register immutable queue destinations before opening any media. Each path is new;
|
||||
// revisits append only to the original owned inode, and unmatched identities are not reported.
|
||||
bool player_register_progress_file(Player *player, const char *identity, const char *path, char *error);
|
||||
// Register immutable queue destinations without changing current playback. Each path is new;
|
||||
// revisits append only to the original owned inode. Conflicting identities/paths are rejected.
|
||||
// Atomic batch: all registrations succeed or none do. Strings are copied before returning.
|
||||
bool player_register_progress_files(Player *player, const char *const *identities,
|
||||
const char *const *paths, int count, char *error);
|
||||
bool player_get_progress_error(Player *player, char *error);
|
||||
// Stops the worker and drains final reporting, allowing errors to be shown before
|
||||
// destroying the handle. Idempotent; do not call from an audio callback.
|
||||
|
||||
@@ -289,32 +289,72 @@ void ProgressReporter::finish() noexcept {
|
||||
if (writer.joinable()) writer.join();
|
||||
}
|
||||
|
||||
ProgressReports::ProgressReports(const std::string &path)
|
||||
: single(std::make_unique<ProgressReporter>(path)), active(single.get()) {}
|
||||
ProgressReports::ProgressReports(const std::string &path) { setSingleFile(path); }
|
||||
|
||||
void ProgressReports::setSingleFile(const std::string &path) {
|
||||
std::lock_guard lock(mutex);
|
||||
if (single || !entries.empty()) throw std::runtime_error("Progress reporting is already initialized");
|
||||
single = std::make_unique<ProgressReporter>(path);
|
||||
singlePath = path;
|
||||
active = single.get();
|
||||
}
|
||||
|
||||
void ProgressReports::registerFile(const std::string &identity, const std::string &path) {
|
||||
const char *identities[] = {identity.c_str()}, *destinations[] = {path.c_str()};
|
||||
registerFiles(identities, destinations, 1);
|
||||
}
|
||||
|
||||
void ProgressReports::registerFiles(const char *const *identities, const char *const *destinations, int count) {
|
||||
std::lock_guard lock(mutex);
|
||||
if (single || !ProgressReporter::validMediaIdentity(identity) || entries.size() >= 4096 ||
|
||||
path.empty() || path.front() != '/' || path.find('\0') != std::string::npos ||
|
||||
entries.contains(identity))
|
||||
throw std::runtime_error("Invalid or duplicate queue progress registration");
|
||||
if (!paths.insert(path).second) throw std::runtime_error("Duplicate queue progress report path");
|
||||
entries.emplace(identity, Entry{path, nullptr});
|
||||
if (!identities || !destinations || count < 1 || count > 4096 ||
|
||||
entries.size() + count > 4096)
|
||||
throw std::runtime_error("Queue reports exceed the 4096 retained registration limit");
|
||||
std::map<std::string, Entry> additions;
|
||||
std::set<std::string> newPaths;
|
||||
for (int index = 0; index < count; ++index) {
|
||||
const auto *identity = identities[index], *path = destinations[index];
|
||||
if (!identity || !path || !ProgressReporter::validMediaIdentity(identity) ||
|
||||
!*path || *path != '/' || singleIdentity == identity || entries.contains(identity) || additions.contains(identity))
|
||||
throw std::runtime_error("Invalid or conflicting queue progress identity");
|
||||
if (singlePath == path || paths.contains(path) || !newPaths.insert(path).second)
|
||||
throw std::runtime_error("Conflicting queue progress report path");
|
||||
additions.emplace(identity, Entry{path, nullptr});
|
||||
}
|
||||
// Node merges allocate nothing: failed validation cannot partially register a batch.
|
||||
entries.merge(additions);
|
||||
paths.merge(newPaths);
|
||||
}
|
||||
|
||||
void ProgressReports::observe(const PlayerState &state) noexcept {
|
||||
std::lock_guard lock(mutex);
|
||||
if (active) active->observe(state);
|
||||
}
|
||||
|
||||
void ProgressReports::closeMedia() noexcept {
|
||||
std::lock_guard lock(mutex);
|
||||
if (active) active->closeMedia();
|
||||
}
|
||||
|
||||
void ProgressReports::begin(const std::string &identity, bool live) noexcept {
|
||||
std::lock_guard lock(mutex);
|
||||
if (single) { active->begin(identity, live); return; }
|
||||
const auto found = entries.find(identity);
|
||||
auto *next = found == entries.end() ? nullptr : found->second.reporter.get();
|
||||
const bool useSingle = found == entries.end() && single &&
|
||||
(entries.empty() || singleIdentity.empty() || singleIdentity == identity);
|
||||
auto *next = found == entries.end() ? (useSingle ? single.get() : nullptr) : found->second.reporter.get();
|
||||
if (active != next && active) active->finish();
|
||||
active = nullptr; // A failed or unregistered identity must never reuse the old report.
|
||||
if (found == entries.end()) return;
|
||||
if (found == entries.end() && !useSingle) return;
|
||||
try {
|
||||
auto &entry = found->second;
|
||||
if (!entry.reporter) entry.reporter = std::make_unique<ProgressReporter>(entry.path);
|
||||
else entry.reporter->resume();
|
||||
active = entry.reporter.get();
|
||||
if (found == entries.end()) {
|
||||
singleIdentity = identity;
|
||||
single->resume();
|
||||
active = single.get();
|
||||
} else {
|
||||
auto &entry = found->second;
|
||||
if (!entry.reporter) entry.reporter = std::make_unique<ProgressReporter>(entry.path);
|
||||
else entry.reporter->resume();
|
||||
active = entry.reporter.get();
|
||||
}
|
||||
active->begin(identity, live);
|
||||
} catch (const std::exception &exception) {
|
||||
try { if (failure.empty()) failure = exception.what(); } catch (...) {}
|
||||
@@ -331,7 +371,10 @@ void ProgressReports::finish() noexcept {
|
||||
std::string ProgressReports::error() const {
|
||||
std::lock_guard lock(mutex);
|
||||
if (!failure.empty()) return failure;
|
||||
if (single) return single->error();
|
||||
if (single) {
|
||||
const auto message = single->error();
|
||||
if (!message.empty()) return message;
|
||||
}
|
||||
for (const auto &[identity, entry] : entries) {
|
||||
if (entry.reporter) {
|
||||
auto message = entry.reporter->error();
|
||||
|
||||
@@ -73,15 +73,19 @@ class ProgressReports {
|
||||
public:
|
||||
explicit ProgressReports(const std::string &path);
|
||||
ProgressReports() = default;
|
||||
void setSingleFile(const std::string &path);
|
||||
void registerFile(const std::string &identity, const std::string &path);
|
||||
void registerFiles(const char *const *identities, const char *const *paths, int count);
|
||||
void begin(const std::string &identity, bool live = false) noexcept;
|
||||
void observe(const PlayerState &state) noexcept { if (active) active->observe(state); }
|
||||
void closeMedia() noexcept { if (active) active->closeMedia(); }
|
||||
void observe(const PlayerState &state) noexcept;
|
||||
void closeMedia() noexcept;
|
||||
void finish() noexcept;
|
||||
std::string error() const;
|
||||
private:
|
||||
struct Entry { std::string path; std::unique_ptr<ProgressReporter> reporter; };
|
||||
std::unique_ptr<ProgressReporter> single;
|
||||
std::string singlePath;
|
||||
std::string singleIdentity;
|
||||
std::map<std::string, Entry> entries;
|
||||
std::set<std::string> paths;
|
||||
ProgressReporter *active = nullptr;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
- [ ] in the playlist sidebar, pressing the `+` button by the queue indicator should it expand it to double it's size (with a nice transition) split down the middle with the left side being a file icon and the right side being a link icon.
|
||||
- pressing the file icon should open the file picker for local file selection (current behavior)
|
||||
- pressing the link icon should present an input field that opens upwards with a nice transition where the user can paste a link; pressing return will fetch and add the video.
|
||||
- we already have this ui effect in the start screen when pressing the "open url" button, which we should be able to reuse much of.
|
||||
- we already have this ui effect in the start screen when pressing the "open url" button, which we should be able to reuse much of.
|
||||
- [ ] in compact mode, "side"bar should be expandable downwards (but hidden by default).
|
||||
- let's also add a keybinding that toggles sidebar visibility in both normal and compact mode.
|
||||
@@ -29,6 +29,8 @@ Create a private reporting-session directory (`0700`) and supply a **nonexistent
|
||||
|
||||
```sh
|
||||
terebi --new-instance --queue-file '/absolute/private/session/queue.json'
|
||||
# Append to the running player, or launch and play if none is running:
|
||||
terebi --enqueue --queue-file '/absolute/private/session/queue.json'
|
||||
```
|
||||
|
||||
The manifest is bounded to 8 MiB, version 1, and 1–4096 entries in playback order:
|
||||
@@ -43,15 +45,17 @@ The manifest is bounded to 8 MiB, version 1, and 1–4096 entries in playback or
|
||||
}
|
||||
```
|
||||
|
||||
Use canonical YouTube video pages or supported Miruro episode pages, never expiring source addresses. Stable identities must be unique even across alternate YouTube forms or different Miruro slugs. Reports must have distinct absolute nonexistent destinations, each under a canonical, nonsymlink, user-owned `0700` directory. Tubular retains each item's existing identity registration/session and stores the manifest in the first session. The CLI validates the complete document before launching; the GUI validates it again before opening any media. Invalid queues fail rather than falling back to a single stream. Queue launch requires `--new-instance`, accepts `--no-config`, and cannot combine with `--url`, `--source-file` or `--progress-file`.
|
||||
Use canonical YouTube video pages or supported Miruro episode pages, never expiring source addresses. Stable identities must be unique even across alternate YouTube forms or different Miruro slugs. Reports must have distinct absolute nonexistent destinations, each under a canonical, nonsymlink, user-owned `0700` directory. Tubular retains each item's existing identity registration/session and stores the manifest in the first session. The CLI validates the complete document before launching; the GUI validates it again before opening any media. Invalid queues fail rather than falling back to a single stream. Queue launch requires either `--new-instance` (replace in a separate player) or `--enqueue` (append to the running player), accepts `--no-config`, and cannot combine with `--url`, `--source-file` or `--progress-file`. `--enqueue` and `--new-instance` are mutually exclusive.
|
||||
|
||||
The supplied order is preserved (local-file batches still use natural filename order). Existing sidebar reorder/remove/play and previous/next navigation apply. Natural end advances without clearing the queue. Sources resolve only when selected, resolve afresh on revisits, and canceled/stale results cannot replace a newer selection. Labels start with descriptive page information and update from resolved titles/optional metadata.
|
||||
|
||||
Each report is immutably bound to its stable identity in the native decoder. Switching entries drains/closes the previous writer before beginning the next; revisits append only after verifying the original private report inode, owner, permissions and link count. An unrelated or replaced file is never reopened as an owned session, and an unregistered identity is never written to the previously selected report. Only the active item owns a running writer/open report descriptor. A reporting failure is shown separately without rerouting facts or changing acknowledged playback/completion semantics. Single-stream `--progress-file` remains unchanged.
|
||||
Enqueue uses native macOS URL-open delivery to the explicit application, not launch arguments: repeated requests reach the existing process. Both one-item and multi-item requests use the same manifest. The current selection, playback/pause state, timeline, pending resolution and metadata stay intact; an empty player selects and starts the first entry. Original single-file, single-stream and resolved-source playback remain navigable when items are appended. Enqueue never silently deduplicates: any identity already in the queue or retained report registrations, or any report-path collision, rejects the complete batch with an error in the player. Removed items retain their report registrations for the process lifetime; at most 4096 report identities may be registered. Existing files are never adopted as reporting sessions. Keep manifests and private sessions available until delivery; CLI success acknowledges macOS delivery, not GUI acceptance or playback.
|
||||
|
||||
Runnable queue checks: `make check-cli check-queue check-reporting check-resolved`. The existing `check-resolved` local HTTP/HLS/VTT harness also drives a real native `PlayerModel` through a mixed YouTube/Miruro queue with injected fixture resolution, stale-result cancellation, manual navigation, natural-end advancement, fresh revisits/resume and isolated v1/v2 reports. Run `TEREBI_CHECK_STREAM_QUEUE_ONLY=1 make check-startup` for focused actual CLI→LaunchServices→GUI queue delivery, native decode, manual previous/next, natural-end advancement and per-item report revisits using the existing bundled-extractor/local-media fixture. These checks require no browser or live streaming service. Full `make check-startup` includes this queue scenario too.
|
||||
Each report is immutably bound to its stable identity in the native decoder. Registration is atomic for a complete batch and can occur during playback without redirecting its current report. Switching entries drains/closes the previous writer before beginning the next; revisits append only after verifying the original private report inode, owner, permissions and link count. An unrelated or replaced file is never reopened as an owned session, and an unregistered identity is never written to a previously selected queue report. Only the active item owns a running writer/open report descriptor. A reporting failure is shown separately without rerouting facts or changing acknowledged playback/completion semantics. An original single-stream `--progress-file` keeps its destination when navigating back from appended queue entries.
|
||||
|
||||
Current local verification: `make check-cli check-queue check-reporting check-resolved all` passed. The packaged queue GUI smoke did not complete because native state snapshots stalled; no complete GUI pass is claimed. Temporary tracing was removed, and further UI testing is deferred to the user at their request. Tubular's handoff document records its terminal/capture and Rust checks separately.
|
||||
Runnable headless queue checks: `make check-cli check-queue check-reporting check-resolved`. The existing `check-resolved` local HTTP/HLS/VTT harness drives a real native `PlayerModel` through mixed YouTube/Miruro queues with injected fixture resolution, stale-result cancellation, manual navigation, natural-end advancement, fresh revisits/resume and isolated v1/v2 reports. It also delivers repeated enqueue URLs while paused and playing, checks original standalone selection/timeline preservation, lazy append ordering, atomic invalid/conflicting requests, independent single/queue report revisits and empty-player startup. These checks require no browser or live streaming service. Actual LaunchServices/UI delivery remains a separate user-owned verification step; no GUI smoke result is claimed for enqueue.
|
||||
|
||||
Enqueue verification: `check-cli`, `check-queue`, `check-reporting` and `make all` passed. An added reporting regression failed before protecting the original single-video identity from rebinding and passed afterward; unknown identities also cannot leak into that report after queue registration. The full `check-resolved` run stopped in the older mixed-queue natural-end scenario with `Could not start Core Audio (560227702)`, before enqueue ran. A focused `.build/resolved-playback-check /absolute/source.json --enqueue-only` smoke using local video-only HLS passed paused/playing append, unchanged current revision/selection, lazy ordering, natural-end advancement, navigation/revisits, atomic conflicts/rollback, isolated reports and empty-player startup. The source document still requires `headers` and `subtitles` (empty values are valid). No full A/V-suite or packaged-GUI/LaunchServices pass is claimed; the app was built, not installed. Actual UI confirmation remains user-owned; temporary fixture/server files were removed. Tubular's handoff document records its terminal/capture and Rust checks separately.
|
||||
|
||||
### Optional native metadata
|
||||
|
||||
|
||||
Reference in New Issue
Block a user