From 1a2c59d243f56cecd209a6af87eb04f8d6e7e11f Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:28:23 +0330 Subject: [PATCH] fix: media downloads connections, progress parsing, file size, and selection highlight - Removed hardcoded 1 connection limit for media downloads - Added support for aria2c progress parsing in media downloads - Fixed missing file size after download completion by reading from disk - Fixed row selection highlight in DownloadTable by using onTapGesture instead of simultaneousGesture --- .gitignore | 1 + Sources/Firelink/AddDownloadsView.swift | 2 +- Sources/Firelink/AppSettings.swift | 20 +++ Sources/Firelink/DownloadController.swift | 16 +- Sources/Firelink/DownloadTable.swift | 22 ++- Sources/Firelink/MediaDownloadEngine.swift | 167 ++++++++++++++---- Sources/Firelink/MediaEngineManager.swift | 68 +++++-- Sources/Firelink/MediaExtractionEngine.swift | 129 ++++++++++---- Sources/Firelink/MediaInspectorCard.swift | 100 ++++++++--- .../Settings/EngineSettingsPane.swift | 15 +- 10 files changed, 417 insertions(+), 123 deletions(-) diff --git a/.gitignore b/.gitignore index 5e200d4..300f520 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ sparkle_private_key* SparklePrivateKey* private-key* private_key* +yt-dlp diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index 6edeea4..6eab01f 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -80,7 +80,7 @@ struct AddDownloadsView: View { fileName: fileName, category: category, destinationDirectory: overrideDirectory ?? settings.destinationDirectory(for: category), - connectionsPerServer: 1, + connectionsPerServer: Int(connectionsPerServer), credentials: explicitCredentials(for: [mediaURL]) ?? settings.credentials(for: mediaURL), checksum: transferOptions.checksum, requestHeaders: transferOptions.requestHeaders, diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 54164b6..5fcfac1 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -36,6 +36,26 @@ enum BrowserCookieSource: String, Codable, CaseIterable, Sendable { rawValue.lowercased() } } + + var statusTitle: String { + switch self { + case .none: + "Not using browser cookies" + case .safari, .chrome, .firefox, .edge, .brave: + "Using \(rawValue) cookies" + } + } + + var statusDetail: String { + switch self { + case .none: + "Restricted media may fail if the site requires login." + case .safari: + "yt-dlp reads Safari cookies during metadata fetch and download. Safari may require Full Disk Access." + case .chrome, .firefox, .edge, .brave: + "yt-dlp reads these browser cookies during metadata fetch and download. Firelink does not store them." + } + } } enum ProxyType: String, Codable, CaseIterable, Sendable { diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index b69c587..77d98d8 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -210,7 +210,6 @@ final class DownloadController: ObservableObject { 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) @@ -531,6 +530,15 @@ final class DownloadController: ObservableObject { if item.mediaFormatSelector != nil { Task { do { + update(item.id) { + guard $0.status == .downloading else { return } + $0.message = "Checking media add-ons..." + } + try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg]) + update(item.id) { + guard $0.status == .downloading else { return } + $0.message = "Starting yt-dlp..." + } let handle = try await mediaEngine.start( item: item, cookieSource: settings.mediaCookieSource, @@ -633,6 +641,12 @@ final class DownloadController: ObservableObject { $0.etaText = "-" $0.message = "Saved to \($0.destinationPath)" $0.autoResumeOnLaunch = false + + if let attr = try? FileManager.default.attributesOfItem(atPath: $0.destinationPath), + let size = attr[.size] as? Int64 { + $0.sizeBytes = size + $0.bytesText = ByteFormatter.string(size) + } } self.saveDownloads() self.showNotification(title: "Download Completed", body: item.fileName) diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index 1e6209f..200bfaf 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -72,10 +72,10 @@ struct DownloadTable: View { TableColumn("Status", value: \.status.rawValue) { item in doubleClickableCell(for: item) { - Text(item.status.rawValue) + statusCell(for: item) } } - .width(min: 80, ideal: 105) + .width(min: 115, ideal: 170) TableColumn("Speed", value: \.displaySpeedText) { item in doubleClickableCell(for: item) { @@ -164,9 +164,9 @@ struct DownloadTable: View { content() .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) - .simultaneousGesture(TapGesture(count: 2).onEnded { + .onTapGesture(count: 2) { performPrimaryAction(for: item) - }) + } } private func performPrimaryAction(for item: DownloadItem) { @@ -177,6 +177,20 @@ struct DownloadTable: View { } } + @ViewBuilder + private func statusCell(for item: DownloadItem) -> some View { + let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines) + if item.status == .downloading, !message.isEmpty { + Text(message) + .lineLimit(1) + .truncationMode(.tail) + } else { + Text(item.status.rawValue) + .lineLimit(1) + .truncationMode(.tail) + } + } + @ViewBuilder private func rowContextMenu(for itemIDs: Set) -> some View { let targetItems = controller.downloads.filter { itemIDs.contains($0.id) } diff --git a/Sources/Firelink/MediaDownloadEngine.swift b/Sources/Firelink/MediaDownloadEngine.swift index b738289..5150bdd 100644 --- a/Sources/Firelink/MediaDownloadEngine.swift +++ b/Sources/Firelink/MediaDownloadEngine.swift @@ -55,7 +55,8 @@ final class MediaDownloadEngine: @unchecked Sendable { 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"]) + let mergeFormat = item.fileName.fileExtension(defaultValue: "mp4") + arguments.append(contentsOf: ["--merge-output-format", mergeFormat]) } } @@ -73,7 +74,9 @@ final class MediaDownloadEngine: @unchecked Sendable { if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 { arguments.append(contentsOf: ["--limit-rate", "\(speedLimitKiBPerSecond)K"]) } - + + appendParallelDownloadArguments(to: &arguments, connectionsPerServer: item.connectionsPerServer) + arguments.append(item.url.absoluteString) process.arguments = arguments @@ -81,23 +84,20 @@ final class MediaDownloadEngine: @unchecked Sendable { let errorPipe = Pipe() process.standardOutput = outputPipe process.standardError = errorPipe - + let parser = YTDLPProgressParser() let errorBuffer = LockedDataBuffer() let completionGate = CompletionGate(completion) - + let outputHandler = YTDLPOutputHandler( + parser: parser, + progress: progress, + messageUpdate: messageUpdate + ) + outputPipe.fileHandleForReading.readabilityHandler = { handle in let data = handle.availableData guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } - - for line in text.split(whereSeparator: \.isNewline) { - let stringLine = String(line) - if let update = parser.parse(stringLine) { - progress(update) - } else if stringLine.contains("[Merger]") || stringLine.contains("[ExtractAudio]") { - messageUpdate("Processing Media...") - } - } + outputHandler.handle(text) } errorPipe.fileHandleForReading.readabilityHandler = { handle in @@ -105,12 +105,7 @@ final class MediaDownloadEngine: @unchecked Sendable { 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...") - } - } + outputHandler.handle(text) } } @@ -122,11 +117,12 @@ final class MediaDownloadEngine: @unchecked Sendable { completionGate.complete(.success(())) } else { 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))) + completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus)))) } } try process.run() + messageUpdate("Fetching YouTube data...") outputPipe.fileHandleForWriting.closeFile() errorPipe.fileHandleForWriting.closeFile() @@ -136,6 +132,94 @@ final class MediaDownloadEngine: @unchecked Sendable { } }) } + + private static func cleanErrorMessage(_ message: String, status: Int32) -> String { + guard !message.isEmpty else { + return "Exit code \(status)" + } + + if message.localizedCaseInsensitiveContains("Sign in to confirm") || + message.localizedCaseInsensitiveContains("not a bot") || + message.localizedCaseInsensitiveContains("Use --cookies-from-browser") { + return "YouTube requires browser cookies for this video. Choose a browser in Settings > Engine, then retry." + } + + if message.localizedCaseInsensitiveContains("n challenge solving failed") || + message.localizedCaseInsensitiveContains("supported JavaScript runtime") { + return "YouTube challenge solving failed. Install Deno or Node, then retry." + } + + return message + } + + private func appendParallelDownloadArguments(to arguments: inout [String], connectionsPerServer: Int) { + let connections = min(max(connectionsPerServer, 1), 16) + guard connections > 1 else { return } + + arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"]) + + guard let aria2URL = Aria2DownloadEngine.findExecutable() else { + return + } + + arguments.append(contentsOf: [ + "--downloader", aria2URL.path, + "--downloader-args", "aria2c:-x \(connections) -s \(connections) -k 1M --file-allocation=none" + ]) + } +} + +final class YTDLPOutputHandler: @unchecked Sendable { + private let parser: YTDLPProgressParser + private let progress: @Sendable (DownloadProgress) -> Void + private let messageUpdate: @Sendable (String) -> Void + + init( + parser: YTDLPProgressParser, + progress: @escaping @Sendable (DownloadProgress) -> Void, + messageUpdate: @escaping @Sendable (String) -> Void + ) { + self.parser = parser + self.progress = progress + self.messageUpdate = messageUpdate + } + + func handle(_ text: String) { + for line in text.split(whereSeparator: \.isNewline) { + let stringLine = String(line) + if let update = parser.parse(stringLine) { + progress(update) + messageUpdate("Downloading Media") + } else if let message = statusMessage(for: stringLine) { + messageUpdate(message) + } + } + } + + private func statusMessage(for line: String) -> String? { + if line.contains("[Merger]") || line.contains("[ExtractAudio]") || line.contains("[Fixup") { + return "Processing Media..." + } + if line.contains("[youtube]") && line.localizedCaseInsensitiveContains("Downloading") { + return "Fetching YouTube data..." + } + if line.contains("[info]") && line.localizedCaseInsensitiveContains("Downloading") { + return "Preparing media stream..." + } + if line.localizedCaseInsensitiveContains("Sign in to confirm") || + line.localizedCaseInsensitiveContains("not a bot") || + line.localizedCaseInsensitiveContains("Use --cookies-from-browser") { + return "YouTube requires browser cookies" + } + if line.localizedCaseInsensitiveContains("n challenge solving failed") || + line.localizedCaseInsensitiveContains("supported JavaScript runtime") { + return "YouTube challenge solver unavailable" + } + if line.contains("Destination:") { + return "Starting media download..." + } + return nil + } } private extension String { @@ -152,20 +236,35 @@ final class YTDLPProgressParser: @unchecked Sendable { private let sizeRegex = try? NSRegularExpression(pattern: #"of\s+~?([0-9.]+[a-zA-Z]+)"#) func parse(_ line: String) -> DownloadProgress? { - guard line.contains("[download]") && line.contains("%") else { return nil } - - let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0 - let speed = firstCapture(in: line, regex: speedRegex) ?? "-" - let eta = firstCapture(in: line, regex: etaRegex) ?? "-" - let size = firstCapture(in: line, regex: sizeRegex) ?? "-" - - return DownloadProgress( - fraction: min(max(fraction, 0), 1), - bytesText: size, - speedText: speed, - etaText: eta, - connectionCount: 1 - ) + if line.contains("[download]") && line.contains("%") { + let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0 + let speed = firstCapture(in: line, regex: speedRegex) ?? "-" + let eta = firstCapture(in: line, regex: etaRegex) ?? "-" + let size = firstCapture(in: line, regex: sizeRegex) ?? "-" + + return DownloadProgress( + fraction: min(max(fraction, 0), 1), + bytesText: size, + speedText: speed, + etaText: eta, + connectionCount: 1 + ) + } else if line.contains("[#") && line.contains("DL:") { + let fraction = (Double(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"\(([\d.]+)%\)"#)) ?? "0") ?? 0) / 100.0 + let speed = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"DL:([^\s\]]+)"#)) ?? "-" + let eta = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"ETA:([^\]]+)"#)) ?? "-" + let size = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"/([^\s\(]+)\("#)) ?? "-" + let cn = Int(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"CN:(\d+)"#)) ?? "1") ?? 1 + + return DownloadProgress( + fraction: min(max(fraction, 0), 1), + bytesText: size, + speedText: speed, + etaText: eta, + connectionCount: cn + ) + } + return nil } private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? { diff --git a/Sources/Firelink/MediaEngineManager.swift b/Sources/Firelink/MediaEngineManager.swift index 837795b..eebe03d 100644 --- a/Sources/Firelink/MediaEngineManager.swift +++ b/Sources/Firelink/MediaEngineManager.swift @@ -75,33 +75,65 @@ final class MediaEngineManager: ObservableObject { 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 + func ensureInstalled(addons requiredAddons: Set = Set(AddonType.allCases)) async throws { let config = try await fetchLatestConfig() - // Use task group to download both if needed try await withThrowingTaskGroup(of: Void.self) { group in - if case .notInstalled = ytDlpState { - 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 { try await self.install(addon: .ytDlp, from: config) } - } - - if case .notInstalled = ffmpegState { - 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 { try await self.install(addon: .ffmpeg, from: config) } + for addon in requiredAddons where shouldInstall(addon: addon, config: config) { + group.addTask { + try await self.install(addon: addon, from: config) + } } try await group.waitForAll() } } + func ensureAvailable(addons requiredAddons: Set) async throws { + checkLocalInstallation() + let missingAddons = requiredAddons.filter { addon in + switch state(for: addon) { + case .installed, .downloading: + return false + case .notInstalled, .failed: + return true + } + } + + guard !missingAddons.isEmpty else { return } + try await ensureInstalled(addons: missingAddons) + } + + private func shouldInstall(addon: AddonType, config: GatekeeperConfig) -> Bool { + let state: AddonState + let configVersion: String? + switch addon { + case .ytDlp: + state = ytDlpState + configVersion = config.ytDlp?.version + case .ffmpeg: + state = ffmpegState + configVersion = config.ffmpeg?.version + } + + switch state { + case .notInstalled, .failed: + return true + case .downloading: + return false + case .installed(let version): + guard let configVersion else { return false } + return version != configVersion + } + } + + private func state(for addon: AddonType) -> AddonState { + switch addon { + case .ytDlp: return ytDlpState + case .ffmpeg: return ffmpegState + } + } + func install(addon: AddonType, from config: GatekeeperConfig) async throws { setState(for: addon, to: .downloading(progress: 0)) diff --git a/Sources/Firelink/MediaExtractionEngine.swift b/Sources/Firelink/MediaExtractionEngine.swift index f1ef679..e282211 100644 --- a/Sources/Firelink/MediaExtractionEngine.swift +++ b/Sources/Firelink/MediaExtractionEngine.swift @@ -33,6 +33,7 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable { let isAudioOnly: Bool let symbol: String let outputExtension: String + let detail: String } enum MediaExtractionEngine { @@ -97,6 +98,8 @@ enum MediaExtractionEngine { args.append(contentsOf: ["--cookies-from-browser", browserName]) } + appendJavaScriptRuntimeArguments(to: &args) + for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty { args.append(contentsOf: ["--add-header", header.headerLine]) } @@ -110,14 +113,47 @@ enum MediaExtractionEngine { args.append(contentsOf: ["--username", credentials.username, "--password", credentials.password]) } } - + + private static func appendJavaScriptRuntimeArguments(to args: inout [String]) { + if let denoPath = executablePath(named: "deno", candidates: [ + "/opt/homebrew/bin/deno", + "/usr/local/bin/deno" + ]) { + args.append(contentsOf: ["--js-runtimes", "deno:\(denoPath)"]) + } + + if let nodePath = executablePath(named: "node", candidates: [ + "/opt/homebrew/bin/node", + "/usr/local/bin/node", + "/usr/bin/node" + ]) { + args.append(contentsOf: ["--js-runtimes", "node:\(nodePath)"]) + } + } + + private static func executablePath(named name: String, candidates: [String]) -> String? { + if let path = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) { + return path + } + + let pathEnvironment = ProcessInfo.processInfo.environment["PATH"] ?? "" + for directory in pathEnvironment.split(separator: ":") { + let candidate = URL(fileURLWithPath: String(directory)).appendingPathComponent(name).path + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + + return nil + } + private static func extractOptions(from metadata: MediaMetadata) -> [CleanFormatOption] { var options: [CleanFormatOption] = [] let rawFormats = metadata.formats ?? [] - + let heights = rawFormats.compactMap { $0.height }.filter { $0 > 0 } let maxHeight = heights.max() ?? 0 - + let standardResolutions = [ (2160, "4K"), (1440, "1440p"), @@ -126,61 +162,76 @@ enum MediaExtractionEngine { (480, "480p"), (360, "360p") ] - - var addedResolutions = Set() - - for (res, name) in standardResolutions { - if maxHeight >= res - 100 && !addedResolutions.contains(res) { // -100 for some leeway (e.g., 1000 instead of 1080) + + let availableResolutions = standardResolutions.filter { resolution, _ in + maxHeight == 0 || maxHeight >= resolution - 100 + } + let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) } + let videoContainers = [ + ("mp4", "MP4"), + ("mkv", "MKV"), + ("webm", "WebM") + ] + + for (height, qualityName) in videoQualities { + for (container, containerName) in videoContainers { options.append(CleanFormatOption( - name: "Video \(name)", - formatSelector: "bestvideo[height<=\(res)]+bestaudio/best", + name: "\(qualityName) \(containerName)", + formatSelector: videoSelector(height: height, container: container), isAudioOnly: false, symbol: "play.tv.fill", - outputExtension: "mp4" + outputExtension: container, + detail: height == nil ? "Best available video" : "Up to \(qualityName)" )) - addedResolutions.insert(res) } } - - if options.isEmpty && maxHeight > 0 { - // Fallback if no standard resolution matched - options.append(CleanFormatOption( - name: "Best Video", - formatSelector: "bestvideo+bestaudio/best", - isAudioOnly: false, - symbol: "play.tv.fill", - outputExtension: "mp4" - )) - } else if options.isEmpty { - // If we really don't have height info, just offer best - options.append(CleanFormatOption( - name: "Default Video", - formatSelector: "best", - isAudioOnly: false, - symbol: "play.tv.fill", - outputExtension: "mp4" - )) - } - - // Add Audio options + options.append(CleanFormatOption( 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. + formatSelector: "bestaudio/best", isAudioOnly: true, symbol: "music.note", - outputExtension: "mp3" + outputExtension: "mp3", + detail: "Converted with ffmpeg" )) - + options.append(CleanFormatOption( name: "Audio M4A", formatSelector: "bestaudio[ext=m4a]/bestaudio/best", isAudioOnly: true, symbol: "waveform", - outputExtension: "m4a" + outputExtension: "m4a", + detail: "Prefer native M4A" )) - + + options.append(CleanFormatOption( + name: "Audio Opus", + formatSelector: "bestaudio[ext=webm]/bestaudio/best", + isAudioOnly: true, + symbol: "waveform", + outputExtension: "opus", + detail: "Efficient audio" + )) + return options } + + private static func videoSelector(height: Int?, container: String) -> String { + let filter = heightFilter(height) + switch container { + case "mp4": + return "bestvideo\(filter)[ext=mp4]+bestaudio[ext=m4a]/best\(filter)[ext=mp4]/bestvideo\(filter)+bestaudio/best\(filter)" + case "webm": + return "bestvideo\(filter)[ext=webm]+bestaudio[ext=webm]/best\(filter)[ext=webm]/bestvideo\(filter)+bestaudio/best\(filter)" + default: + return "bestvideo\(filter)+bestaudio/best\(filter)" + } + } + + private static func heightFilter(_ height: Int?) -> String { + guard let height else { return "" } + return "[height<=\(height)]" + } } private final class YTDLPMetadataProcess: @unchecked Sendable { diff --git a/Sources/Firelink/MediaInspectorCard.swift b/Sources/Firelink/MediaInspectorCard.swift index e3f55de..f1685c1 100644 --- a/Sources/Firelink/MediaInspectorCard.swift +++ b/Sources/Firelink/MediaInspectorCard.swift @@ -17,6 +17,14 @@ struct MediaInspectorCard: View { @State private var errorMessage: String? @State private var loadTask: Task? + private var videoOptions: [CleanFormatOption] { + options.filter { !$0.isAudioOnly } + } + + private var audioOptions: [CleanFormatOption] { + options.filter(\.isAudioOnly) + } + var body: some View { ZStack { // Blurred Background @@ -54,9 +62,12 @@ struct MediaInspectorCard: View { .font(.headline) .foregroundStyle(.secondary) } else { - Text(statusText) - .font(.headline) - .foregroundStyle(.secondary) + VStack(spacing: 6) { + Text(statusText) + .font(.headline) + .foregroundStyle(.secondary) + cookieStatusLabel + } } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -135,26 +146,15 @@ struct MediaInspectorCard: View { .font(.caption.monospacedDigit()) .foregroundStyle(.tertiary) } + + cookieStatusLabel } 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 - formatCard(for: option) - } - } - .padding(.bottom, 4) // For shadow - } - } + formatPickerSection Spacer() @@ -174,6 +174,50 @@ struct MediaInspectorCard: View { } } + @ViewBuilder + private var cookieStatusLabel: some View { + if let browserName = cookieSource.ytDlpBrowserName { + Label("Using \(browserName.capitalized) cookies", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } else { + Label("Browser cookies off", systemImage: "circle") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var formatPickerSection: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + if !videoOptions.isEmpty { + formatGroup(title: "Video Quality and Container", options: videoOptions) + } + + if !audioOptions.isEmpty { + formatGroup(title: "Audio", options: audioOptions) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 2) + } + .frame(minHeight: 180) + } + + @ViewBuilder + private func formatGroup(title: String, options: [CleanFormatOption]) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.subheadline.weight(.semibold)) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 128), spacing: 10)], alignment: .leading, spacing: 10) { + ForEach(options) { option in + formatCard(for: option) + } + } + } + } + @ViewBuilder private func formatCard(for option: CleanFormatOption) -> some View { let isSelected = selectedOptionID == option.id @@ -185,14 +229,22 @@ struct MediaInspectorCard: View { } label: { VStack(spacing: 8) { Image(systemName: option.symbol) - .font(.system(size: 24)) + .font(.system(size: 22)) .foregroundStyle(isSelected ? .white : .accentColor) - Text(option.name) - .font(.subheadline.weight(.medium)) - .foregroundStyle(isSelected ? .white : .primary) + VStack(spacing: 2) { + Text(option.name) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Text(option.detail) + .font(.caption2) + .lineLimit(1) + .foregroundStyle(isSelected ? .white.opacity(0.8) : .secondary) + } + .foregroundStyle(isSelected ? .white : .primary) } - .frame(width: 100, height: 80) + .frame(maxWidth: .infinity, minHeight: 82) + .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(isSelected ? Color.accentColor : Color(NSColor.controlBackgroundColor)) @@ -215,9 +267,9 @@ struct MediaInspectorCard: View { loadTask = Task { do { await MainActor.run { - statusText = "Checking Media Engine..." + statusText = "Checking yt-dlp..." } - try await MediaEngineManager.shared.ensureInstalled() + try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp]) guard !Task.isCancelled else { return } await MainActor.run { diff --git a/Sources/Firelink/Settings/EngineSettingsPane.swift b/Sources/Firelink/Settings/EngineSettingsPane.swift index 62f851f..3291534 100644 --- a/Sources/Firelink/Settings/EngineSettingsPane.swift +++ b/Sources/Firelink/Settings/EngineSettingsPane.swift @@ -84,12 +84,23 @@ struct EngineSettingsPane: View { Spacer() } - Picker("Extract Cookies from", selection: $settings.mediaCookieSource) { + Picker("Browser Cookies", selection: $settings.mediaCookieSource) { ForEach(BrowserCookieSource.allCases, id: \.self) { source in Text(source.rawValue).tag(source) } } - Text("Allows downloading restricted media from sites requiring login. Requires Full Disk Access for Safari.") + + LabeledContent("Cookie Status") { + if settings.mediaCookieSource == .none { + Label(settings.mediaCookieSource.statusTitle, systemImage: "circle") + .foregroundStyle(.secondary) + } else { + Label(settings.mediaCookieSource.statusTitle, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + + Text(settings.mediaCookieSource.statusDetail) .font(.caption) .foregroundStyle(.secondary) }