mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-30 12:29:34 +00:00
fix(media): stabilize yt-dlp metadata and add-on updates
This commit is contained in:
@@ -64,26 +64,36 @@ struct AddDownloadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
item.message = "Added to queue"
|
|
||||||
|
|
||||||
controller.downloads.append(item)
|
controller.addMediaDownload(item, startImmediately: true)
|
||||||
controller.engineMessage = "Added \(fileName) to \(category.rawValue)."
|
|
||||||
controller.startQueue(queueID: DownloadQueue.mainQueueID)
|
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
@@ -91,8 +101,26 @@ struct AddDownloadsView: View {
|
|||||||
} 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,25 +1,57 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -28,9 +60,20 @@ final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
|
|||||||
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()
|
||||||
@@ -49,13 +92,67 @@ final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Make executable
|
let isZip = url.pathExtension.lowercased() == "zip"
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destination.path)
|
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()
|
continuation.resume()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -75,4 +172,18 @@ final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
|
|||||||
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -88,6 +100,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
|
||||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
errorPipe.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,7 +50,7 @@ final class MediaEngineManager: ObservableObject {
|
|||||||
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 {
|
||||||
@@ -65,6 +65,7 @@ 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 {
|
||||||
@@ -80,22 +81,28 @@ final class MediaEngineManager: ObservableObject {
|
|||||||
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? = {
|
||||||
@@ -107,19 +114,28 @@ final class MediaEngineManager: ObservableObject {
|
|||||||
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
@@ -130,6 +146,7 @@ final class MediaEngineManager: ObservableObject {
|
|||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
setState(for: addon, to: .failed(error: error.localizedDescription))
|
setState(for: addon, to: .failed(error: error.localizedDescription))
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
args.append(url.absoluteString)
|
||||||
|
|
||||||
var args = ["-J", "--no-warnings", "--ignore-no-formats-error"]
|
let data = try await YTDLPMetadataProcess(
|
||||||
|
executableURL: URL(fileURLWithPath: ytDlpPath),
|
||||||
|
arguments: args
|
||||||
|
).run(timeoutSeconds: metadataTimeoutSeconds)
|
||||||
|
|
||||||
// Add cookies if configured
|
guard !data.isEmpty else {
|
||||||
if let storedData = UserDefaults.standard.data(forKey: "Firelink.AppSettings.v1"),
|
throw ExtractionError.invalidOutput
|
||||||
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)
|
do {
|
||||||
process.arguments = args
|
let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data)
|
||||||
|
let options = extractOptions(from: metadata)
|
||||||
|
return (metadata, options)
|
||||||
|
} catch {
|
||||||
|
throw ExtractionError.parsingFailed(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let pipe = Pipe()
|
static func appendCommonArguments(
|
||||||
let errorPipe = Pipe()
|
to args: inout [String],
|
||||||
process.standardOutput = pipe
|
cookieSource: BrowserCookieSource,
|
||||||
process.standardError = errorPipe
|
credentials: DownloadCredentials?,
|
||||||
|
transferOptions: DownloadTransferOptions
|
||||||
|
) {
|
||||||
|
if let browserName = cookieSource.ytDlpBrowserName {
|
||||||
|
args.append(contentsOf: ["--cookies-from-browser", browserName])
|
||||||
|
}
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
|
||||||
do {
|
args.append(contentsOf: ["--add-header", header.headerLine])
|
||||||
try process.run()
|
}
|
||||||
} catch {
|
|
||||||
continuation.resume(throwing: ExtractionError.processFailed(error.localizedDescription))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
process.terminationHandler = { p in
|
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
!cookieHeader.isEmpty {
|
||||||
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
args.append(contentsOf: ["--add-header", "Cookie: \(cookieHeader)"])
|
||||||
|
}
|
||||||
|
|
||||||
if p.terminationStatus != 0 {
|
if let credentials, !credentials.isEmpty {
|
||||||
let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error"
|
args.append(contentsOf: ["--username", credentials.username, "--password", credentials.password])
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,14 +2,20 @@ 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 {
|
||||||
@@ -35,9 +41,23 @@ struct MediaInspectorCard: View {
|
|||||||
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 {
|
||||||
@@ -73,6 +93,10 @@ struct MediaInspectorCard: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
loadMetadata()
|
loadMetadata()
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
loadTask?.cancel()
|
||||||
|
loadTask = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -184,21 +208,34 @@ struct MediaInspectorCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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..."
|
await MainActor.run {
|
||||||
let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(for: url)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user