fix: resolve queued stream metadata in the background
Queue labels were only enriched through selected-entry playback. Resolve unplayed titles serially at utility priority, preempt enrichment for playback, and bind updates to queue item IDs. Verified: make check-resolved check-queue check-youtube all.
This commit is contained in:
@@ -111,7 +111,7 @@ struct PlaybackQueueChecks {
|
||||
require(queue.items.map(\.url) == [miruro, youtube], "stream order is preserved without file filtering or sorting")
|
||||
require(queue.currentItem?.displayName == "Episode 2", "stream rows have descriptive labels")
|
||||
let streamID = queue.currentID!
|
||||
queue.updateCurrentLabel("Resolved episode title")
|
||||
queue.updateLabel("Resolved episode title", for: streamID)
|
||||
require(queue.currentID == streamID && queue.currentItem?.displayName == "Resolved episode title",
|
||||
"metadata updates labels without changing queue identity")
|
||||
require(queue.advance(1) == youtube && queue.advance(-1) == miruro, "stream navigation uses supplied order")
|
||||
|
||||
@@ -50,6 +50,12 @@ struct ResolvedPlaybackCheck {
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let clock = Clock()
|
||||
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil,
|
||||
miruroMetadataResolver: { id, episode in
|
||||
if input.hasPrefix("https://") {
|
||||
return try await MiruroMetadataResolver.resolve(mediaID: id, episode: episode)
|
||||
}
|
||||
return MiruroMetadata(title: nil, intervals: [])
|
||||
},
|
||||
skipClock: { clock.now })
|
||||
defer { model.shutdown() }
|
||||
if input.hasPrefix("https://") { model.openStream(input) }
|
||||
@@ -100,12 +106,118 @@ struct ResolvedPlaybackCheck {
|
||||
if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) }
|
||||
if !input.hasPrefix("https://") {
|
||||
model.shutdown()
|
||||
try await queueMetadataLifecycle(source: source)
|
||||
try await streamQueueLifecycle(source: source)
|
||||
try await enqueueLifecycle(source: source)
|
||||
try await sidebarQueueLifecycle(source: source)
|
||||
}
|
||||
}
|
||||
|
||||
static func queueMetadataLifecycle(source: ResolvedSource) async throws {
|
||||
let suite = "terebi.queue-metadata-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let urls = ["AAAAAAAAAAA", "BBBBBBBBBBB", "CCCCCCCCCCC", "DDDDDDDDDDD"].map {
|
||||
URL(string: "https://www.youtube.com/watch?v=\($0)")!
|
||||
}
|
||||
let episode = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
|
||||
var foreground: CheckedContinuation<YouTubeVideo, Never>?
|
||||
var background: CheckedContinuation<YouTubeVideo, Never>?
|
||||
var holdFirst = true
|
||||
var episodeRequests = 0
|
||||
func video(_ url: URL, title: String) -> YouTubeVideo {
|
||||
YouTubeVideo(title: title, video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
||||
}
|
||||
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
||||
if url == urls[0], holdFirst {
|
||||
holdFirst = false
|
||||
return await withCheckedContinuation { foreground = $0 }
|
||||
}
|
||||
if url == urls[1], Task.currentPriority <= .utility {
|
||||
return await withCheckedContinuation { background = $0 }
|
||||
}
|
||||
if url == urls[2] { throw CocoaError(.fileReadCorruptFile) }
|
||||
return video(url, title: url == urls[0] ? "Playing title" : "Fresh playback title")
|
||||
}, miruroResolver: { _, _ in
|
||||
fatalError("FAIL: sidebar enrichment must not fetch Miruro playback sources")
|
||||
}, miruroMetadataResolver: { _, _ in
|
||||
episodeRequests += 1
|
||||
return MiruroMetadata(title: "Queued episode", intervals: [])
|
||||
})
|
||||
defer { model.shutdown() }
|
||||
func queue(_ entries: [URL]) -> StreamQueue {
|
||||
StreamQueue(entries: entries.map { url in
|
||||
.init(url: url, identity: url == episode ? source.identity : url.absoluteString,
|
||||
progressFile: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString),
|
||||
label: url.absoluteString)
|
||||
})
|
||||
}
|
||||
model.openStreamQueue(queue([urls[0], urls[1], episode, urls[2], urls[3]]))
|
||||
try await until("foreground extraction starts first", model: model) { foreground != nil }
|
||||
require(background == nil && episodeRequests == 0, "sidebar lookups yield to selected extraction")
|
||||
foreground!.resume(returning: video(urls[0], title: "Playing title"))
|
||||
foreground = nil
|
||||
try await until("native playback and background extraction start", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && background != nil
|
||||
}
|
||||
require(episodeRequests == 0, "sidebar lookups are serial")
|
||||
require(model.setPlayback(playing: false), "pause for background title resolution")
|
||||
try await until("metadata smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
let selected = model.queue.currentID
|
||||
let position = model.state.position
|
||||
let pendingID = model.queue.items[1].id
|
||||
model.moveQueueItems(fromOffsets: IndexSet(integer: 1), toOffset: 5)
|
||||
background!.resume(returning: video(urls[1], title: "Queued title"))
|
||||
background = nil
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(2))
|
||||
while model.queue.items.last?.displayName != "Queued title", ContinuousClock.now < deadline {
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
require(model.queue.items.last?.id == pendingID && model.queue.items.last?.displayName == "Queued title",
|
||||
"unplayed queue entry resolves by identity after reorder")
|
||||
try await until("mixed queue metadata continues after failure", model: model) {
|
||||
model.queue.items.first { $0.url == episode }?.displayName == "Queued episode" &&
|
||||
model.queue.items.first { $0.url == urls[3] }?.displayName == "Fresh playback title"
|
||||
}
|
||||
require(model.queue.items.first { $0.url == urls[2] }?.displayName == urls[2].absoluteString &&
|
||||
model.commandError == nil, "failed enrichment preserves fallback and does not block later titles")
|
||||
require(model.queue.currentID == selected &&
|
||||
model.state.position == position && model.state.status == PLAYER_PAUSED &&
|
||||
model.fileName == "Playing title", "background metadata preserves selection, position, paused state and current title")
|
||||
|
||||
model.openStreamQueue(queue([urls[0], urls[1]]))
|
||||
try await until("next background lookup starts", model: model) { background != nil }
|
||||
let cancelled = background!
|
||||
background = nil
|
||||
model.selectQueueItem(model.queue.items[1].id)
|
||||
try await until("selection preempts pending metadata with fresh playback extraction", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == "Fresh playback title"
|
||||
}
|
||||
cancelled.resume(returning: video(urls[1], title: "Stale title"))
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
require(model.queue.currentItem?.displayName == "Fresh playback title" &&
|
||||
model.fileName == "Fresh playback title", "cancelled metadata cannot overwrite selected title")
|
||||
|
||||
model.openStreamQueue(queue([urls[0], urls[1]]))
|
||||
try await until("removable background lookup starts", model: model) { background != nil }
|
||||
let removed = background!
|
||||
background = nil
|
||||
model.removeQueueItem(model.queue.items[1].id)
|
||||
model.openStreamQueue(queue([urls[0], urls[1]]))
|
||||
try await until("replacement background lookup starts", model: model) { background != nil }
|
||||
removed.resume(returning: video(urls[1], title: "Removed title"))
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
require(model.queue.items[1].displayName == urls[1].absoluteString,
|
||||
"removed/replaced item cannot rename a new row for the same URL")
|
||||
background!.resume(returning: video(urls[1], title: "Replacement title"))
|
||||
background = nil
|
||||
try await until("replacement result updates its own row", model: model) {
|
||||
model.queue.items[1].displayName == "Replacement title"
|
||||
}
|
||||
print("PASS: background mixed-stream titles, utility priority, serial lookup, reorder, failure, playback preemption and stale removal/replacement")
|
||||
}
|
||||
|
||||
static func streamQueueLifecycle(source: ResolvedSource) async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("terebi.queue-check." + UUID().uuidString).resolvingSymlinksInPath()
|
||||
@@ -130,15 +242,17 @@ struct ResolvedPlaybackCheck {
|
||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [])
|
||||
let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in
|
||||
youtubeRequests += 1
|
||||
if youtubeRequests == 1 {
|
||||
return await withCheckedContinuation { stale = $0 }
|
||||
if Task.currentPriority > .utility {
|
||||
youtubeRequests += 1
|
||||
if youtubeRequests == 1 {
|
||||
return await withCheckedContinuation { stale = $0 }
|
||||
}
|
||||
}
|
||||
return video
|
||||
}, miruroResolver: { _, _ in
|
||||
episodeRequests += 1
|
||||
return source
|
||||
})
|
||||
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||
defer { model.shutdown() }
|
||||
require(model.progressInitializationError == nil, "queue report registration")
|
||||
model.openStreamQueue(queue)
|
||||
@@ -235,7 +349,8 @@ struct ResolvedPlaybackCheck {
|
||||
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 })
|
||||
}, miruroResolver: { _, _ in episodeRequests += 1; return source },
|
||||
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||
defer { model.shutdown() }
|
||||
func deliver(_ items: [(URL, URL)]) throws {
|
||||
let document: [String: Any] = ["version": 1, "items": items.map {
|
||||
@@ -265,7 +380,7 @@ struct ResolvedPlaybackCheck {
|
||||
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,
|
||||
model.state.status == PLAYER_PLAYING,
|
||||
"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,
|
||||
@@ -383,7 +498,8 @@ struct ResolvedPlaybackCheck {
|
||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
||||
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
|
||||
return video
|
||||
}, miruroResolver: { _, _ in source })
|
||||
}, miruroResolver: { _, _ in source },
|
||||
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||
defer { model.shutdown() }
|
||||
try await model.addStreamToQueue(first.absoluteString)
|
||||
try await until("empty sidebar addition starts fetched video", model: model) {
|
||||
|
||||
@@ -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; **+ → file icon** 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 an HTTP(S) video link. To append without replacing playback, press the sidebar's **+**, then the **link icon**: the URL field opens upwards; **Return** fetches and adds the video. The link icon toggles the field; **Esc** dismisses it and cancels a pending fetch. Fetch errors leave the queue unchanged. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
|
||||
- **Stream queues:** integrations can supply an ordered yt-dlp/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. Manifest entries resolve only when selected, including fresh resolution on revisits; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
||||
- **Stream queues:** integrations can supply an ordered yt-dlp/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. Queued titles load serially in the background at lower priority, yielding during playback loading, buffering and quality changes. Failed lookups retain the supplied label. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
||||
- **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.
|
||||
|
||||
@@ -5,6 +5,7 @@ struct PlaybackQueue {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
var label: String? = nil
|
||||
var metadataAttempted = false
|
||||
var displayName: String { label ?? url.lastPathComponent }
|
||||
}
|
||||
|
||||
@@ -31,9 +32,10 @@ struct PlaybackQueue {
|
||||
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
|
||||
mutating func updateLabel(_ label: String?, for id: UUID) {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
||||
if let label { items[index].label = label }
|
||||
items[index].metadataAttempted = true
|
||||
}
|
||||
|
||||
mutating func append(_ urls: [URL]) {
|
||||
|
||||
@@ -34,7 +34,9 @@ final class PlayerModel: ObservableObject {
|
||||
@Published var showsYouTubeInput = false
|
||||
@Published private(set) var fileIcon: NSImage?
|
||||
@Published private(set) var commandError: String?
|
||||
@Published private(set) var queue = PlaybackQueue()
|
||||
@Published private(set) var queue = PlaybackQueue() {
|
||||
didSet { queueMetadataPending = true }
|
||||
}
|
||||
@Published private(set) var recentMedia: [PlaybackProgress.RecentMedia] = []
|
||||
@Published private(set) var startContentHeight: CGFloat = 400
|
||||
@Published var showsQueue = false {
|
||||
@@ -51,6 +53,8 @@ final class PlayerModel: ObservableObject {
|
||||
private var queueCompletionHandled = false
|
||||
private var queueCastDevice: CastDevice?
|
||||
private var resolvedQueueSource: (id: UUID, source: ResolvedSource)?
|
||||
private var queueMetadataPending = false
|
||||
private var queueMetadataTask: (id: UUID, task: Task<Void, Never>)?
|
||||
var canPreviousQueueItem: Bool { canOpen && queue.canPrevious }
|
||||
var canNextQueueItem: Bool { canOpen && queue.canNext }
|
||||
private var transition = PlaybackTransition()
|
||||
@@ -179,6 +183,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var reportingErrorBuffer = [CChar]()
|
||||
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
|
||||
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource
|
||||
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
|
||||
private var reportingErrorMessage: String {
|
||||
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
|
||||
}
|
||||
@@ -187,12 +192,14 @@ final class PlayerModel: ObservableObject {
|
||||
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
|
||||
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
|
||||
miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
|
||||
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
|
||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
||||
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
self.qualityClock = qualityClock
|
||||
self.skipClock = skipClock
|
||||
self.youtubeResolver = youtubeResolver
|
||||
self.miruroResolver = miruroResolver
|
||||
self.miruroMetadataResolver = miruroMetadataResolver
|
||||
keyBindings = configuration.keys
|
||||
compactTitleProgress = configuration.compactTitleProgress
|
||||
automaticYouTubeQuality = configuration.automaticQuality
|
||||
@@ -399,6 +406,7 @@ final class PlayerModel: ObservableObject {
|
||||
guard canOpen else { throw CancellationError() }
|
||||
let needsStart = queue.currentID == nil && currentMedia == nil && !resolvingStream
|
||||
queue.appendStreams([(url: url, label: title)])
|
||||
if case .youtube = stream, let added = queue.items.last { queue.updateLabel(title, for: added.id) }
|
||||
commandError = nil
|
||||
showsQueue = true
|
||||
if needsStart, let added = queue.items.last {
|
||||
@@ -643,6 +651,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func cancelStreamResolution() {
|
||||
cancelQueueMetadata()
|
||||
guard transition.isResolving else { return }
|
||||
objectWillChange.send()
|
||||
transition.cancelResolution()
|
||||
@@ -661,6 +670,58 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
}
|
||||
|
||||
private func cancelQueueMetadata() {
|
||||
queueMetadataTask?.task.cancel()
|
||||
queueMetadataTask = nil
|
||||
}
|
||||
|
||||
private func resolveQueueMetadataIfNeeded() {
|
||||
// Playback and quality changes own the network before sidebar enrichment.
|
||||
guard !isBusy, !resolvingStream, !state.buffering else {
|
||||
cancelQueueMetadata()
|
||||
return
|
||||
}
|
||||
if let request = queueMetadataTask,
|
||||
!queue.items.contains(where: { $0.id == request.id }) {
|
||||
cancelQueueMetadata()
|
||||
}
|
||||
guard queueMetadataPending, queueMetadataTask == nil else { return }
|
||||
guard let item = queue.items.first(where: {
|
||||
!$0.url.isFileURL && $0.id != queue.currentID && !$0.metadataAttempted
|
||||
}) else {
|
||||
queueMetadataPending = false
|
||||
return
|
||||
}
|
||||
guard let stream = try? StreamURL.parse(item.url.absoluteString) else {
|
||||
queue.updateLabel(nil, for: item.id)
|
||||
return
|
||||
}
|
||||
let youtube = youtubeResolver
|
||||
let miruro = miruroMetadataResolver
|
||||
let task = Task(priority: .utility) { [weak self] in
|
||||
let title: String?
|
||||
do {
|
||||
switch stream {
|
||||
case .youtube(let url):
|
||||
let video = try await youtube(url, nil)
|
||||
guard video.webpageURL == url else { throw CocoaError(.fileReadCorruptFile) }
|
||||
title = video.title
|
||||
case .miruro(let id, let episode):
|
||||
title = try await miruro(id, episode).title
|
||||
}
|
||||
} catch {
|
||||
// Optional enrichment must not surface as a playback error or retry every poll.
|
||||
title = nil
|
||||
}
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
if self.queue.currentID != item.id {
|
||||
self.queue.updateLabel(title, for: item.id)
|
||||
}
|
||||
self.queueMetadataTask = nil
|
||||
}
|
||||
queueMetadataTask = (item.id, task)
|
||||
}
|
||||
|
||||
private func replaceMetadata(for source: MediaSource?) {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = nil
|
||||
@@ -674,8 +735,9 @@ final class PlayerModel: ObservableObject {
|
||||
guard let source, let episode else { return }
|
||||
let identity = source.url.absoluteString
|
||||
let mediaID = String(parts[1])
|
||||
let resolver = miruroMetadataResolver
|
||||
metadataTask = Task { [weak self] in
|
||||
guard let metadata = try? await MiruroMetadataResolver.resolve(mediaID: mediaID, episode: episode),
|
||||
guard let metadata = try? await resolver(mediaID, episode),
|
||||
!Task.isCancelled, let self,
|
||||
self.currentMedia?.url.absoluteString == identity,
|
||||
self.metadataLifecycle.accept(metadata, identity: identity, generation: generation) else { return }
|
||||
@@ -685,7 +747,7 @@ final class PlayerModel: ObservableObject {
|
||||
self.fileName = title
|
||||
self.window?.title = title
|
||||
self.casting.updateTitle(title)
|
||||
if self.queue.currentID != nil { self.queue.updateCurrentLabel(title) }
|
||||
if let id = self.queue.currentID { self.queue.updateLabel(title, for: id) }
|
||||
self.systemPlayback?.update()
|
||||
}
|
||||
self.metadataTask = nil
|
||||
@@ -777,7 +839,9 @@ final class PlayerModel: ObservableObject {
|
||||
youtubeHeight = nil
|
||||
youtubeQuality = nil
|
||||
}
|
||||
if let fileName, !source.url.isFileURL { queue.updateCurrentLabel(fileName) }
|
||||
if let fileName, !source.url.isFileURL, let id = queue.currentID {
|
||||
queue.updateLabel(fileName, for: id)
|
||||
}
|
||||
selectSubtitle(nil)
|
||||
if case .resolved(let resolved) = source {
|
||||
subtitleTracks = resolved.subtitles
|
||||
@@ -862,6 +926,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func clearQueue() {
|
||||
cancelQueueMetadata()
|
||||
queue.replace([])
|
||||
queuePlaybackRevision = nil
|
||||
resolvedQueueSource = nil
|
||||
@@ -1368,6 +1433,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
if !casting.active { applyState(player_get_state(handle)) }
|
||||
else { updateSegmentSkip() }
|
||||
resolveQueueMetadataIfNeeded()
|
||||
}
|
||||
|
||||
func applyState(_ next: PlayerState) {
|
||||
|
||||
@@ -312,7 +312,7 @@ private struct YouTubeExtractionOutput: Sendable {
|
||||
|
||||
// All Process, pipe, and continuation state belongs to this request's serial queue.
|
||||
private final class YouTubeExtraction: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "terebi.youtube-extraction", qos: .userInitiated)
|
||||
private let queue: DispatchQueue
|
||||
private let process = Process()
|
||||
private let stdoutPipe = Pipe()
|
||||
private let stderrPipe = Pipe()
|
||||
@@ -331,6 +331,9 @@ private final class YouTubeExtraction: @unchecked Sendable {
|
||||
private var failure: (any Error)?
|
||||
|
||||
init(executable: URL, arguments: [String], environment: [String: String]) {
|
||||
let background = Task.currentPriority <= .utility
|
||||
queue = DispatchQueue(label: "terebi.youtube-extraction", qos: background ? .utility : .userInitiated)
|
||||
process.qualityOfService = background ? .utility : .userInitiated
|
||||
process.executableURL = executable
|
||||
process.arguments = arguments
|
||||
process.environment = environment
|
||||
|
||||
@@ -47,7 +47,7 @@ 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 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.
|
||||
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. Unselected labels are enriched serially at utility priority after playback loading settles; buffering, selection and quality changes cancel background work. yt-dlp extraction supplies page titles; Miruro uses optional watch-route metadata without fetching playback sources. Failures keep the supplied label and do not block later entries or surface playback errors. Results follow item IDs across reordering and cannot rename removed/replaced rows. Only titles are retained: playback resolves fresh on selection/revisits, and canceled/stale results cannot replace a newer selection.
|
||||
|
||||
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 still in the queue, or any report-path collision, rejects the complete batch with an error in the player. Removed videos can be re-added with fresh report destinations, including the original standalone video. Old report paths remain reserved for the process lifetime; at most 4096 queue-report destinations may be registered, counting retired sessions. 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.
|
||||
|
||||
@@ -55,6 +55,8 @@ Each report is immutably bound to its stable identity in the native decoder. Reg
|
||||
|
||||
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, removal/re-addition with fresh reports and unchanged retired files, 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.
|
||||
|
||||
Background-title verification: `make check-resolved check-queue check-youtube all` passed. The native local-HLS smoke exercises unplayed YouTube/Miruro titles, serial utility-priority enrichment, unchanged paused selection/position/title, reordering during lookup, failure fallbacks, fresh playback preemption, and stale removal/replacement results. No browser or live service was used; sidebar appearance remains a user-owned visual check.
|
||||
|
||||
Removal/re-addition verification: the new native registration regression failed before the fix and passed afterward. `make check-reporting check-queue .build/resolved-playback-check all` passed. A focused `.build/resolved-playback-check /absolute/source.json --enqueue-only` smoke using local video-only H.264 HLS passed paused/playing append, natural-end advancement, atomic conflicts/rollback, removal and re-addition of the current standalone video and an inactive queued video, fresh-session playback, byte-for-byte retired-report preservation, and empty-player startup. The source document 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.
|
||||
|
||||
### Optional native metadata
|
||||
|
||||
Reference in New Issue
Block a user