From c139ac50f2a70d9bd5ff62bfd2ba02b57a025a87 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:48:00 +0330 Subject: [PATCH] fix(media): stabilize yt-dlp metadata and add-on updates --- Sources/Firelink/AddDownloadsView.swift | 106 ++++++--- Sources/Firelink/AppSettings.swift | 9 + Sources/Firelink/BinaryDownloader.swift | 149 ++++++++++-- Sources/Firelink/DownloadController.swift | 48 +++- Sources/Firelink/GatekeeperConfig.swift | 14 ++ Sources/Firelink/MediaDownloadEngine.swift | 58 ++++- Sources/Firelink/MediaEngineManager.swift | 83 ++++--- Sources/Firelink/MediaExtractionEngine.swift | 232 ++++++++++++++----- Sources/Firelink/MediaInspectorCard.swift | 96 +++++--- 9 files changed, 605 insertions(+), 190 deletions(-) diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index a22dc43..6edeea4 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -34,7 +34,7 @@ struct AddDownloadsView: View { ScrollView { VStack(alignment: .leading, spacing: 12) { linkSection - + if detectedMediaURL != nil, !isMediaMode { Button { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { @@ -62,37 +62,65 @@ struct AddDownloadsView: View { .buttonStyle(.plain) .transition(.move(edge: .top).combined(with: .opacity)) } - + if isMediaMode, let mediaURL = detectedMediaURL { - MediaInspectorCard(url: mediaURL) { selectedFormat, metadata in + MediaInspectorCard( + url: mediaURL, + cookieSource: settings.mediaCookieSource, + credentials: metadataCredentials(for: mediaURL), + transferOptions: transferOptions + ) { selectedFormat, metadata in let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media") - let ext = selectedFormat.isAudioOnly ? "mp3" : "mp4" + let ext = selectedFormat.outputExtension let fileName = "\(cleanTitle).\(ext)" let category = FileClassifier.category(forFileName: fileName) - - var item = DownloadItem( + + let item = DownloadItem( url: mediaURL, fileName: fileName, category: category, - destinationDirectory: settings.destinationDirectory(for: category), - connectionsPerServer: 1 + destinationDirectory: overrideDirectory ?? settings.destinationDirectory(for: category), + connectionsPerServer: 1, + credentials: explicitCredentials(for: [mediaURL]) ?? settings.credentials(for: mediaURL), + checksum: transferOptions.checksum, + requestHeaders: transferOptions.requestHeaders, + cookieHeader: transferOptions.cookieHeader, + mirrorURLs: transferOptions.mirrorURLs, + speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil, + message: "Added to queue", + queueID: targetQueueID, + mediaFormatSelector: selectedFormat.formatSelector, + isAudioOnlyMedia: selectedFormat.isAudioOnly ) - item.mediaFormatSelector = selectedFormat.formatSelector - item.isAudioOnlyMedia = selectedFormat.isAudioOnly - item.message = "Added to queue" - - controller.downloads.append(item) - controller.engineMessage = "Added \(fileName) to \(category.rawValue)." - controller.startQueue(queueID: DownloadQueue.mainQueueID) - + + controller.addMediaDownload(item, startImmediately: true) + dismiss() } .transition(.scale(scale: 0.95).combined(with: .opacity)) } else { optionsSection advancedTransferSection - summarySection - previewSection + + if detectedMediaURL != nil { + VStack(spacing: 16) { + Image(systemName: "sparkles.tv") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("Media link detected. Click 'Extract Video / Audio' above to fetch available formats, or proceed to download the raw file.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, minHeight: 160) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.quaternary.opacity(0.35)) + ) + } else { + summarySection + previewSection + } } } .padding(12) @@ -230,7 +258,7 @@ struct AddDownloadsView: View { VStack(alignment: .leading, spacing: 8) { Toggle("Use authorization", isOn: $useAuthorization) .toggleStyle(.switch) - + if useAuthorization { HStack(spacing: 8) { TextField("Username", text: $authUsername) @@ -305,7 +333,7 @@ struct AddDownloadsView: View { .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) - + if metadataTask != nil { Button { metadataTask?.cancel() @@ -509,7 +537,7 @@ struct AddDownloadsView: View { private func refreshMetadata(for text: String, isAutoFetch: Bool) { let urls = DownloadURLParser.parse(text) metadataTask?.cancel() - + if let first = urls.first, MediaDetector.isSupportedMedia(url: first) { withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { detectedMediaURL = first @@ -584,22 +612,7 @@ struct AddDownloadsView: View { } private func addDownloads(start: Bool) { - var explicitCredentials: DownloadCredentials? = nil - if useAuthorization { - let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines) - if !cleanUsername.isEmpty { - explicitCredentials = DownloadCredentials(username: cleanUsername, password: authPassword) - if saveLogin { - var savedHosts = Set() - for item in pendingDownloads { - if let host = item.url.host, !savedHosts.contains(host) { - settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword) - savedHosts.insert(host) - } - } - } - } - } + let explicitCredentials = explicitCredentials(for: pendingDownloads.map(\.url)) controller.addPendingDownloads( pendingDownloads, @@ -614,6 +627,25 @@ struct AddDownloadsView: View { dismiss() } + private func explicitCredentials(for urls: [URL]) -> DownloadCredentials? { + guard useAuthorization else { return nil } + + let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanUsername.isEmpty else { return nil } + + if saveLogin { + var savedHosts = Set() + for url in urls { + if let host = url.host, !savedHosts.contains(host) { + settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword) + savedHosts.insert(host) + } + } + } + + return DownloadCredentials(username: cleanUsername, password: authPassword) + } + private var overrideDirectory: URL? { guard overrideDestination else { return nil } let trimmed = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 7f19499..54164b6 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -27,6 +27,15 @@ enum BrowserCookieSource: String, Codable, CaseIterable, Sendable { case firefox = "Firefox" case edge = "Edge" case brave = "Brave" + + var ytDlpBrowserName: String? { + switch self { + case .none: + nil + case .safari, .chrome, .firefox, .edge, .brave: + rawValue.lowercased() + } + } } enum ProxyType: String, Codable, CaseIterable, Sendable { diff --git a/Sources/Firelink/BinaryDownloader.swift b/Sources/Firelink/BinaryDownloader.swift index 457ddd6..b2c3f1a 100644 --- a/Sources/Firelink/BinaryDownloader.swift +++ b/Sources/Firelink/BinaryDownloader.swift @@ -1,42 +1,85 @@ import Foundation +import CryptoKit -enum BinaryDownloaderError: Error { +enum BinaryDownloaderError: LocalizedError { case invalidResponse case httpError(statusCode: Int) case downloadFailed(Error?) case moveFailed(Error) case permissionFailed(Error) case unzipFailed + case unsupportedDownloadURL + case checksumMismatch + + var errorDescription: String? { + switch self { + case .invalidResponse: + "The add-on server returned an invalid response." + case .httpError(let statusCode): + "The add-on download failed with HTTP \(statusCode)." + case .downloadFailed(let error): + error?.localizedDescription ?? "The add-on download failed." + case .moveFailed(let error): + error.localizedDescription + case .permissionFailed(let error): + "Could not mark the add-on executable: \(error.localizedDescription)" + case .unzipFailed: + "Could not extract the downloaded add-on archive." + case .unsupportedDownloadURL: + "The add-on URL must be HTTP or HTTPS." + case .checksumMismatch: + "The downloaded add-on did not match the expected SHA-256 checksum." + } + } } final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable { private let url: URL private let destination: URL + private let expectedSHA256: String? private let onProgress: @Sendable (Double) -> Void private let session: URLSession - + private let continuation: CheckedContinuation - - init(url: URL, destination: URL, onProgress: @escaping @Sendable (Double) -> Void, continuation: CheckedContinuation) { + + init( + url: URL, + destination: URL, + expectedSHA256: String?, + onProgress: @escaping @Sendable (Double) -> Void, + continuation: CheckedContinuation + ) { self.url = url self.destination = destination + self.expectedSHA256 = expectedSHA256 self.onProgress = onProgress self.continuation = continuation - + let config = URLSessionConfiguration.ephemeral self.session = URLSession(configuration: config, delegate: nil, delegateQueue: nil) // Delegate set below super.init() } - - static func download(from url: URL, to destination: URL, onProgress: @escaping @Sendable (Double) -> Void) async throws { + + static func download( + from url: URL, + to destination: URL, + expectedSHA256: String? = nil, + onProgress: @escaping @Sendable (Double) -> Void + ) async throws { try await withCheckedThrowingContinuation { continuation in - let downloader = BinaryDownloader(url: url, destination: destination, onProgress: onProgress, continuation: continuation) + let downloader = BinaryDownloader( + url: url, + destination: destination, + expectedSHA256: expectedSHA256, + onProgress: onProgress, + continuation: continuation + ) let session = URLSession(configuration: .ephemeral, delegate: downloader, delegateQueue: nil) let task = session.downloadTask(with: url) task.resume() } } - + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { defer { session.finishTasksAndInvalidate() } guard let response = downloadTask.response as? HTTPURLResponse else { @@ -47,32 +90,100 @@ final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable { continuation.resume(throwing: BinaryDownloaderError.httpError(statusCode: response.statusCode)) return } - + do { - if FileManager.default.fileExists(atPath: destination.path) { - try FileManager.default.removeItem(at: destination) + guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { + throw BinaryDownloaderError.unsupportedDownloadURL } - try FileManager.default.moveItem(at: location, to: destination) - - // Make executable - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destination.path) - + + let isZip = url.pathExtension.lowercased() == "zip" + let stagingURL = destination + .deletingLastPathComponent() + .appendingPathComponent(".\(destination.lastPathComponent).\(UUID().uuidString).staged") + var cleanupURLs: [URL] = [stagingURL] + defer { + for cleanupURL in cleanupURLs { + try? FileManager.default.removeItem(at: cleanupURL) + } + } + + if isZip { + let tempZip = location.appendingPathExtension("zip") + try FileManager.default.moveItem(at: location, to: tempZip) + cleanupURLs.append(tempZip) + + let extractDir = tempZip.deletingLastPathComponent().appendingPathComponent("extracted_\(UUID().uuidString)") + try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true) + cleanupURLs.append(extractDir) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-q", tempZip.path, "-d", extractDir.path] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw BinaryDownloaderError.unzipFailed + } + + let expectedName = destination.lastPathComponent + var foundBinary: URL? + if let enumerator = FileManager.default.enumerator(at: extractDir, includingPropertiesForKeys: nil) { + for case let fileURL as URL in enumerator { + if fileURL.lastPathComponent == expectedName || fileURL.lastPathComponent == expectedName + "c" { + foundBinary = fileURL + break + } + } + } + + guard let foundBinary = foundBinary else { + throw BinaryDownloaderError.unzipFailed + } + + try FileManager.default.moveItem(at: foundBinary, to: stagingURL) + } else { + try FileManager.default.moveItem(at: location, to: stagingURL) + } + + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stagingURL.path) + if let expectedSHA256 { + let actualSHA256 = try Self.sha256Hex(for: stagingURL) + guard actualSHA256.caseInsensitiveCompare(expectedSHA256.trimmingCharacters(in: .whitespacesAndNewlines)) == .orderedSame else { + throw BinaryDownloaderError.checksumMismatch + } + } + try installStagedBinary(stagingURL, at: destination) + continuation.resume() } catch { continuation.resume(throwing: BinaryDownloaderError.moveFailed(error)) } } - + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { guard totalBytesExpectedToWrite > 0 else { return } let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) onProgress(progress) } - + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let error = error { session.finishTasksAndInvalidate() continuation.resume(throwing: BinaryDownloaderError.downloadFailed(error)) } } + + private func installStagedBinary(_ stagedURL: URL, at destination: URL) throws { + if FileManager.default.fileExists(atPath: destination.path) { + _ = try FileManager.default.replaceItemAt(destination, withItemAt: stagedURL) + } else { + try FileManager.default.moveItem(at: stagedURL, to: destination) + } + } + + private static func sha256Hex(for url: URL) throws -> String { + let data = try Data(contentsOf: url, options: .mappedIfSafe) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } } diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 9f0741b..b69c587 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -110,6 +110,23 @@ final class DownloadController: ObservableObject { Aria2DownloadEngine.findExecutable() != nil } + private var hasStartableQueuedDownloadIgnoringEngine: Bool { + downloads.contains { item in + item.status == .queued && + (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) && + isAllowedToStart(item) + } + } + + private var hasRunnableQueuedDownload: Bool { + downloads.contains { item in + item.status == .queued && + (item.mediaFormatSelector != nil || hasAria2) && + (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) && + isAllowedToStart(item) + } + } + func add(urlText: String, connectionsPerServer: Int? = nil, queueID: UUID = DownloadQueue.mainQueueID) { guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)), let scheme = url.scheme?.lowercased(), @@ -189,6 +206,27 @@ final class DownloadController: ObservableObject { } } + func addMediaDownload(_ item: DownloadItem, startImmediately: Bool) { + var item = item + item.fileName = FileClassifier.sanitizedFileName(item.fileName) + item.category = FileClassifier.category(forFileName: item.fileName) + item.connectionsPerServer = 1 + item.speedLimitKiBPerSecond = normalizedSpeedLimit(item.speedLimitKiBPerSecond) + item.queueID = normalizedQueueID(item.queueID ?? DownloadQueue.mainQueueID) + + if let password = item.credentials?.password, !password.isEmpty { + KeychainCredentialStore.setPassword(password, for: item.id) + } + + downloads.append(item) + engineMessage = "Added \(item.fileName) to \(item.category.rawValue)." + saveDownloads() + + if startImmediately { + startQueue(queueID: item.queueID ?? DownloadQueue.mainQueueID) + } + } + func startQueue(queueID: UUID? = nil) { engineMessage = "" restrictQueueToAutoResume = false @@ -449,7 +487,11 @@ final class DownloadController: ObservableObject { } private func pumpQueue() { - guard hasAria2 else { + guard hasStartableQueuedDownloadIgnoringEngine else { + return + } + + guard hasRunnableQueuedDownload else { engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads." return } @@ -459,6 +501,7 @@ final class DownloadController: ObservableObject { while activeCount < settings.maxConcurrentDownloads, let next = downloads.first(where: { item in item.status == .queued && + (item.mediaFormatSelector != nil || hasAria2) && (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) && isAllowedToStart(item) }) { @@ -490,6 +533,9 @@ final class DownloadController: ObservableObject { do { let handle = try await mediaEngine.start( item: item, + cookieSource: settings.mediaCookieSource, + proxyConfiguration: settings.downloadProxyConfiguration, + speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item), progress: { [weak self] progress in Task { @MainActor in self?.update(item.id) { diff --git a/Sources/Firelink/GatekeeperConfig.swift b/Sources/Firelink/GatekeeperConfig.swift index cff59a1..d874b8d 100644 --- a/Sources/Firelink/GatekeeperConfig.swift +++ b/Sources/Firelink/GatekeeperConfig.swift @@ -4,11 +4,15 @@ struct AddonConfig: Codable, Equatable, Sendable { let version: String let macArm64: URL? let macX64: URL? + let macArm64SHA256: String? + let macX64SHA256: String? enum CodingKeys: String, CodingKey { case version case macArm64 = "mac-arm64" case macX64 = "mac-x64" + case macArm64SHA256 = "mac-arm64-sha256" + case macX64SHA256 = "mac-x64-sha256" } /// Returns the appropriate download URL for the current system architecture @@ -21,6 +25,16 @@ struct AddonConfig: Codable, Equatable, Sendable { return nil #endif } + + var currentArchSHA256: String? { + #if arch(arm64) + return macArm64SHA256 + #elseif arch(x86_64) + return macX64SHA256 + #else + return nil + #endif + } } struct GatekeeperConfig: Codable, Equatable, Sendable { diff --git a/Sources/Firelink/MediaDownloadEngine.swift b/Sources/Firelink/MediaDownloadEngine.swift index 4dbc3d8..b738289 100644 --- a/Sources/Firelink/MediaDownloadEngine.swift +++ b/Sources/Firelink/MediaDownloadEngine.swift @@ -19,6 +19,9 @@ final class MediaDownloadEngine: @unchecked Sendable { func start( item: DownloadItem, + cookieSource: BrowserCookieSource, + proxyConfiguration: DownloadProxyConfiguration, + speedLimitKiBPerSecond: Int?, progress: @escaping @Sendable (DownloadProgress) -> Void, messageUpdate: @escaping @Sendable (String) -> Void, completion: @escaping @Sendable (Result) -> Void @@ -26,10 +29,10 @@ final class MediaDownloadEngine: @unchecked Sendable { let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp) let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg) - guard FileManager.default.fileExists(atPath: ytDlpURL.path) else { + guard FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else { throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.") } - guard FileManager.default.fileExists(atPath: ffmpegURL.path) else { + guard FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else { throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.") } @@ -49,18 +52,26 @@ final class MediaDownloadEngine: @unchecked Sendable { arguments.append(format) if item.isAudioOnlyMedia == true { - arguments.append(contentsOf: ["-x", "--audio-format", "mp3", "--audio-quality", "0"]) + let audioFormat = item.fileName.fileExtension(defaultValue: "mp3") + arguments.append(contentsOf: ["-x", "--audio-format", audioFormat, "--audio-quality", "0"]) } else { arguments.append(contentsOf: ["--merge-output-format", "mp4"]) } } - - // Add cookies if configured - if let storedData = UserDefaults.standard.data(forKey: "Firelink.AppSettings.v1"), - let json = try? JSONSerialization.jsonObject(with: storedData) as? [String: Any], - let cookieSourceStr = json["mediaCookieSource"] as? String, - cookieSourceStr != "None" { - arguments.append(contentsOf: ["--cookies-from-browser", cookieSourceStr.lowercased()]) + + MediaExtractionEngine.appendCommonArguments( + to: &arguments, + cookieSource: cookieSource, + credentials: item.credentials, + transferOptions: item.transferOptions + ) + + if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom { + arguments.append(contentsOf: ["--proxy", proxyURI]) + } + + if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 { + arguments.append(contentsOf: ["--limit-rate", "\(speedLimitKiBPerSecond)K"]) } arguments.append(item.url.absoluteString) @@ -72,6 +83,7 @@ final class MediaDownloadEngine: @unchecked Sendable { process.standardError = errorPipe let parser = YTDLPProgressParser() + let errorBuffer = LockedDataBuffer() let completionGate = CompletionGate(completion) outputPipe.fileHandleForReading.readabilityHandler = { handle in @@ -87,6 +99,20 @@ final class MediaDownloadEngine: @unchecked Sendable { } } } + + errorPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + errorBuffer.append(data) + if let text = String(data: data, encoding: .utf8) { + for line in text.split(whereSeparator: \.isNewline) { + let stringLine = String(line) + if stringLine.contains("[Merger]") || stringLine.contains("[ExtractAudio]") { + messageUpdate("Processing Media...") + } + } + } + } process.terminationHandler = { finishedProcess in outputPipe.fileHandleForReading.readabilityHandler = nil @@ -95,13 +121,14 @@ final class MediaDownloadEngine: @unchecked Sendable { if finishedProcess.terminationStatus == 0 { completionGate.complete(.success(())) } else { - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - let errorString = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error" + let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error" completionGate.complete(.failure(EngineError.launchFailed(errorString.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : errorString))) } } try process.run() + outputPipe.fileHandleForWriting.closeFile() + errorPipe.fileHandleForWriting.closeFile() return Handle(cancel: { if process.isRunning { @@ -111,6 +138,13 @@ final class MediaDownloadEngine: @unchecked Sendable { } } +private extension String { + func fileExtension(defaultValue: String) -> String { + let ext = (self as NSString).pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return ext.isEmpty ? defaultValue : ext + } +} + final class YTDLPProgressParser: @unchecked Sendable { private let percentageRegex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)%"#) private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#) diff --git a/Sources/Firelink/MediaEngineManager.swift b/Sources/Firelink/MediaEngineManager.swift index 639e965..837795b 100644 --- a/Sources/Firelink/MediaEngineManager.swift +++ b/Sources/Firelink/MediaEngineManager.swift @@ -11,11 +11,11 @@ enum AddonState: Equatable, Sendable { enum AddonType: String, CaseIterable, Sendable { case ytDlp = "yt-dlp" case ffmpeg - + var defaultsKey: String { return "Firelink.AddonVersion.\(self.rawValue)" } - + var binaryName: String { switch self { case .ytDlp: return "yt-dlp" @@ -27,30 +27,30 @@ enum AddonType: String, CaseIterable, Sendable { @MainActor final class MediaEngineManager: ObservableObject { static let shared = MediaEngineManager() - + @Published var ytDlpState: AddonState = .notInstalled @Published var ffmpegState: AddonState = .notInstalled - + private let configURL = URL(string: "https://nimbold.github.io/Firelink/firelink-addons.json")! - + private var addonsDirectory: URL { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app" return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true) } - + private init() { checkLocalInstallation() } - + func binaryPath(for addon: AddonType) -> URL { return addonsDirectory.appendingPathComponent(addon.binaryName) } - + func checkLocalInstallation() { for addon in AddonType.allCases { let path = binaryPath(for: addon) - if FileManager.default.fileExists(atPath: path.path) { + if FileManager.default.isExecutableFile(atPath: path.path) { if let version = UserDefaults.standard.string(forKey: addon.defaultsKey) { setState(for: addon, to: .installed(version: version)) } else { @@ -61,78 +61,95 @@ final class MediaEngineManager: ObservableObject { } } } - + func fetchLatestConfig() async throws -> GatekeeperConfig { var request = URLRequest(url: configURL) request.cachePolicy = .reloadIgnoringLocalCacheData - + request.timeoutInterval = 30 + let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { throw URLError(.badServerResponse) } - + return try JSONDecoder().decode(GatekeeperConfig.self, from: data) } - + func ensureInstalled() async throws { // Simple helper for the "Extract" button flow // Fetches config and installs if not installed or out of date let config = try await fetchLatestConfig() - + // Use task group to download both if needed - await withTaskGroup(of: Void.self) { group in + try await withThrowingTaskGroup(of: Void.self) { group in if case .notInstalled = ytDlpState { - group.addTask { await self.install(addon: .ytDlp, from: config) } + group.addTask { try await self.install(addon: .ytDlp, from: config) } + } else if case .failed = ytDlpState { + group.addTask { try await self.install(addon: .ytDlp, from: config) } } else if case let .installed(version) = ytDlpState, let configVersion = config.ytDlp?.version, version != configVersion { - group.addTask { await self.install(addon: .ytDlp, from: config) } + group.addTask { try await self.install(addon: .ytDlp, from: config) } } - + if case .notInstalled = ffmpegState { - group.addTask { await self.install(addon: .ffmpeg, from: config) } + group.addTask { try await self.install(addon: .ffmpeg, from: config) } + } else if case .failed = ffmpegState { + group.addTask { try await self.install(addon: .ffmpeg, from: config) } } else if case let .installed(version) = ffmpegState, let configVersion = config.ffmpeg?.version, version != configVersion { - group.addTask { await self.install(addon: .ffmpeg, from: config) } + group.addTask { try await self.install(addon: .ffmpeg, from: config) } } + + try await group.waitForAll() } } - - func install(addon: AddonType, from config: GatekeeperConfig) async { + + func install(addon: AddonType, from config: GatekeeperConfig) async throws { setState(for: addon, to: .downloading(progress: 0)) - + let addonConfig: AddonConfig? = { switch addon { case .ytDlp: return config.ytDlp case .ffmpeg: return config.ffmpeg } }() - + guard let addonConfig = addonConfig else { setState(for: addon, to: .failed(error: "Missing configuration for \(addon.rawValue)")) - return + throw URLError(.badURL) } - + guard let downloadURL = addonConfig.currentArchURL else { setState(for: addon, to: .failed(error: "No download URL for current architecture")) - return + throw URLError(.badURL) } - + + guard downloadURL.scheme?.lowercased() == "https" else { + setState(for: addon, to: .failed(error: "Add-on URL must use HTTPS")) + throw URLError(.badURL) + } + do { try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil) let destination = binaryPath(for: addon) - - try await BinaryDownloader.download(from: downloadURL, to: destination) { progress in + + try await BinaryDownloader.download( + from: downloadURL, + to: destination, + expectedSHA256: addonConfig.currentArchSHA256 + ) { progress in Task { @MainActor in self.setState(for: addon, to: .downloading(progress: progress)) } } - + UserDefaults.standard.set(addonConfig.version, forKey: addon.defaultsKey) setState(for: addon, to: .installed(version: addonConfig.version)) - + } catch { setState(for: addon, to: .failed(error: error.localizedDescription)) + throw error } } - + private func setState(for addon: AddonType, to state: AddonState) { switch addon { case .ytDlp: ytDlpState = state diff --git a/Sources/Firelink/MediaExtractionEngine.swift b/Sources/Firelink/MediaExtractionEngine.swift index 3edb2e1..f1ef679 100644 --- a/Sources/Firelink/MediaExtractionEngine.swift +++ b/Sources/Firelink/MediaExtractionEngine.swift @@ -32,81 +32,82 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable { let formatSelector: String let isAudioOnly: Bool let symbol: String + let outputExtension: String } enum MediaExtractionEngine { + private static let metadataTimeoutSeconds: UInt64 = 75 + enum ExtractionError: Error, LocalizedError { case processFailed(String) case invalidOutput case parsingFailed(Error) + case timedOut var errorDescription: String? { switch self { case .processFailed(let msg): return "Extraction failed: \(msg)" case .invalidOutput: return "Invalid output from media engine." case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)" + case .timedOut: return "Fetching metadata timed out. Try again, update yt-dlp, or change the selected browser cookie source." } } } - static func fetchMetadata(for url: URL) async throws -> (MediaMetadata, [CleanFormatOption]) { + static func fetchMetadata( + for url: URL, + cookieSource: BrowserCookieSource, + credentials: DownloadCredentials?, + transferOptions: DownloadTransferOptions + ) async throws -> (MediaMetadata, [CleanFormatOption]) { let ytDlpPath = await MediaEngineManager.shared.binaryPath(for: .ytDlp).path - guard FileManager.default.fileExists(atPath: ytDlpPath) else { + guard FileManager.default.isExecutableFile(atPath: ytDlpPath) else { throw ExtractionError.processFailed("yt-dlp binary not found.") } - - let process = Process() - process.executableURL = URL(fileURLWithPath: ytDlpPath) - - var args = ["-J", "--no-warnings", "--ignore-no-formats-error"] - - // Add cookies if configured - if let storedData = UserDefaults.standard.data(forKey: "Firelink.AppSettings.v1"), - let json = try? JSONSerialization.jsonObject(with: storedData) as? [String: Any], - let cookieSourceStr = json["mediaCookieSource"] as? String, - cookieSourceStr != "None" { - args.append(contentsOf: ["--cookies-from-browser", cookieSourceStr.lowercased()]) - } - + + var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist"] + appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions) args.append(url.absoluteString) - process.arguments = args - - let pipe = Pipe() - let errorPipe = Pipe() - process.standardOutput = pipe - process.standardError = errorPipe - - return try await withCheckedThrowingContinuation { continuation in - do { - try process.run() - } catch { - continuation.resume(throwing: ExtractionError.processFailed(error.localizedDescription)) - return - } - - process.terminationHandler = { p in - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - - if p.terminationStatus != 0 { - let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error" - continuation.resume(throwing: ExtractionError.processFailed(errorString)) - return - } - - guard !data.isEmpty else { - continuation.resume(throwing: ExtractionError.invalidOutput) - return - } - - do { - let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data) - let options = extractOptions(from: metadata) - continuation.resume(returning: (metadata, options)) - } catch { - continuation.resume(throwing: ExtractionError.parsingFailed(error)) - } - } + + let data = try await YTDLPMetadataProcess( + executableURL: URL(fileURLWithPath: ytDlpPath), + arguments: args + ).run(timeoutSeconds: metadataTimeoutSeconds) + + guard !data.isEmpty else { + throw ExtractionError.invalidOutput + } + + do { + let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data) + let options = extractOptions(from: metadata) + return (metadata, options) + } catch { + throw ExtractionError.parsingFailed(error) + } + } + + static func appendCommonArguments( + to args: inout [String], + cookieSource: BrowserCookieSource, + credentials: DownloadCredentials?, + transferOptions: DownloadTransferOptions + ) { + if let browserName = cookieSource.ytDlpBrowserName { + args.append(contentsOf: ["--cookies-from-browser", browserName]) + } + + for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty { + args.append(contentsOf: ["--add-header", header.headerLine]) + } + + if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), + !cookieHeader.isEmpty { + args.append(contentsOf: ["--add-header", "Cookie: \(cookieHeader)"]) + } + + if let credentials, !credentials.isEmpty { + args.append(contentsOf: ["--username", credentials.username, "--password", credentials.password]) } } @@ -134,7 +135,8 @@ enum MediaExtractionEngine { name: "Video \(name)", formatSelector: "bestvideo[height<=\(res)]+bestaudio/best", isAudioOnly: false, - symbol: "play.tv.fill" + symbol: "play.tv.fill", + outputExtension: "mp4" )) addedResolutions.insert(res) } @@ -146,7 +148,8 @@ enum MediaExtractionEngine { name: "Best Video", formatSelector: "bestvideo+bestaudio/best", isAudioOnly: false, - symbol: "play.tv.fill" + symbol: "play.tv.fill", + outputExtension: "mp4" )) } else if options.isEmpty { // If we really don't have height info, just offer best @@ -154,7 +157,8 @@ enum MediaExtractionEngine { name: "Default Video", formatSelector: "best", isAudioOnly: false, - symbol: "play.tv.fill" + symbol: "play.tv.fill", + outputExtension: "mp4" )) } @@ -163,16 +167,126 @@ enum MediaExtractionEngine { name: "Audio MP3", formatSelector: "bestaudio/best", // Actual extraction to MP3 needs ffmpeg, which we have. We will handle the conversion flags later in the download engine. isAudioOnly: true, - symbol: "music.note" + symbol: "music.note", + outputExtension: "mp3" )) options.append(CleanFormatOption( name: "Audio M4A", formatSelector: "bestaudio[ext=m4a]/bestaudio/best", isAudioOnly: true, - symbol: "waveform" + symbol: "waveform", + outputExtension: "m4a" )) return options } } + +private final class YTDLPMetadataProcess: @unchecked Sendable { + private let executableURL: URL + private let arguments: [String] + private let lock = NSLock() + private var process: Process? + + init(executableURL: URL, arguments: [String]) { + self.executableURL = executableURL + self.arguments = arguments + } + + func run(timeoutSeconds: UInt64) async throws -> Data { + try await withTaskCancellationHandler { + try await withThrowingTaskGroup(of: Data.self) { group in + group.addTask { + try await self.runProcess() + } + group.addTask { + try await Task.sleep(for: .seconds(timeoutSeconds)) + self.terminate() + throw MediaExtractionEngine.ExtractionError.timedOut + } + + guard let result = try await group.next() else { + throw MediaExtractionEngine.ExtractionError.invalidOutput + } + group.cancelAll() + return result + } + } onCancel: { + self.terminate() + } + } + + private func runProcess() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + let outputPipe = Pipe() + let errorPipe = Pipe() + let outputBuffer = LockedDataBuffer(maxBytes: 64 * 1024 * 1024) + let errorBuffer = LockedDataBuffer() + + process.executableURL = executableURL + process.arguments = arguments + process.standardOutput = outputPipe + process.standardError = errorPipe + process.standardInput = nil + + outputPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + outputBuffer.append(data) + } + errorPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + errorBuffer.append(data) + } + + lock.withLock { + self.process = process + } + + process.terminationHandler = { finishedProcess in + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + + if finishedProcess.terminationStatus == 0 { + continuation.resume(returning: outputBuffer.data) + return + } + + let stderr = String(data: errorBuffer.data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let stdout = String(data: outputBuffer.data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let message = [stderr, stdout] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: "\n") + continuation.resume( + throwing: MediaExtractionEngine.ExtractionError.processFailed( + message.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : message + ) + ) + } + + do { + try process.run() + outputPipe.fileHandleForWriting.closeFile() + errorPipe.fileHandleForWriting.closeFile() + } catch { + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + continuation.resume(throwing: MediaExtractionEngine.ExtractionError.processFailed(error.localizedDescription)) + } + } + } + + private func terminate() { + lock.withLock { + if let process, process.isRunning { + process.terminate() + } + } + } +} diff --git a/Sources/Firelink/MediaInspectorCard.swift b/Sources/Firelink/MediaInspectorCard.swift index 4ed54c3..e3f55de 100644 --- a/Sources/Firelink/MediaInspectorCard.swift +++ b/Sources/Firelink/MediaInspectorCard.swift @@ -2,15 +2,21 @@ import SwiftUI struct MediaInspectorCard: View { let url: URL + let cookieSource: BrowserCookieSource + let credentials: DownloadCredentials? + let transferOptions: DownloadTransferOptions let onDownload: (CleanFormatOption, MediaMetadata) -> Void - + + @ObservedObject private var engineManager = MediaEngineManager.shared + @State private var isLoading = true @State private var statusText = "Checking Media Engine..." @State private var metadata: MediaMetadata? @State private var options: [CleanFormatOption] = [] @State private var selectedOptionID: String? @State private var errorMessage: String? - + @State private var loadTask: Task? + var body: some View { ZStack { // Blurred Background @@ -26,18 +32,32 @@ struct MediaInspectorCard: View { Color.clear } } - + Rectangle() .fill(.ultraThinMaterial) - + VStack(spacing: 24) { if isLoading { VStack(spacing: 16) { ProgressView() .controlSize(.large) - Text(statusText) - .font(.headline) - .foregroundStyle(.secondary) + + let ytState = engineManager.ytDlpState + let ffState = engineManager.ffmpegState + + if case let .downloading(p) = ytState, p > 0 { + Text("Downloading yt-dlp: \(Int(p * 100))%") + .font(.headline) + .foregroundStyle(.secondary) + } else if case let .downloading(p) = ffState, p > 0 { + Text("Downloading ffmpeg: \(Int(p * 100))%") + .font(.headline) + .foregroundStyle(.secondary) + } else { + Text(statusText) + .font(.headline) + .foregroundStyle(.secondary) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let errorMessage { @@ -52,7 +72,7 @@ struct MediaInspectorCard: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) - + Button("Retry") { loadMetadata() } @@ -73,8 +93,12 @@ struct MediaInspectorCard: View { .onAppear { loadMetadata() } + .onDisappear { + loadTask?.cancel() + loadTask = nil + } } - + @ViewBuilder private func contentView(metadata: MediaMetadata) -> some View { VStack(spacing: 20) { @@ -94,18 +118,18 @@ struct MediaInspectorCard: View { .frame(width: 140, height: 90) } } - + VStack(alignment: .leading, spacing: 6) { Text(metadata.title ?? "Unknown Title") .font(.headline) .lineLimit(2) - + if let uploader = metadata.displayUploader { Text(uploader) .font(.subheadline) .foregroundStyle(.secondary) } - + if let duration = metadata.duration { Text(formatDuration(duration)) .font(.caption.monospacedDigit()) @@ -114,14 +138,14 @@ struct MediaInspectorCard: View { } Spacer() } - + Divider() - + // Format Picker VStack(alignment: .leading, spacing: 12) { Text("Select Format") .font(.subheadline.weight(.semibold)) - + ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(options) { option in @@ -131,9 +155,9 @@ struct MediaInspectorCard: View { .padding(.bottom, 4) // For shadow } } - + Spacer() - + // Action Button { if let selected = options.first(where: { $0.id == selectedOptionID }) { @@ -149,11 +173,11 @@ struct MediaInspectorCard: View { .disabled(selectedOptionID == nil) } } - + @ViewBuilder private func formatCard(for option: CleanFormatOption) -> some View { let isSelected = selectedOptionID == option.id - + Button { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { selectedOptionID = option.id @@ -163,7 +187,7 @@ struct MediaInspectorCard: View { Image(systemName: option.symbol) .font(.system(size: 24)) .foregroundStyle(isSelected ? .white : .accentColor) - + Text(option.name) .font(.subheadline.weight(.medium)) .foregroundStyle(isSelected ? .white : .primary) @@ -182,23 +206,36 @@ struct MediaInspectorCard: View { .buttonStyle(.plain) .scaleEffect(isSelected ? 1.05 : 1.0) } - + private func loadMetadata() { + loadTask?.cancel() isLoading = true errorMessage = nil - - Task { + + loadTask = Task { do { - statusText = "Checking Media Engine..." + await MainActor.run { + statusText = "Checking Media Engine..." + } try await MediaEngineManager.shared.ensureInstalled() - - statusText = "Fetching Metadata..." - let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(for: url) - + guard !Task.isCancelled else { return } + + await MainActor.run { + statusText = "Fetching Metadata..." + } + let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata( + for: url, + cookieSource: cookieSource, + credentials: credentials, + transferOptions: transferOptions + ) + guard !Task.isCancelled else { return } + await MainActor.run { self.metadata = fetchedMetadata self.options = fetchedOptions self.selectedOptionID = fetchedOptions.first?.id + self.loadTask = nil withAnimation { self.isLoading = false } @@ -206,6 +243,7 @@ struct MediaInspectorCard: View { } catch { await MainActor.run { self.errorMessage = error.localizedDescription + self.loadTask = nil withAnimation { self.isLoading = false } @@ -213,7 +251,7 @@ struct MediaInspectorCard: View { } } } - + private func formatDuration(_ duration: Double) -> String { let formatter = DateComponentsFormatter() formatter.allowedUnits = duration > 3600 ? [.hour, .minute, .second] : [.minute, .second]