From eaea5bd908002e0c4b8d39c865f8c11762e0f82b Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:23:55 +0330 Subject: [PATCH] feat: integrate yt-dlp to DownloadController and add global queue support --- Sources/Firelink/AddDownloadsView.swift | 24 ++- Sources/Firelink/DownloadController.swift | 181 ++++++++++++++------- Sources/Firelink/MediaDownloadEngine.swift | 140 ++++++++++++++++ Sources/Firelink/MediaInspectorCard.swift | 4 +- Sources/Firelink/Models.swift | 2 + 5 files changed, 287 insertions(+), 64 deletions(-) create mode 100644 Sources/Firelink/MediaDownloadEngine.swift diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index fc367c3..a22dc43 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -64,9 +64,27 @@ struct AddDownloadsView: View { } if isMediaMode, let mediaURL = detectedMediaURL { - MediaInspectorCard(url: mediaURL) { selectedFormat in - print("Selected format: \(selectedFormat.name) with selector: \(selectedFormat.formatSelector)") - // TODO: Add to queue using MediaDownloadEngine + MediaInspectorCard(url: mediaURL) { selectedFormat, metadata in + let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media") + let ext = selectedFormat.isAudioOnly ? "mp3" : "mp4" + let fileName = "\(cleanTitle).\(ext)" + let category = FileClassifier.category(forFileName: fileName) + + var item = DownloadItem( + url: mediaURL, + fileName: fileName, + category: category, + destinationDirectory: settings.destinationDirectory(for: category), + connectionsPerServer: 1 + ) + 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) + dismiss() } .transition(.scale(scale: 0.95).combined(with: .opacity)) diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 9bb43d0..9f0741b 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -15,7 +15,9 @@ final class DownloadController: ObservableObject { private let settings: AppSettings private let engine = Aria2DownloadEngine() + private let mediaEngine = MediaDownloadEngine() private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:] + private var activeMediaHandles: [UUID: MediaDownloadEngine.Handle] = [:] private var automaticRetryCounts: [UUID: Int] = [:] private var restrictQueueToAutoResume = false private var queuePumpScope: QueuePumpScope = .idle @@ -211,6 +213,8 @@ final class DownloadController: ObservableObject { func pause(_ item: DownloadItem) { activeHandles[item.id]?.cancel() activeHandles[item.id] = nil + activeMediaHandles[item.id]?.cancel() + activeMediaHandles[item.id] = nil update(item.id) { $0.status = .paused $0.message = "Paused. Resume will continue from the partial file." @@ -234,6 +238,8 @@ final class DownloadController: ObservableObject { for item in activeItems { activeHandles[item.id]?.cancel() activeHandles[item.id] = nil + activeMediaHandles[item.id]?.cancel() + activeMediaHandles[item.id] = nil update(item.id) { $0.status = .paused $0.message = "Paused. Resume will continue from the partial file." @@ -252,6 +258,8 @@ final class DownloadController: ObservableObject { func queue(_ item: DownloadItem) { activeHandles[item.id]?.cancel() activeHandles[item.id] = nil + activeMediaHandles[item.id]?.cancel() + activeMediaHandles[item.id] = nil update(item.id) { $0.status = .queued if item.status != .paused { @@ -327,6 +335,8 @@ final class DownloadController: ObservableObject { func cancel(_ item: DownloadItem) { activeHandles[item.id]?.cancel() activeHandles[item.id] = nil + activeMediaHandles[item.id]?.cancel() + activeMediaHandles[item.id] = nil update(item.id) { $0.status = .canceled $0.message = "Canceled" @@ -349,6 +359,8 @@ final class DownloadController: ObservableObject { func delete(_ item: DownloadItem, deleteFiles: Bool = false) { activeHandles[item.id]?.cancel() activeHandles[item.id] = nil + activeMediaHandles[item.id]?.cancel() + activeMediaHandles[item.id] = nil if deleteFiles { trashFiles(for: item) } else if item.status != .completed { @@ -473,73 +485,124 @@ final class DownloadController: ObservableObject { } saveDownloads() - do { - let handle = try engine.start( - item: item, - proxyConfiguration: settings.downloadProxyConfiguration, - speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item), - progress: { [weak self] progress in - Task { @MainActor in - self?.update(item.id) { - guard $0.status == .downloading else { return } - $0.progress = progress.fraction - $0.bytesText = progress.bytesText - $0.speedText = progress.speedText - $0.etaText = progress.etaText - $0.connectionCount = progress.connectionCount - $0.message = "Downloading" - } - } - }, - completion: { [weak self] result in - Task { @MainActor in - guard let self else { return } - self.activeHandles[item.id] = nil - - switch result { - case .success: - self.automaticRetryCounts[item.id] = nil - self.update(item.id) { - $0.status = .completed - $0.progress = 1 - $0.speedText = "-" - $0.etaText = "-" - $0.message = "Saved to \($0.destinationPath)" - $0.autoResumeOnLaunch = false + if item.mediaFormatSelector != nil { + Task { + do { + let handle = try await mediaEngine.start( + item: item, + progress: { [weak self] progress in + Task { @MainActor in + self?.update(item.id) { + guard $0.status == .downloading else { return } + $0.progress = progress.fraction + $0.bytesText = progress.bytesText + $0.speedText = progress.speedText + $0.etaText = progress.etaText + $0.connectionCount = progress.connectionCount + if $0.message == "Starting" { + $0.message = "Downloading Media" + } + } } - self.saveDownloads() - self.showNotification(title: "Download Completed", body: item.fileName) - case .failure(let error): - if self.downloads.first(where: { $0.id == item.id })?.status == .paused || - self.downloads.first(where: { $0.id == item.id })?.status == .canceled { - return + }, + messageUpdate: { [weak self] message in + Task { @MainActor in + self?.update(item.id) { + guard $0.status == .downloading else { return } + $0.message = message + } + } + }, + completion: { [weak self] result in + Task { @MainActor in + self?.handleCompletion(item: item, result: result, isMedia: true) } - self.handleDownloadFailure(itemID: item.id, error: error) } - - self.pumpQueue() - self.applySpeedLimitsToActiveDownloads() - self.updateSleepActivity() - } + ) + activeMediaHandles[item.id] = handle + } catch { + handleDownloadFailure(itemID: item.id, error: error) + applySpeedLimitsToActiveDownloads() + updateSleepActivity() + pumpQueue() } - ) - activeHandles[item.id] = handle - update(item.id) { - $0.rpcPort = handle.rpcPort - $0.rpcSecret = handle.rpcSecret - $0.message = "Process \(handle.processIdentifier)" } - saveDownloads() - applySpeedLimitsToActiveDownloads() - updateSleepActivity() - } catch { - handleDownloadFailure(itemID: item.id, error: error) - applySpeedLimitsToActiveDownloads() - updateSleepActivity() - pumpQueue() + } else { + do { + let handle = try engine.start( + item: item, + proxyConfiguration: settings.downloadProxyConfiguration, + speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item), + progress: { [weak self] progress in + Task { @MainActor in + self?.update(item.id) { + guard $0.status == .downloading else { return } + $0.progress = progress.fraction + $0.bytesText = progress.bytesText + $0.speedText = progress.speedText + $0.etaText = progress.etaText + $0.connectionCount = progress.connectionCount + $0.message = "Downloading" + } + } + }, + completion: { [weak self] result in + Task { @MainActor in + self?.handleCompletion(item: item, result: result, isMedia: false) + } + } + ) + activeHandles[item.id] = handle + update(item.id) { + $0.rpcPort = handle.rpcPort + $0.rpcSecret = handle.rpcSecret + $0.message = "Process \(handle.processIdentifier)" + } + saveDownloads() + applySpeedLimitsToActiveDownloads() + updateSleepActivity() + } catch { + handleDownloadFailure(itemID: item.id, error: error) + applySpeedLimitsToActiveDownloads() + updateSleepActivity() + pumpQueue() + } } } + private func handleCompletion(item: DownloadItem, result: Result, isMedia: Bool) { + if isMedia { + activeMediaHandles[item.id] = nil + } else { + activeHandles[item.id] = nil + } + + switch result { + case .success: + self.automaticRetryCounts[item.id] = nil + self.update(item.id) { + $0.status = .completed + $0.progress = 1 + $0.speedText = "-" + $0.etaText = "-" + $0.message = "Saved to \($0.destinationPath)" + $0.autoResumeOnLaunch = false + } + self.saveDownloads() + self.showNotification(title: "Download Completed", body: item.fileName) + case .failure(let error): + if self.downloads.first(where: { $0.id == item.id })?.status == .paused || + self.downloads.first(where: { $0.id == item.id })?.status == .canceled { + return + } + self.handleDownloadFailure(itemID: item.id, error: error) + } + + self.pumpQueue() + self.applySpeedLimitsToActiveDownloads() + self.updateSleepActivity() + } + private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) { guard let index = downloads.firstIndex(where: { $0.id == id }) else { return } mutate(&downloads[index]) diff --git a/Sources/Firelink/MediaDownloadEngine.swift b/Sources/Firelink/MediaDownloadEngine.swift new file mode 100644 index 0000000..3596b96 --- /dev/null +++ b/Sources/Firelink/MediaDownloadEngine.swift @@ -0,0 +1,140 @@ +import Foundation + +final class MediaDownloadEngine: @unchecked Sendable { + struct Handle { + let cancel: @Sendable () -> Void + } + + enum EngineError: LocalizedError { + case missingEngine(String) + case launchFailed(String) + + var errorDescription: String? { + switch self { + case .missingEngine(let msg): return msg + case .launchFailed(let msg): return msg + } + } + } + + func start( + item: DownloadItem, + progress: @escaping @Sendable (DownloadProgress) -> Void, + messageUpdate: @escaping @Sendable (String) -> Void, + completion: @escaping @Sendable (Result) -> Void + ) async throws -> Handle { + let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp) + let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg) + + guard FileManager.default.fileExists(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 { + throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.") + } + + try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true) + + let process = Process() + process.executableURL = ytDlpURL + + var arguments = [ + "--newline", + "--ffmpeg-location", ffmpegURL.path, + "-o", item.destinationPath + ] + + if let format = item.mediaFormatSelector { + arguments.append("-f") + arguments.append(format) + + if item.isAudioOnlyMedia == true { + arguments.append(contentsOf: ["-x", "--audio-format", "mp3", "--audio-quality", "0"]) + } else { + arguments.append(contentsOf: ["--merge-output-format", "mp4"]) + } + } + + arguments.append(item.url.absoluteString) + process.arguments = arguments + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + let parser = YTDLPProgressParser() + let completionGate = CompletionGate(completion) + + 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...") + } + } + } + + process.terminationHandler = { finishedProcess in + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + + 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" + completionGate.complete(.failure(EngineError.launchFailed(errorString.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : errorString))) + } + } + + try process.run() + + return Handle(cancel: { + if process.isRunning { + process.terminate() + } + }) + } +} + +final class YTDLPProgressParser: @unchecked Sendable { + private let percentageRegex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)%"#) + private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#) + private let etaRegex = try? NSRegularExpression(pattern: #"ETA\s+([^\s]+)"#) + 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 + ) + } + + private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? { + guard let regex else { return nil } + let range = NSRange(text.startIndex.. 1 else { + return nil + } + guard let captureRange = Range(match.range(at: 1), in: text) else { + return nil + } + return String(text[captureRange]) + } +} diff --git a/Sources/Firelink/MediaInspectorCard.swift b/Sources/Firelink/MediaInspectorCard.swift index 42cceae..4ed54c3 100644 --- a/Sources/Firelink/MediaInspectorCard.swift +++ b/Sources/Firelink/MediaInspectorCard.swift @@ -2,7 +2,7 @@ import SwiftUI struct MediaInspectorCard: View { let url: URL - let onDownload: (CleanFormatOption) -> Void + let onDownload: (CleanFormatOption, MediaMetadata) -> Void @State private var isLoading = true @State private var statusText = "Checking Media Engine..." @@ -137,7 +137,7 @@ struct MediaInspectorCard: View { // Action Button { if let selected = options.first(where: { $0.id == selectedOptionID }) { - onDownload(selected) + onDownload(selected, metadata) } } label: { Text("Download") diff --git a/Sources/Firelink/Models.swift b/Sources/Firelink/Models.swift index 1dcd302..4233930 100644 --- a/Sources/Firelink/Models.swift +++ b/Sources/Firelink/Models.swift @@ -188,6 +188,8 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable { var queueID: UUID? var rpcPort: Int? var rpcSecret: String? + var mediaFormatSelector: String? + var isAudioOnlyMedia: Bool? var displaySpeedText: String { status == .downloading ? speedText : "-"