mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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
This commit is contained in:
@@ -22,3 +22,4 @@ sparkle_private_key*
|
||||
SparklePrivateKey*
|
||||
private-key*
|
||||
private_key*
|
||||
yt-dlp
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<DownloadItem.ID>) -> some View {
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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<AddonType> = 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<AddonType>) 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))
|
||||
|
||||
|
||||
@@ -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<Int>()
|
||||
|
||||
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 {
|
||||
|
||||
@@ -17,6 +17,14 @@ struct MediaInspectorCard: View {
|
||||
@State private var errorMessage: String?
|
||||
@State private var loadTask: Task<Void, Never>?
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user