fix(media): stabilize yt-dlp metadata and add-on updates

This commit is contained in:
nimbold
2026-06-07 12:48:00 +03:30
parent a900d97a5c
commit c139ac50f2
9 changed files with 605 additions and 190 deletions
+69 -37
View File
@@ -34,7 +34,7 @@ struct AddDownloadsView: View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
linkSection linkSection
if detectedMediaURL != nil, !isMediaMode { if detectedMediaURL != nil, !isMediaMode {
Button { Button {
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
@@ -62,37 +62,65 @@ struct AddDownloadsView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.transition(.move(edge: .top).combined(with: .opacity)) .transition(.move(edge: .top).combined(with: .opacity))
} }
if isMediaMode, let mediaURL = detectedMediaURL { 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 cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
let ext = selectedFormat.isAudioOnly ? "mp3" : "mp4" let ext = selectedFormat.outputExtension
let fileName = "\(cleanTitle).\(ext)" let fileName = "\(cleanTitle).\(ext)"
let category = FileClassifier.category(forFileName: fileName) let category = FileClassifier.category(forFileName: fileName)
var item = DownloadItem( let item = DownloadItem(
url: mediaURL, url: mediaURL,
fileName: fileName, fileName: fileName,
category: category, category: category,
destinationDirectory: settings.destinationDirectory(for: category), destinationDirectory: overrideDirectory ?? settings.destinationDirectory(for: category),
connectionsPerServer: 1 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 controller.addMediaDownload(item, startImmediately: true)
item.message = "Added to queue"
controller.downloads.append(item)
controller.engineMessage = "Added \(fileName) to \(category.rawValue)."
controller.startQueue(queueID: DownloadQueue.mainQueueID)
dismiss() dismiss()
} }
.transition(.scale(scale: 0.95).combined(with: .opacity)) .transition(.scale(scale: 0.95).combined(with: .opacity))
} else { } else {
optionsSection optionsSection
advancedTransferSection 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) .padding(12)
@@ -230,7 +258,7 @@ struct AddDownloadsView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Toggle("Use authorization", isOn: $useAuthorization) Toggle("Use authorization", isOn: $useAuthorization)
.toggleStyle(.switch) .toggleStyle(.switch)
if useAuthorization { if useAuthorization {
HStack(spacing: 8) { HStack(spacing: 8) {
TextField("Username", text: $authUsername) TextField("Username", text: $authUsername)
@@ -305,7 +333,7 @@ struct AddDownloadsView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
if metadataTask != nil { if metadataTask != nil {
Button { Button {
metadataTask?.cancel() metadataTask?.cancel()
@@ -509,7 +537,7 @@ struct AddDownloadsView: View {
private func refreshMetadata(for text: String, isAutoFetch: Bool) { private func refreshMetadata(for text: String, isAutoFetch: Bool) {
let urls = DownloadURLParser.parse(text) let urls = DownloadURLParser.parse(text)
metadataTask?.cancel() metadataTask?.cancel()
if let first = urls.first, MediaDetector.isSupportedMedia(url: first) { if let first = urls.first, MediaDetector.isSupportedMedia(url: first) {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
detectedMediaURL = first detectedMediaURL = first
@@ -584,22 +612,7 @@ struct AddDownloadsView: View {
} }
private func addDownloads(start: Bool) { private func addDownloads(start: Bool) {
var explicitCredentials: DownloadCredentials? = nil let explicitCredentials = explicitCredentials(for: pendingDownloads.map(\.url))
if useAuthorization {
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanUsername.isEmpty {
explicitCredentials = DownloadCredentials(username: cleanUsername, password: authPassword)
if saveLogin {
var savedHosts = Set<String>()
for item in pendingDownloads {
if let host = item.url.host, !savedHosts.contains(host) {
settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
savedHosts.insert(host)
}
}
}
}
}
controller.addPendingDownloads( controller.addPendingDownloads(
pendingDownloads, pendingDownloads,
@@ -614,6 +627,25 @@ struct AddDownloadsView: View {
dismiss() 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<String>()
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? { private var overrideDirectory: URL? {
guard overrideDestination else { return nil } guard overrideDestination else { return nil }
let trimmed = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)
+9
View File
@@ -27,6 +27,15 @@ enum BrowserCookieSource: String, Codable, CaseIterable, Sendable {
case firefox = "Firefox" case firefox = "Firefox"
case edge = "Edge" case edge = "Edge"
case brave = "Brave" 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 { enum ProxyType: String, Codable, CaseIterable, Sendable {
+130 -19
View File
@@ -1,42 +1,85 @@
import Foundation import Foundation
import CryptoKit
enum BinaryDownloaderError: Error { enum BinaryDownloaderError: LocalizedError {
case invalidResponse case invalidResponse
case httpError(statusCode: Int) case httpError(statusCode: Int)
case downloadFailed(Error?) case downloadFailed(Error?)
case moveFailed(Error) case moveFailed(Error)
case permissionFailed(Error) case permissionFailed(Error)
case unzipFailed 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 { final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
private let url: URL private let url: URL
private let destination: URL private let destination: URL
private let expectedSHA256: String?
private let onProgress: @Sendable (Double) -> Void private let onProgress: @Sendable (Double) -> Void
private let session: URLSession private let session: URLSession
private let continuation: CheckedContinuation<Void, Error> private let continuation: CheckedContinuation<Void, Error>
init(url: URL, destination: URL, onProgress: @escaping @Sendable (Double) -> Void, continuation: CheckedContinuation<Void, Error>) { init(
url: URL,
destination: URL,
expectedSHA256: String?,
onProgress: @escaping @Sendable (Double) -> Void,
continuation: CheckedContinuation<Void, Error>
) {
self.url = url self.url = url
self.destination = destination self.destination = destination
self.expectedSHA256 = expectedSHA256
self.onProgress = onProgress self.onProgress = onProgress
self.continuation = continuation self.continuation = continuation
let config = URLSessionConfiguration.ephemeral let config = URLSessionConfiguration.ephemeral
self.session = URLSession(configuration: config, delegate: nil, delegateQueue: nil) // Delegate set below self.session = URLSession(configuration: config, delegate: nil, delegateQueue: nil) // Delegate set below
super.init() 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 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 session = URLSession(configuration: .ephemeral, delegate: downloader, delegateQueue: nil)
let task = session.downloadTask(with: url) let task = session.downloadTask(with: url)
task.resume() task.resume()
} }
} }
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
defer { session.finishTasksAndInvalidate() } defer { session.finishTasksAndInvalidate() }
guard let response = downloadTask.response as? HTTPURLResponse else { 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)) continuation.resume(throwing: BinaryDownloaderError.httpError(statusCode: response.statusCode))
return return
} }
do { do {
if FileManager.default.fileExists(atPath: destination.path) { guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
try FileManager.default.removeItem(at: destination) throw BinaryDownloaderError.unsupportedDownloadURL
} }
try FileManager.default.moveItem(at: location, to: destination)
let isZip = url.pathExtension.lowercased() == "zip"
// Make executable let stagingURL = destination
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destination.path) .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() continuation.resume()
} catch { } catch {
continuation.resume(throwing: BinaryDownloaderError.moveFailed(error)) continuation.resume(throwing: BinaryDownloaderError.moveFailed(error))
} }
} }
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
guard totalBytesExpectedToWrite > 0 else { return } guard totalBytesExpectedToWrite > 0 else { return }
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
onProgress(progress) onProgress(progress)
} }
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error { if let error = error {
session.finishTasksAndInvalidate() session.finishTasksAndInvalidate()
continuation.resume(throwing: BinaryDownloaderError.downloadFailed(error)) 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()
}
} }
+47 -1
View File
@@ -110,6 +110,23 @@ final class DownloadController: ObservableObject {
Aria2DownloadEngine.findExecutable() != nil 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) { func add(urlText: String, connectionsPerServer: Int? = nil, queueID: UUID = DownloadQueue.mainQueueID) {
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)), guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = url.scheme?.lowercased(), 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) { func startQueue(queueID: UUID? = nil) {
engineMessage = "" engineMessage = ""
restrictQueueToAutoResume = false restrictQueueToAutoResume = false
@@ -449,7 +487,11 @@ final class DownloadController: ObservableObject {
} }
private func pumpQueue() { 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." engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads."
return return
} }
@@ -459,6 +501,7 @@ final class DownloadController: ObservableObject {
while activeCount < settings.maxConcurrentDownloads, while activeCount < settings.maxConcurrentDownloads,
let next = downloads.first(where: { item in let next = downloads.first(where: { item in
item.status == .queued && item.status == .queued &&
(item.mediaFormatSelector != nil || hasAria2) &&
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) && (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
isAllowedToStart(item) isAllowedToStart(item)
}) { }) {
@@ -490,6 +533,9 @@ final class DownloadController: ObservableObject {
do { do {
let handle = try await mediaEngine.start( let handle = try await mediaEngine.start(
item: item, item: item,
cookieSource: settings.mediaCookieSource,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
progress: { [weak self] progress in progress: { [weak self] progress in
Task { @MainActor in Task { @MainActor in
self?.update(item.id) { self?.update(item.id) {
+14
View File
@@ -4,11 +4,15 @@ struct AddonConfig: Codable, Equatable, Sendable {
let version: String let version: String
let macArm64: URL? let macArm64: URL?
let macX64: URL? let macX64: URL?
let macArm64SHA256: String?
let macX64SHA256: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case version case version
case macArm64 = "mac-arm64" case macArm64 = "mac-arm64"
case macX64 = "mac-x64" case macX64 = "mac-x64"
case macArm64SHA256 = "mac-arm64-sha256"
case macX64SHA256 = "mac-x64-sha256"
} }
/// Returns the appropriate download URL for the current system architecture /// Returns the appropriate download URL for the current system architecture
@@ -21,6 +25,16 @@ struct AddonConfig: Codable, Equatable, Sendable {
return nil return nil
#endif #endif
} }
var currentArchSHA256: String? {
#if arch(arm64)
return macArm64SHA256
#elseif arch(x86_64)
return macX64SHA256
#else
return nil
#endif
}
} }
struct GatekeeperConfig: Codable, Equatable, Sendable { struct GatekeeperConfig: Codable, Equatable, Sendable {
+46 -12
View File
@@ -19,6 +19,9 @@ final class MediaDownloadEngine: @unchecked Sendable {
func start( func start(
item: DownloadItem, item: DownloadItem,
cookieSource: BrowserCookieSource,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void, progress: @escaping @Sendable (DownloadProgress) -> Void,
messageUpdate: @escaping @Sendable (String) -> Void, messageUpdate: @escaping @Sendable (String) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void completion: @escaping @Sendable (Result<Void, Error>) -> Void
@@ -26,10 +29,10 @@ final class MediaDownloadEngine: @unchecked Sendable {
let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp) let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp)
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg) 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.") 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.") throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.")
} }
@@ -49,18 +52,26 @@ final class MediaDownloadEngine: @unchecked Sendable {
arguments.append(format) arguments.append(format)
if item.isAudioOnlyMedia == true { 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 { } else {
arguments.append(contentsOf: ["--merge-output-format", "mp4"]) arguments.append(contentsOf: ["--merge-output-format", "mp4"])
} }
} }
// Add cookies if configured MediaExtractionEngine.appendCommonArguments(
if let storedData = UserDefaults.standard.data(forKey: "Firelink.AppSettings.v1"), to: &arguments,
let json = try? JSONSerialization.jsonObject(with: storedData) as? [String: Any], cookieSource: cookieSource,
let cookieSourceStr = json["mediaCookieSource"] as? String, credentials: item.credentials,
cookieSourceStr != "None" { transferOptions: item.transferOptions
arguments.append(contentsOf: ["--cookies-from-browser", cookieSourceStr.lowercased()]) )
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) arguments.append(item.url.absoluteString)
@@ -72,6 +83,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
process.standardError = errorPipe process.standardError = errorPipe
let parser = YTDLPProgressParser() let parser = YTDLPProgressParser()
let errorBuffer = LockedDataBuffer()
let completionGate = CompletionGate(completion) let completionGate = CompletionGate(completion)
outputPipe.fileHandleForReading.readabilityHandler = { handle in 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 process.terminationHandler = { finishedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil outputPipe.fileHandleForReading.readabilityHandler = nil
@@ -95,13 +121,14 @@ final class MediaDownloadEngine: @unchecked Sendable {
if finishedProcess.terminationStatus == 0 { if finishedProcess.terminationStatus == 0 {
completionGate.complete(.success(())) completionGate.complete(.success(()))
} else { } else {
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
let errorString = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
completionGate.complete(.failure(EngineError.launchFailed(errorString.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : errorString))) completionGate.complete(.failure(EngineError.launchFailed(errorString.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : errorString)))
} }
} }
try process.run() try process.run()
outputPipe.fileHandleForWriting.closeFile()
errorPipe.fileHandleForWriting.closeFile()
return Handle(cancel: { return Handle(cancel: {
if process.isRunning { 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 { final class YTDLPProgressParser: @unchecked Sendable {
private let percentageRegex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)%"#) private let percentageRegex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)%"#)
private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#) private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#)
+50 -33
View File
@@ -11,11 +11,11 @@ enum AddonState: Equatable, Sendable {
enum AddonType: String, CaseIterable, Sendable { enum AddonType: String, CaseIterable, Sendable {
case ytDlp = "yt-dlp" case ytDlp = "yt-dlp"
case ffmpeg case ffmpeg
var defaultsKey: String { var defaultsKey: String {
return "Firelink.AddonVersion.\(self.rawValue)" return "Firelink.AddonVersion.\(self.rawValue)"
} }
var binaryName: String { var binaryName: String {
switch self { switch self {
case .ytDlp: return "yt-dlp" case .ytDlp: return "yt-dlp"
@@ -27,30 +27,30 @@ enum AddonType: String, CaseIterable, Sendable {
@MainActor @MainActor
final class MediaEngineManager: ObservableObject { final class MediaEngineManager: ObservableObject {
static let shared = MediaEngineManager() static let shared = MediaEngineManager()
@Published var ytDlpState: AddonState = .notInstalled @Published var ytDlpState: AddonState = .notInstalled
@Published var ffmpegState: AddonState = .notInstalled @Published var ffmpegState: AddonState = .notInstalled
private let configURL = URL(string: "https://nimbold.github.io/Firelink/firelink-addons.json")! private let configURL = URL(string: "https://nimbold.github.io/Firelink/firelink-addons.json")!
private var addonsDirectory: URL { private var addonsDirectory: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app" let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app"
return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true) return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true)
} }
private init() { private init() {
checkLocalInstallation() checkLocalInstallation()
} }
func binaryPath(for addon: AddonType) -> URL { func binaryPath(for addon: AddonType) -> URL {
return addonsDirectory.appendingPathComponent(addon.binaryName) return addonsDirectory.appendingPathComponent(addon.binaryName)
} }
func checkLocalInstallation() { func checkLocalInstallation() {
for addon in AddonType.allCases { for addon in AddonType.allCases {
let path = binaryPath(for: addon) 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) { if let version = UserDefaults.standard.string(forKey: addon.defaultsKey) {
setState(for: addon, to: .installed(version: version)) setState(for: addon, to: .installed(version: version))
} else { } else {
@@ -61,78 +61,95 @@ final class MediaEngineManager: ObservableObject {
} }
} }
} }
func fetchLatestConfig() async throws -> GatekeeperConfig { func fetchLatestConfig() async throws -> GatekeeperConfig {
var request = URLRequest(url: configURL) var request = URLRequest(url: configURL)
request.cachePolicy = .reloadIgnoringLocalCacheData request.cachePolicy = .reloadIgnoringLocalCacheData
request.timeoutInterval = 30
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse) throw URLError(.badServerResponse)
} }
return try JSONDecoder().decode(GatekeeperConfig.self, from: data) return try JSONDecoder().decode(GatekeeperConfig.self, from: data)
} }
func ensureInstalled() async throws { func ensureInstalled() async throws {
// Simple helper for the "Extract" button flow // Simple helper for the "Extract" button flow
// Fetches config and installs if not installed or out of date // Fetches config and installs if not installed or out of date
let config = try await fetchLatestConfig() let config = try await fetchLatestConfig()
// Use task group to download both if needed // 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 { 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 { } 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 { 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 { } 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)) setState(for: addon, to: .downloading(progress: 0))
let addonConfig: AddonConfig? = { let addonConfig: AddonConfig? = {
switch addon { switch addon {
case .ytDlp: return config.ytDlp case .ytDlp: return config.ytDlp
case .ffmpeg: return config.ffmpeg case .ffmpeg: return config.ffmpeg
} }
}() }()
guard let addonConfig = addonConfig else { guard let addonConfig = addonConfig else {
setState(for: addon, to: .failed(error: "Missing configuration for \(addon.rawValue)")) setState(for: addon, to: .failed(error: "Missing configuration for \(addon.rawValue)"))
return throw URLError(.badURL)
} }
guard let downloadURL = addonConfig.currentArchURL else { guard let downloadURL = addonConfig.currentArchURL else {
setState(for: addon, to: .failed(error: "No download URL for current architecture")) 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 { do {
try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil)
let destination = binaryPath(for: addon) 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 Task { @MainActor in
self.setState(for: addon, to: .downloading(progress: progress)) self.setState(for: addon, to: .downloading(progress: progress))
} }
} }
UserDefaults.standard.set(addonConfig.version, forKey: addon.defaultsKey) UserDefaults.standard.set(addonConfig.version, forKey: addon.defaultsKey)
setState(for: addon, to: .installed(version: addonConfig.version)) setState(for: addon, to: .installed(version: addonConfig.version))
} catch { } catch {
setState(for: addon, to: .failed(error: error.localizedDescription)) setState(for: addon, to: .failed(error: error.localizedDescription))
throw error
} }
} }
private func setState(for addon: AddonType, to state: AddonState) { private func setState(for addon: AddonType, to state: AddonState) {
switch addon { switch addon {
case .ytDlp: ytDlpState = state case .ytDlp: ytDlpState = state
+173 -59
View File
@@ -32,81 +32,82 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable {
let formatSelector: String let formatSelector: String
let isAudioOnly: Bool let isAudioOnly: Bool
let symbol: String let symbol: String
let outputExtension: String
} }
enum MediaExtractionEngine { enum MediaExtractionEngine {
private static let metadataTimeoutSeconds: UInt64 = 75
enum ExtractionError: Error, LocalizedError { enum ExtractionError: Error, LocalizedError {
case processFailed(String) case processFailed(String)
case invalidOutput case invalidOutput
case parsingFailed(Error) case parsingFailed(Error)
case timedOut
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .processFailed(let msg): return "Extraction failed: \(msg)" case .processFailed(let msg): return "Extraction failed: \(msg)"
case .invalidOutput: return "Invalid output from media engine." case .invalidOutput: return "Invalid output from media engine."
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)" 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 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.") throw ExtractionError.processFailed("yt-dlp binary not found.")
} }
let process = Process() var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist"]
process.executableURL = URL(fileURLWithPath: ytDlpPath) appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
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()])
}
args.append(url.absoluteString) args.append(url.absoluteString)
process.arguments = args
let data = try await YTDLPMetadataProcess(
let pipe = Pipe() executableURL: URL(fileURLWithPath: ytDlpPath),
let errorPipe = Pipe() arguments: args
process.standardOutput = pipe ).run(timeoutSeconds: metadataTimeoutSeconds)
process.standardError = errorPipe
guard !data.isEmpty else {
return try await withCheckedThrowingContinuation { continuation in throw ExtractionError.invalidOutput
do { }
try process.run()
} catch { do {
continuation.resume(throwing: ExtractionError.processFailed(error.localizedDescription)) let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data)
return let options = extractOptions(from: metadata)
} return (metadata, options)
} catch {
process.terminationHandler = { p in throw ExtractionError.parsingFailed(error)
let data = pipe.fileHandleForReading.readDataToEndOfFile() }
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() }
if p.terminationStatus != 0 { static func appendCommonArguments(
let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error" to args: inout [String],
continuation.resume(throwing: ExtractionError.processFailed(errorString)) cookieSource: BrowserCookieSource,
return credentials: DownloadCredentials?,
} transferOptions: DownloadTransferOptions
) {
guard !data.isEmpty else { if let browserName = cookieSource.ytDlpBrowserName {
continuation.resume(throwing: ExtractionError.invalidOutput) args.append(contentsOf: ["--cookies-from-browser", browserName])
return }
}
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
do { args.append(contentsOf: ["--add-header", header.headerLine])
let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data) }
let options = extractOptions(from: metadata)
continuation.resume(returning: (metadata, options)) if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines),
} catch { !cookieHeader.isEmpty {
continuation.resume(throwing: ExtractionError.parsingFailed(error)) 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)", name: "Video \(name)",
formatSelector: "bestvideo[height<=\(res)]+bestaudio/best", formatSelector: "bestvideo[height<=\(res)]+bestaudio/best",
isAudioOnly: false, isAudioOnly: false,
symbol: "play.tv.fill" symbol: "play.tv.fill",
outputExtension: "mp4"
)) ))
addedResolutions.insert(res) addedResolutions.insert(res)
} }
@@ -146,7 +148,8 @@ enum MediaExtractionEngine {
name: "Best Video", name: "Best Video",
formatSelector: "bestvideo+bestaudio/best", formatSelector: "bestvideo+bestaudio/best",
isAudioOnly: false, isAudioOnly: false,
symbol: "play.tv.fill" symbol: "play.tv.fill",
outputExtension: "mp4"
)) ))
} else if options.isEmpty { } else if options.isEmpty {
// If we really don't have height info, just offer best // If we really don't have height info, just offer best
@@ -154,7 +157,8 @@ enum MediaExtractionEngine {
name: "Default Video", name: "Default Video",
formatSelector: "best", formatSelector: "best",
isAudioOnly: false, isAudioOnly: false,
symbol: "play.tv.fill" symbol: "play.tv.fill",
outputExtension: "mp4"
)) ))
} }
@@ -163,16 +167,126 @@ enum MediaExtractionEngine {
name: "Audio MP3", 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", // Actual extraction to MP3 needs ffmpeg, which we have. We will handle the conversion flags later in the download engine.
isAudioOnly: true, isAudioOnly: true,
symbol: "music.note" symbol: "music.note",
outputExtension: "mp3"
)) ))
options.append(CleanFormatOption( options.append(CleanFormatOption(
name: "Audio M4A", name: "Audio M4A",
formatSelector: "bestaudio[ext=m4a]/bestaudio/best", formatSelector: "bestaudio[ext=m4a]/bestaudio/best",
isAudioOnly: true, isAudioOnly: true,
symbol: "waveform" symbol: "waveform",
outputExtension: "m4a"
)) ))
return options 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()
}
}
}
}
+67 -29
View File
@@ -2,15 +2,21 @@ import SwiftUI
struct MediaInspectorCard: View { struct MediaInspectorCard: View {
let url: URL let url: URL
let cookieSource: BrowserCookieSource
let credentials: DownloadCredentials?
let transferOptions: DownloadTransferOptions
let onDownload: (CleanFormatOption, MediaMetadata) -> Void let onDownload: (CleanFormatOption, MediaMetadata) -> Void
@ObservedObject private var engineManager = MediaEngineManager.shared
@State private var isLoading = true @State private var isLoading = true
@State private var statusText = "Checking Media Engine..." @State private var statusText = "Checking Media Engine..."
@State private var metadata: MediaMetadata? @State private var metadata: MediaMetadata?
@State private var options: [CleanFormatOption] = [] @State private var options: [CleanFormatOption] = []
@State private var selectedOptionID: String? @State private var selectedOptionID: String?
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var loadTask: Task<Void, Never>?
var body: some View { var body: some View {
ZStack { ZStack {
// Blurred Background // Blurred Background
@@ -26,18 +32,32 @@ struct MediaInspectorCard: View {
Color.clear Color.clear
} }
} }
Rectangle() Rectangle()
.fill(.ultraThinMaterial) .fill(.ultraThinMaterial)
VStack(spacing: 24) { VStack(spacing: 24) {
if isLoading { if isLoading {
VStack(spacing: 16) { VStack(spacing: 16) {
ProgressView() ProgressView()
.controlSize(.large) .controlSize(.large)
Text(statusText)
.font(.headline) let ytState = engineManager.ytDlpState
.foregroundStyle(.secondary) 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) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage { } else if let errorMessage {
@@ -52,7 +72,7 @@ struct MediaInspectorCard: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal) .padding(.horizontal)
Button("Retry") { Button("Retry") {
loadMetadata() loadMetadata()
} }
@@ -73,8 +93,12 @@ struct MediaInspectorCard: View {
.onAppear { .onAppear {
loadMetadata() loadMetadata()
} }
.onDisappear {
loadTask?.cancel()
loadTask = nil
}
} }
@ViewBuilder @ViewBuilder
private func contentView(metadata: MediaMetadata) -> some View { private func contentView(metadata: MediaMetadata) -> some View {
VStack(spacing: 20) { VStack(spacing: 20) {
@@ -94,18 +118,18 @@ struct MediaInspectorCard: View {
.frame(width: 140, height: 90) .frame(width: 140, height: 90)
} }
} }
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(metadata.title ?? "Unknown Title") Text(metadata.title ?? "Unknown Title")
.font(.headline) .font(.headline)
.lineLimit(2) .lineLimit(2)
if let uploader = metadata.displayUploader { if let uploader = metadata.displayUploader {
Text(uploader) Text(uploader)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let duration = metadata.duration { if let duration = metadata.duration {
Text(formatDuration(duration)) Text(formatDuration(duration))
.font(.caption.monospacedDigit()) .font(.caption.monospacedDigit())
@@ -114,14 +138,14 @@ struct MediaInspectorCard: View {
} }
Spacer() Spacer()
} }
Divider() Divider()
// Format Picker // Format Picker
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Select Format") Text("Select Format")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) { HStack(spacing: 12) {
ForEach(options) { option in ForEach(options) { option in
@@ -131,9 +155,9 @@ struct MediaInspectorCard: View {
.padding(.bottom, 4) // For shadow .padding(.bottom, 4) // For shadow
} }
} }
Spacer() Spacer()
// Action // Action
Button { Button {
if let selected = options.first(where: { $0.id == selectedOptionID }) { if let selected = options.first(where: { $0.id == selectedOptionID }) {
@@ -149,11 +173,11 @@ struct MediaInspectorCard: View {
.disabled(selectedOptionID == nil) .disabled(selectedOptionID == nil)
} }
} }
@ViewBuilder @ViewBuilder
private func formatCard(for option: CleanFormatOption) -> some View { private func formatCard(for option: CleanFormatOption) -> some View {
let isSelected = selectedOptionID == option.id let isSelected = selectedOptionID == option.id
Button { Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
selectedOptionID = option.id selectedOptionID = option.id
@@ -163,7 +187,7 @@ struct MediaInspectorCard: View {
Image(systemName: option.symbol) Image(systemName: option.symbol)
.font(.system(size: 24)) .font(.system(size: 24))
.foregroundStyle(isSelected ? .white : .accentColor) .foregroundStyle(isSelected ? .white : .accentColor)
Text(option.name) Text(option.name)
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundStyle(isSelected ? .white : .primary) .foregroundStyle(isSelected ? .white : .primary)
@@ -182,23 +206,36 @@ struct MediaInspectorCard: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.scaleEffect(isSelected ? 1.05 : 1.0) .scaleEffect(isSelected ? 1.05 : 1.0)
} }
private func loadMetadata() { private func loadMetadata() {
loadTask?.cancel()
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
Task { loadTask = Task {
do { do {
statusText = "Checking Media Engine..." await MainActor.run {
statusText = "Checking Media Engine..."
}
try await MediaEngineManager.shared.ensureInstalled() try await MediaEngineManager.shared.ensureInstalled()
guard !Task.isCancelled else { return }
statusText = "Fetching Metadata..."
let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(for: url) 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 { await MainActor.run {
self.metadata = fetchedMetadata self.metadata = fetchedMetadata
self.options = fetchedOptions self.options = fetchedOptions
self.selectedOptionID = fetchedOptions.first?.id self.selectedOptionID = fetchedOptions.first?.id
self.loadTask = nil
withAnimation { withAnimation {
self.isLoading = false self.isLoading = false
} }
@@ -206,6 +243,7 @@ struct MediaInspectorCard: View {
} catch { } catch {
await MainActor.run { await MainActor.run {
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
self.loadTask = nil
withAnimation { withAnimation {
self.isLoading = false self.isLoading = false
} }
@@ -213,7 +251,7 @@ struct MediaInspectorCard: View {
} }
} }
} }
private func formatDuration(_ duration: Double) -> String { private func formatDuration(_ duration: Double) -> String {
let formatter = DateComponentsFormatter() let formatter = DateComponentsFormatter()
formatter.allowedUnits = duration > 3600 ? [.hour, .minute, .second] : [.minute, .second] formatter.allowedUnits = duration > 3600 ? [.hour, .minute, .second] : [.minute, .second]