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:
2026-10-22 18:01:07 +02:00
parent f1339050d9
commit 6ed7db8167
7 changed files with 207 additions and 18 deletions
+5 -3
View File
@@ -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]) {
+70 -4
View File
@@ -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) {
+4 -1
View File
@@ -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