feat: integrate yt-dlp to DownloadController and add global queue support

This commit is contained in:
nimbold
2026-06-07 10:23:55 +03:30
parent af05180ee6
commit eaea5bd908
5 changed files with 287 additions and 64 deletions
+21 -3
View File
@@ -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))
+122 -59
View File
@@ -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<Void, Error>, 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])
+140
View File
@@ -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, Error>) -> 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..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, range: range), match.numberOfRanges > 1 else {
return nil
}
guard let captureRange = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[captureRange])
}
}
+2 -2
View File
@@ -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")
+2
View File
@@ -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 : "-"