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:
+10
-12
@@ -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()
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user