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