fix: populate queue titles independently of playback extraction

The sidebar scheduler waited for playback and used full stream extraction for titles. Use independent utility-priority title requests, native YouTube oEmbed, and metadata-only extraction for generic sites. Preserve foreground title precedence and stale row protection.

Verified: make check-youtube check-queue check-resolved check-adaptive check-stream all.
This commit is contained in:
2026-10-22 18:28:49 +02:00
parent 98e6dcb3c5
commit 4576080955
8 changed files with 287 additions and 85 deletions
+10 -12
View File
@@ -187,6 +187,7 @@ final class PlayerModel: ObservableObject {
private var reportingErrorShown = false
private var reportingErrorBuffer = [CChar]()
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
private var reportingErrorMessage: String {
@@ -196,6 +197,7 @@ final class PlayerModel: ObservableObject {
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
youtubeTitleResolver: @escaping @MainActor (URL) async throws -> String? = { try await YouTubeResolver.resolveTitle($0) },
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 },
@@ -203,6 +205,7 @@ final class PlayerModel: ObservableObject {
self.qualityClock = qualityClock
self.skipClock = skipClock
self.youtubeResolver = youtubeResolver
self.youtubeTitleResolver = youtubeTitleResolver
self.miruroResolver = miruroResolver
self.miruroMetadataResolver = miruroMetadataResolver
keyBindings = configuration.keys
@@ -656,7 +659,6 @@ final class PlayerModel: ObservableObject {
}
private func cancelStreamResolution() {
cancelQueueMetadata()
guard transition.isResolving else { return }
objectWillChange.send()
transition.cancelResolution()
@@ -681,18 +683,14 @@ final class PlayerModel: ObservableObject {
}
private func resolveQueueMetadataIfNeeded() {
// Playback and quality changes own the network before sidebar enrichment.
guard !isBusy, !resolvingStream, !state.buffering else {
cancelQueueMetadata()
return
}
// One utility-priority title lookup runs alongside (never behind) stream extraction.
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
!$0.url.isFileURL && !$0.metadataAttempted
}) else {
queueMetadataPending = false
return
@@ -701,16 +699,14 @@ final class PlayerModel: ObservableObject {
queue.updateLabel(nil, for: item.id)
return
}
let youtube = youtubeResolver
let youtube = youtubeTitleResolver
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
title = try await youtube(url)
case .miruro(let id, let episode):
title = try await miruro(id, episode).title
}
@@ -719,7 +715,8 @@ final class PlayerModel: ObservableObject {
title = nil
}
guard !Task.isCancelled, let self else { return }
if self.queue.currentID != item.id {
// Full playback/episode metadata may have populated this row while we waited.
if self.queue.items.first(where: { $0.id == item.id })?.metadataAttempted == false {
self.queue.updateLabel(title, for: item.id)
}
self.queueMetadataTask = nil
@@ -1559,6 +1556,7 @@ final class PlayerModel: ObservableObject {
if castDevice != nil { stopCasting(resume: false) }
castDiscovery.stop()
cancelStreamResolution()
cancelQueueMetadata()
replaceMetadata(for: nil)
selectSubtitle(nil)
systemPlayback?.shutdown()
+93 -10
View File
@@ -139,22 +139,97 @@ enum YouTubeResolver {
)
}
// --skip-download/-j still perform extraction; --flat-playlist only skips playlist
// entries. YouTube's oEmbed endpoint avoids format and JavaScript challenge work.
static func resolveTitle(_ url: URL, executableURL: URL? = nil,
configuration: URLSessionConfiguration? = nil) async throws -> String? {
try Task.checkCancellation()
let webpageURL = try validateURL(url.absoluteString)
if webpageURL.host == "www.youtube.com", webpageURL.path == "/watch",
URLComponents(url: webpageURL, resolvingAgainstBaseURL: false)?.queryItems?.first?.name == "v" {
var endpoint = URLComponents(string: "https://www.youtube.com/oembed")!
endpoint.queryItems = [URLQueryItem(name: "url", value: webpageURL.absoluteString),
URLQueryItem(name: "format", value: "json")]
let configuration = configuration ?? .ephemeral
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 20
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCache = nil
let session = URLSession(configuration: configuration, delegate: YouTubeTitleRedirectPolicy(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
let (bytes, response) = try await session.bytes(from: endpoint.url!)
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
http.mimeType == "application/json", response.expectedContentLength <= 65536 else {
throw YouTubeResolutionError(message: "The optional video title was unavailable.")
}
var data = Data()
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < 65536 else {
throw YouTubeResolutionError(message: "The optional video title was too large.")
}
data.append(byte)
}
try Task.checkCancellation()
return try decodeTitle(data)
}
let (executable, directories) = try extractorExecutable(executableURL)
var environment = ProcessInfo.processInfo.environment
environment["PATH"] = directories.joined(separator: ":")
// Generic sites may need site-specific extraction, but no playback format,
// runtime, stream validation or native media load is required for their title.
let arguments = [
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
"--no-playlist", "--flat-playlist", "--simulate", "--skip-download", "--dump-single-json",
"--no-progress", "--color", "never", "--no-check-formats", "--ignore-no-formats-error",
"--no-js-runtimes", "--socket-timeout", "15", "--extractor-retries", "0", "--retries", "0",
"--", webpageURL.absoluteString
]
let output = try await YouTubeExtraction(executable: executable, arguments: arguments, environment: environment).run()
try Task.checkCancellation()
return try decodeTitle(output.stdout)
}
static func decodeTitle(_ data: Data) throws -> String? {
struct Metadata: Decodable { let title: String?; let _type: String? }
guard data.count <= outputLimit else {
throw YouTubeResolutionError(message: "The optional video title was too large.")
}
let metadata = try JSONDecoder().decode(Metadata.self, from: data)
guard metadata._type == nil || ["video", "url", "url_transparent"].contains(metadata._type!) else {
throw YouTubeResolutionError(message: "The optional video title did not identify a single video.")
}
guard let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines),
!title.isEmpty else { return nil }
guard title.utf8.count <= 16384,
!title.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else {
throw YouTubeResolutionError(message: "The optional video title was invalid.")
}
return title
}
private static func extractorExecutable(_ override: URL?) throws -> (URL, [String]) {
let bundle = Bundle.main.bundleURL
let directories = bundle.pathExtension == "app"
? [bundle.appendingPathComponent("Contents/MacOS").path]
: searchDirectories()
let executable = try override.map { try executableFile($0, name: "yt-dlp") }
?? bundledExecutable("yt-dlp")
?? findExecutable("yt-dlp", directories: directories)
guard let executable else {
throw YouTubeResolutionError(message: "Stream playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
}
return (executable, directories)
}
static func resolve(_ url: URL, height: Int? = nil, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
try Task.checkCancellation()
let webpageURL = try validateURL(url.absoluteString)
guard height == nil || height! > 0 else {
throw YouTubeResolutionError(message: "Choose a positive video resolution.")
}
let bundle = Bundle.main.bundleURL
let directories = bundle.pathExtension == "app"
? [bundle.appendingPathComponent("Contents/MacOS").path]
: searchDirectories()
let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") }
?? bundledExecutable("yt-dlp")
?? findExecutable("yt-dlp", directories: directories)
guard let executable else {
throw YouTubeResolutionError(message: "Stream playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
}
let (executable, directories) = try extractorExecutable(executableURL)
let runtime: URL?
if let runtimeURL {
runtime = try executableFile(runtimeURL, name: "JavaScript runtime")
@@ -284,6 +359,14 @@ enum YouTubeResolver {
}
}
private final class YouTubeTitleRedirectPolicy: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
completionHandler(nil)
}
}
private struct YouTubeMetadata: Decodable {
let _type: String?
let title: String?