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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user