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:
@@ -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