build: bundle playback and YouTube dependencies
Build pinned LGPL playback libraries and package standalone YouTube helpers inside the signed native app. Keep the CLI inside the app and relocate private library links for installation without runtime Homebrew dependencies. Record adaptive fixture completion in its EXIT trap: cancellation can exit with status 143 before the TERM trap writes its marker, while native pause and quality preservation remain correct.
This commit is contained in:
@@ -68,7 +68,7 @@ enum YouTubeResolver {
|
||||
do {
|
||||
metadata = try JSONDecoder().decode(YouTubeMetadata.self, from: data)
|
||||
} catch {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned malformed video metadata. Update yt-dlp and try again.")
|
||||
throw YouTubeResolutionError(message: "The bundled yt-dlp returned malformed video metadata. Upgrade terebi or rebuild its bundled helpers and try again.")
|
||||
}
|
||||
guard metadata._type == nil || metadata._type == "video" else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp did not resolve a single playable video.")
|
||||
@@ -143,21 +143,26 @@ enum YouTubeResolver {
|
||||
guard height == nil || height! > 0 else {
|
||||
throw YouTubeResolutionError(message: "Choose a positive video resolution.")
|
||||
}
|
||||
let directories = searchDirectories()
|
||||
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: "YouTube playback requires yt-dlp. Install it with ‘brew install yt-dlp deno’, then try again.")
|
||||
throw YouTubeResolutionError(message: "YouTube playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
|
||||
}
|
||||
let runtime: URL?
|
||||
if let runtimeURL {
|
||||
runtime = try executableFile(runtimeURL, name: "JavaScript runtime")
|
||||
} else {
|
||||
runtime = ["deno", "node", "qjs", "quickjs", "bun"].lazy.compactMap { findExecutable($0, directories: directories) }.first
|
||||
runtime = try bundledExecutable("deno")
|
||||
?? ["deno", "node", "qjs", "quickjs", "bun"].lazy.compactMap { findExecutable($0, directories: directories) }.first
|
||||
}
|
||||
guard let runtime,
|
||||
let runtimeName = ["deno": "deno", "node": "node", "qjs": "quickjs", "quickjs": "quickjs", "bun": "bun"][runtime.lastPathComponent] else {
|
||||
throw YouTubeResolutionError(message: "YouTube playback requires a supported JavaScript runtime. Install Deno with ‘brew install deno’. Supported runtimes are Deno, Node, QuickJS, and Bun.")
|
||||
throw YouTubeResolutionError(message: "YouTube playback requires a supported JavaScript runtime. Rebuild or reinstall terebi with its bundled Deno helper.")
|
||||
}
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["PATH"] = directories.joined(separator: ":")
|
||||
@@ -209,9 +214,18 @@ enum YouTubeResolver {
|
||||
return YouTubeStream(url: url.absoluteString, headers: headers)
|
||||
}
|
||||
|
||||
// A missing bundled helper is an installation error, never permission to use PATH.
|
||||
static func bundledExecutable(_ name: String, bundleURL: URL = Bundle.main.bundleURL) throws -> URL? {
|
||||
guard bundleURL.pathExtension == "app" else { return nil }
|
||||
let url = bundleURL.appendingPathComponent("Contents/MacOS").appendingPathComponent(name)
|
||||
guard isExecutableFile(url) else {
|
||||
throw YouTubeResolutionError(message: "This app's bundled \(name) helper is missing or is not executable. Rebuild or reinstall the complete terebi app.")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private static func searchDirectories() -> [String] {
|
||||
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
|
||||
+ ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
|
||||
var result: [String] = []
|
||||
for path in paths where path.hasPrefix("/") && !result.contains(path) { result.append(path) }
|
||||
return result
|
||||
@@ -240,10 +254,10 @@ enum YouTubeResolver {
|
||||
|
||||
fileprivate static func dependencyError(_ text: String) -> YouTubeResolutionError? {
|
||||
if text.contains("yt-dlp-ejs") || text.contains("challenge solving failed") || text.contains("no supported javascript") || text.contains("no javascript runtime") {
|
||||
return YouTubeResolutionError(message: "YouTube extraction requires current yt-dlp, its EJS components, and a supported JavaScript runtime. Run ‘brew upgrade yt-dlp deno’. For a pip installation, install ‘yt-dlp[default]’ and Deno.")
|
||||
return YouTubeResolutionError(message: "The bundled YouTube helpers could not solve YouTube's JavaScript challenge. Upgrade terebi or rebuild its bundled yt-dlp, EJS components, and Deno.")
|
||||
}
|
||||
if text.contains("no such option") || text.contains("unrecognized arguments") {
|
||||
return YouTubeResolutionError(message: "The installed yt-dlp is too old for YouTube streaming. Update it with ‘brew upgrade yt-dlp’.")
|
||||
return YouTubeResolutionError(message: "The bundled yt-dlp is too old for YouTube streaming. Upgrade terebi or rebuild its bundled helpers.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -256,15 +270,15 @@ enum YouTubeResolver {
|
||||
return YouTubeResolutionError(message: "YouTube requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.")
|
||||
}
|
||||
if text.contains("no video formats") || text.contains("no formats") || text.contains("requested format") {
|
||||
return YouTubeResolutionError(message: "YouTube did not provide a supported SDR video and audio stream. Update yt-dlp and try again.")
|
||||
return YouTubeResolutionError(message: "YouTube did not provide a supported SDR video and audio stream. Upgrade terebi or rebuild its bundled helpers and try again.")
|
||||
}
|
||||
if text.contains("not available") || text.contains("unavailable") || text.contains("removed") || text.contains("copyright") || text.contains("blocked") {
|
||||
return YouTubeResolutionError(message: "This YouTube video is unavailable or restricted in your region.")
|
||||
}
|
||||
if text.contains("timed out") || text.contains("connection") || text.contains("http error") || text.contains("certificate") || text.contains("urlopen") {
|
||||
return YouTubeResolutionError(message: "Could not reach YouTube. Check your network connection and try again; update yt-dlp if the problem persists.")
|
||||
return YouTubeResolutionError(message: "Could not reach YouTube. Check your network connection and try again; upgrade terebi if the problem persists.")
|
||||
}
|
||||
return YouTubeResolutionError(message: "yt-dlp could not resolve this video (exit status \(status)). Update yt-dlp and Deno, then try again.")
|
||||
return YouTubeResolutionError(message: "The bundled yt-dlp could not resolve this video (exit status \(status)). Upgrade terebi or rebuild its bundled helpers, then try again.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,11 +36,22 @@ enum TerebiCLI {
|
||||
fail(error.localizedDescription, code: 78)
|
||||
}
|
||||
}
|
||||
guard let executable = Bundle.main.executableURL else {
|
||||
var size: UInt32 = 0
|
||||
_ = _NSGetExecutablePath(nil, &size)
|
||||
var path = [CChar](repeating: 0, count: Int(size))
|
||||
guard _NSGetExecutablePath(&path, &size) == 0 else {
|
||||
fail("Could not locate the CLI executable.", code: 69)
|
||||
}
|
||||
let app = executable.resolvingSymlinksInPath().deletingLastPathComponent()
|
||||
.appendingPathComponent("terebi.app", isDirectory: true)
|
||||
let executable = path.withUnsafeBufferPointer {
|
||||
URL(fileURLWithPath: String(cString: $0.baseAddress!)).resolvingSymlinksInPath()
|
||||
}
|
||||
let macOS = executable.deletingLastPathComponent()
|
||||
let contents = macOS.deletingLastPathComponent()
|
||||
let app = contents.deletingLastPathComponent()
|
||||
guard macOS.lastPathComponent == "MacOS", contents.lastPathComponent == "Contents",
|
||||
app.pathExtension == "app" else {
|
||||
fail("The CLI must be installed inside terebi.app. Rebuild and reinstall the bundled app.", code: 69)
|
||||
}
|
||||
if let error = FFmpegRuntime.error(for: app) {
|
||||
fail(error, code: 69)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ enum FFmpegRuntime {
|
||||
guard paths.count == libraries.count else { return metadataError }
|
||||
var found = Set<String>()
|
||||
for path in paths {
|
||||
guard path.hasPrefix("/"), !path.contains("\0"), !path.contains("\r") else {
|
||||
guard !path.contains("/"), !path.contains("\0"), !path.contains("\r") else {
|
||||
return metadataError
|
||||
}
|
||||
let parts = URL(fileURLWithPath: path).lastPathComponent.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count >= 2, parts.last == "dylib" else { return metadataError }
|
||||
guard parts.count >= 3, parts.last == "dylib" else { return metadataError }
|
||||
let library = String(parts[0])
|
||||
guard libraries.contains(library),
|
||||
parts.dropFirst().dropLast().allSatisfy({ !$0.isEmpty && $0.utf8.allSatisfy { (48...57).contains($0) } }),
|
||||
@@ -26,11 +26,13 @@ enum FFmpegRuntime {
|
||||
return metadataError
|
||||
}
|
||||
}
|
||||
for path in paths {
|
||||
let frameworks = app.appendingPathComponent("Contents/Frameworks", isDirectory: true)
|
||||
for filename in paths {
|
||||
let path = frameworks.appendingPathComponent(filename).path
|
||||
_ = dlerror()
|
||||
guard dlopen(path, RTLD_NOW | RTLD_LOCAL) != nil else {
|
||||
let detail = dlerror().map { String(cString: $0) } ?? "The dynamic loader could not load this library."
|
||||
return "The FFmpeg libraries required by this build are unavailable or broken.\n\nRequired library: \(path)\n\(detail)\n\nInstall FFmpeg with `brew install ffmpeg`. If FFmpeg was upgraded or its library versions changed, rebuild and reinstall terebi."
|
||||
return "This app's bundled FFmpeg libraries are missing or broken.\n\nRequired library: \(filename)\n\(detail)\n\nRebuild and reinstall terebi, or reinstall a complete bundled app."
|
||||
}
|
||||
// Deliberately never dlclose: retain successful loads until exit.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user