mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-06 01:17:48 +00:00
fix(media): stabilize yt-dlp metadata and add-on updates
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]+)"#)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user