fix: harden media download flow

This commit is contained in:
nimbold
2026-06-08 01:06:00 +03:30
parent 2e5b4ae7c3
commit 4e48f1e42c
36 changed files with 576 additions and 530 deletions
+4 -2
View File
@@ -4,8 +4,10 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="Firelink" APP_NAME="Firelink"
CONFIGURATION="${CONFIGURATION:-release}" CONFIGURATION="${CONFIGURATION:-release}"
MARKETING_VERSION="${MARKETING_VERSION:-0.1.0}" DEFAULT_MARKETING_VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)"
BUILD_NUMBER="${BUILD_NUMBER:-1}" DEFAULT_BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || true)"
MARKETING_VERSION="${MARKETING_VERSION:-${DEFAULT_MARKETING_VERSION:-0.1.0}}"
BUILD_NUMBER="${BUILD_NUMBER:-${DEFAULT_BUILD_NUMBER:-1}}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app" APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents" CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS" MACOS_DIR="$CONTENTS_DIR/MacOS"
-23
View File
@@ -1,23 +0,0 @@
import re
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
content = f.read()
# Remove doubleClickableCell block
content = re.sub(r' private func doubleClickableCell.*? }\n\n', '', content, flags=re.DOTALL)
# Remove doubleClickableCell wrappers from TableColumn
content = re.sub(r'doubleClickableCell\(for: item\) \{\n\s*(.*?)\n\s*\}', r'\1', content, flags=re.DOTALL)
# Add simultaneousGesture to Table
table_end = content.find(' .environment(\\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)')
gesture = ''' .simultaneousGesture(TapGesture(count: 2).onEnded {
if let id = selection.first, let item = controller.downloads.first(where: { $0.id == id }) {
performPrimaryAction(for: item)
}
})
'''
content = content[:table_end] + gesture + content[table_end:]
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(content)
-62
View File
@@ -1,62 +0,0 @@
import re
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
content = f.read()
# Fix size to use bytesText if sizeBytes is nil or 0
old_size = ''' TableColumn("Size", value: \\.sortableSize) { item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}'''
new_size = ''' TableColumn("Size", value: \\.sortableSize) { item in
if let size = item.sizeBytes, size > 0 {
Text(ByteFormatter.string(size))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else if item.bytesText != "-" && !item.bytesText.isEmpty {
Text(item.bytesText)
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else {
Text("Unknown")
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}
}'''
content = content.replace(old_size, new_size)
# Add allowsHitTesting(false) to text to fix single click interception
old_text = ''' Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)'''
new_text = ''' Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)'''
content = content.replace(old_text, new_text)
# Change performPrimaryAction to use id
old_action = 'openWindow(value: item.id)'
new_action = 'openWindow(id: "download-properties", value: item.id)'
content = content.replace(old_action, new_action)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(content)
with open("Sources/Firelink/FirelinkApp.swift", "r") as f:
app_content = f.read()
app_content = app_content.replace(
'WindowGroup("Download Properties", for: UUID.self)',
'WindowGroup("Download Properties", id: "download-properties", for: UUID.self)'
)
with open("Sources/Firelink/FirelinkApp.swift", "w") as f:
f.write(app_content)
-86
View File
@@ -1,86 +0,0 @@
with open("Sources/Firelink/FirelinkApp.swift", "r") as f:
app_content = f.read()
app_content = app_content.replace(
'WindowGroup("Download Properties", id: "download-properties", for: String.self) { $downloadIDString in\n if let idString = downloadIDString, let downloadID = UUID(uuidString: idString) {',
'WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in\n if let downloadID {'
)
with open("Sources/Firelink/FirelinkApp.swift", "w") as f:
f.write(app_content)
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
table_content = f.read()
# Fix openWindow in performPrimaryAction
table_content = table_content.replace(
'openWindow(id: "download-properties", value: item.id.uuidString)',
'openWindow(id: "download-properties", value: item.id)'
)
# Fix openWindow in rowContextMenu
table_content = table_content.replace(
'openWindow(value: target.id)',
'openWindow(id: "download-properties", value: target.id)'
)
# Remove simultaneousGesture from Table
import re
table_content = re.sub(
r'\s*\.simultaneousGesture\(TapGesture\(count: 2\)\.onEnded \{\s*if let id = selection\.first, let item = controller\.downloads\.first\(where: \{ \$0\.id == id \}\) \{\s*performPrimaryAction\(for: item\)\s*\}\s*\}\)',
'',
table_content
)
# Replace the File Name TableColumn to use doubleClickableCell
old_column = ''' TableColumn("File Name", value: \\.fileName) { item in
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
.draggable(item.id.uuidString)
}
}'''
new_column = ''' TableColumn("File Name", value: \\.fileName) { item in
doubleClickableCell(for: item) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}
}'''
table_content = table_content.replace(old_column, new_column)
# Add doubleClickableCell helper
helper = ''' private func performPrimaryAction(for item: DownloadItem) {'''
helper_new = ''' private func doubleClickableCell<Content: View>(for item: DownloadItem, @ViewBuilder content: () -> Content) -> some View {
content()
.contentShape(Rectangle())
.simultaneousGesture(TapGesture(count: 2).onEnded {
performPrimaryAction(for: item)
})
}
private func performPrimaryAction(for item: DownloadItem) {'''
table_content = table_content.replace(helper, helper_new)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(table_content)
-21
View File
@@ -1,21 +0,0 @@
with open("Sources/Firelink/FirelinkApp.swift", "r") as f:
app_content = f.read()
old_group = ''' WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in
if let downloadID {'''
new_group = ''' WindowGroup("Download Properties", id: "download-properties", for: String.self) { $downloadIDString in
if let idString = downloadIDString, let downloadID = UUID(uuidString: idString) {'''
app_content = app_content.replace(old_group, new_group)
with open("Sources/Firelink/FirelinkApp.swift", "w") as f:
f.write(app_content)
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
table_content = f.read()
old_open1 = 'openWindow(id: "download-properties", value: item.id)'
new_open1 = 'openWindow(id: "download-properties", value: item.id.uuidString)'
table_content = table_content.replace(old_open1, new_open1)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(table_content)
-45
View File
@@ -1,45 +0,0 @@
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
content = f.read()
old_column = ''' TableColumn("File Name", value: \\.fileName) { item in
doubleClickableCell(for: item) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}
}'''
new_column = ''' TableColumn("File Name", value: \\.fileName) { item in
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}'''
content = content.replace(old_column, new_column)
import re
# Remove doubleClickableCell function block
pattern = r'\s*private func doubleClickableCell<Content: View>\(for item: DownloadItem, @ViewBuilder content: \(\) -> Content\) -> some View \{\s*content\(\)\s*\.contentShape\(Rectangle\(\)\)\s*\.simultaneousGesture\(TapGesture\(count: 2\)\.onEnded \{\s*performPrimaryAction\(for: item\)\s*\}\)\s*\}'
content = re.sub(pattern, '', content)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(content)
+1
View File
@@ -5,4 +5,5 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
swift build swift build
git diff --check
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
+15 -2
View File
@@ -493,9 +493,16 @@ struct AddDownloadsView: View {
let urls = DownloadURLParser.parse(text) let urls = DownloadURLParser.parse(text)
metadataTask?.cancel() metadataTask?.cancel()
if let first = urls.first, MediaDetector.isSupportedMedia(url: first) { let mediaURL: URL? = {
guard urls.count == 1, let first = urls.first, MediaDetector.isSupportedMedia(url: first) else {
return nil
}
return first
}()
if let mediaURL {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
detectedMediaURL = first detectedMediaURL = mediaURL
} }
} else { } else {
withAnimation { withAnimation {
@@ -522,6 +529,12 @@ struct AddDownloadsView: View {
saveLogin = false saveLogin = false
} }
guard mediaURL == nil else {
pendingDownloads = []
metadataTask = nil
return
}
metadataTask = Task { metadataTask = Task {
var loaded: [PendingDownload] = [] var loaded: [PendingDownload] = []
for url in urls { for url in urls {
+5 -5
View File
@@ -230,7 +230,7 @@ final class Aria2DownloadEngine {
rpcPort: Int, rpcPort: Int,
rpcSecret: String, rpcSecret: String,
process: Process, process: Process,
completionGate: CompletionGate completionGate: CompletionGate<Void>
) -> Task<Void, Never> { ) -> Task<Void, Never> {
Task.detached { Task.detached {
while !Task.isCancelled && process.isRunning { while !Task.isCancelled && process.isRunning {
@@ -527,16 +527,16 @@ final class LockedDataBuffer: @unchecked Sendable {
} }
} }
final class CompletionGate: @unchecked Sendable { final class CompletionGate<Success>: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var didComplete = false private var didComplete = false
private let completion: @Sendable (Result<Void, Error>) -> Void private let completion: @Sendable (Result<Success, Error>) -> Void
init(_ completion: @escaping @Sendable (Result<Void, Error>) -> Void) { init(_ completion: @escaping @Sendable (Result<Success, Error>) -> Void) {
self.completion = completion self.completion = completion
} }
func complete(_ result: Result<Void, Error>) { func complete(_ result: Result<Success, Error>) {
lock.lock() lock.lock()
let shouldComplete = !didComplete let shouldComplete = !didComplete
if shouldComplete { if shouldComplete {
+3
View File
@@ -9,6 +9,7 @@ enum BinaryDownloaderError: LocalizedError {
case permissionFailed(Error) case permissionFailed(Error)
case unzipFailed case unzipFailed
case unsupportedDownloadURL case unsupportedDownloadURL
case missingChecksum
case checksumMismatch case checksumMismatch
var errorDescription: String? { var errorDescription: String? {
@@ -27,6 +28,8 @@ enum BinaryDownloaderError: LocalizedError {
"Could not extract the downloaded add-on archive." "Could not extract the downloaded add-on archive."
case .unsupportedDownloadURL: case .unsupportedDownloadURL:
"The add-on URL must be HTTP or HTTPS." "The add-on URL must be HTTP or HTTPS."
case .missingChecksum:
"The add-on configuration is missing a SHA-256 checksum."
case .checksumMismatch: case .checksumMismatch:
"The downloaded add-on did not match the expected SHA-256 checksum." "The downloaded add-on did not match the expected SHA-256 checksum."
} }
+16
View File
@@ -154,6 +154,14 @@ struct ContentView: View {
} }
.keyboardShortcut("a", modifiers: .command) .keyboardShortcut("a", modifiers: .command)
.opacity(0) .opacity(0)
Button("") {
if let item = selectedItems.first {
performPrimaryAction(for: item)
}
}
.keyboardShortcut(.return, modifiers: [])
.opacity(0)
} }
.confirmationDialog( .confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")", "Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
@@ -194,6 +202,14 @@ struct ContentView: View {
selection = Set(items.map { $0.id }) selection = Set(items.map { $0.id })
} }
private func performPrimaryAction(for item: DownloadItem) {
if item.status == .completed {
NSWorkspace.shared.open(URL(fileURLWithPath: item.destinationPath))
} else {
openWindow(id: "download-properties", value: item.id)
}
}
private func hasActiveDownloads(in queueID: UUID?) -> Bool { private func hasActiveDownloads(in queueID: UUID?) -> Bool {
if let queueID { if let queueID {
return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID } return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID }
+116 -16
View File
@@ -535,15 +535,17 @@ final class DownloadController: ObservableObject {
$0.message = "Checking media add-ons..." $0.message = "Checking media add-ons..."
} }
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg]) try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
guard let liveItem = activeDownloadItem(id: item.id) else { return }
update(item.id) { update(item.id) {
guard $0.status == .downloading else { return } guard $0.status == .downloading else { return }
$0.message = "Starting yt-dlp..." $0.message = "Starting yt-dlp..."
} }
let handle = try await mediaEngine.start( let handle = try await mediaEngine.start(
item: item, item: liveItem,
cookieSource: settings.mediaCookieSource, cookieSource: settings.mediaCookieSource,
proxyConfiguration: settings.downloadProxyConfiguration, proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item), speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
progress: { [weak self] progress in progress: { [weak self] progress in
Task { @MainActor in Task { @MainActor in
self?.update(item.id) { self?.update(item.id) {
@@ -569,12 +571,20 @@ final class DownloadController: ObservableObject {
}, },
completion: { [weak self] result in completion: { [weak self] result in
Task { @MainActor in Task { @MainActor in
self?.handleCompletion(item: item, result: result, isMedia: true) self?.handleMediaCompletion(item: item, result: result)
} }
} }
) )
guard activeDownloadItem(id: item.id) != nil else {
handle.cancel()
return
}
activeMediaHandles[item.id] = handle activeMediaHandles[item.id] = handle
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
} catch { } catch {
guard shouldHandleStartFailure(for: item.id) else { return }
handleDownloadFailure(itemID: item.id, error: error) handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads() applySpeedLimitsToActiveDownloads()
updateSleepActivity() updateSleepActivity()
@@ -602,7 +612,7 @@ final class DownloadController: ObservableObject {
}, },
completion: { [weak self] result in completion: { [weak self] result in
Task { @MainActor in Task { @MainActor in
self?.handleCompletion(item: item, result: result, isMedia: false) self?.handleCompletion(item: item, result: result)
} }
} }
) )
@@ -624,12 +634,20 @@ final class DownloadController: ObservableObject {
} }
} }
private func handleCompletion(item: DownloadItem, result: Result<Void, Error>, isMedia: Bool) { private func activeDownloadItem(id: UUID) -> DownloadItem? {
if isMedia { downloads.first { $0.id == id && $0.status == .downloading }
activeMediaHandles[item.id] = nil }
} else {
activeHandles[item.id] = nil private func shouldHandleStartFailure(for id: UUID) -> Bool {
guard let item = downloads.first(where: { $0.id == id }) else {
automaticRetryCounts[id] = nil
return false
} }
return item.status != .paused && item.status != .canceled
}
private func handleCompletion(item: DownloadItem, result: Result<Void, Error>) {
activeHandles[item.id] = nil
switch result { switch result {
case .success: case .success:
@@ -663,6 +681,45 @@ final class DownloadController: ObservableObject {
self.updateSleepActivity() self.updateSleepActivity()
} }
private func handleMediaCompletion(item: DownloadItem, result: Result<URL, Error>) {
activeMediaHandles[item.id] = nil
switch result {
case .success(let outputURL):
self.automaticRetryCounts[item.id] = nil
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.destinationDirectory = outputURL.deletingLastPathComponent()
$0.fileName = outputURL.lastPathComponent
$0.category = FileClassifier.category(forFileName: $0.fileName)
$0.message = "Saved to \(outputURL.path)"
$0.autoResumeOnLaunch = false
if let attr = try? FileManager.default.attributesOfItem(atPath: outputURL.path),
let size = attr[.size] as? Int64 {
$0.sizeBytes = size
$0.bytesText = ByteFormatter.string(size)
}
}
self.saveDownloads()
self.showNotification(title: "Download Completed", body: outputURL.lastPathComponent)
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
}
self.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
self.applySpeedLimitsToActiveDownloads()
self.updateSleepActivity()
}
private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) { private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) {
guard let index = downloads.firstIndex(where: { $0.id == id }) else { return } guard let index = downloads.firstIndex(where: { $0.id == id }) else { return }
mutate(&downloads[index]) mutate(&downloads[index])
@@ -747,6 +804,14 @@ final class DownloadController: ObservableObject {
} }
private func handleDownloadFailure(itemID: UUID, error: Error) { private func handleDownloadFailure(itemID: UUID, error: Error) {
guard let currentItem = downloads.first(where: { $0.id == itemID }) else {
automaticRetryCounts[itemID] = nil
return
}
guard currentItem.status != .paused, currentItem.status != .canceled else {
return
}
let retryCount = automaticRetryCounts[itemID] ?? 0 let retryCount = automaticRetryCounts[itemID] ?? 0
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else { guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
@@ -779,16 +844,51 @@ final class DownloadController: ObservableObject {
} }
private func isAutomaticallyRecoverable(_ error: Error) -> Bool { private func isAutomaticallyRecoverable(_ error: Error) -> Bool {
guard let engineError = error as? Aria2DownloadEngine.EngineError else { if let engineError = error as? Aria2DownloadEngine.EngineError {
return true switch engineError {
case .executableNotFound, .unsupportedProxy:
return false
case .launchFailed:
return true
}
} }
switch engineError { if let mediaError = error as? MediaDownloadEngine.EngineError {
case .executableNotFound, .unsupportedProxy: switch mediaError {
return false case .missingEngine:
case .launchFailed: return false
return true case .launchFailed(let message):
return isRecoverableMediaFailure(message)
}
} }
return true
}
private func isRecoverableMediaFailure(_ message: String) -> Bool {
let lowercased = message.lowercased()
let permanentMarkers = [
"requires browser cookies",
"choose a browser",
"challenge solving failed",
"install deno or node",
"requested format is not available",
"unsupported url",
"private video",
"sign in",
"not a bot",
"video unavailable",
"no video formats found",
"no audio formats found",
"ffmpeg is not installed",
"yt-dlp is not installed"
]
if permanentMarkers.contains(where: { lowercased.contains($0) }) {
return false
}
return true
} }
private func isAllowedToStart(_ item: DownloadItem) -> Bool { private func isAllowedToStart(_ item: DownloadItem) -> Bool {
-8
View File
@@ -156,14 +156,6 @@ struct DownloadTable: View {
} }
} }
private func performPrimaryAction(for item: DownloadItem) {
if item.status == .completed {
openFile(item)
} else {
openWindow(id: "download-properties", value: item.id)
}
}
@ViewBuilder @ViewBuilder
private func statusCell(for item: DownloadItem) -> some View { private func statusCell(for item: DownloadItem) -> some View {
let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines) let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines)
+90 -2
View File
@@ -24,7 +24,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
speedLimitKiBPerSecond: Int?, 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<URL, Error>) -> Void
) async throws -> Handle { ) async throws -> Handle {
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)
@@ -88,9 +88,11 @@ final class MediaDownloadEngine: @unchecked Sendable {
let parser = YTDLPProgressParser() let parser = YTDLPProgressParser()
let errorBuffer = LockedDataBuffer() let errorBuffer = LockedDataBuffer()
let outputPathTracker = YTDLPOutputPathTracker()
let completionGate = CompletionGate(completion) let completionGate = CompletionGate(completion)
let outputHandler = YTDLPOutputHandler( let outputHandler = YTDLPOutputHandler(
parser: parser, parser: parser,
outputPathTracker: outputPathTracker,
progress: progress, progress: progress,
messageUpdate: messageUpdate messageUpdate: messageUpdate
) )
@@ -115,7 +117,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
errorPipe.fileHandleForReading.readabilityHandler = nil errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 { if finishedProcess.terminationStatus == 0 {
completionGate.complete(.success(())) completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
} else { } else {
let errorString = String(data: errorBuffer.data, 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(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus)))) completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
@@ -134,6 +136,34 @@ final class MediaDownloadEngine: @unchecked Sendable {
}) })
} }
private static func resolvedOutputURL(for item: DownloadItem, tracker: YTDLPOutputPathTracker) -> URL {
let expectedURL = URL(fileURLWithPath: item.destinationPath)
if FileManager.default.fileExists(atPath: expectedURL.path) {
return expectedURL
}
if let observedURL = tracker.lastExistingOutputURL {
return observedURL
}
let baseName = expectedURL.deletingPathExtension().lastPathComponent
guard let contents = try? FileManager.default.contentsOfDirectory(
at: item.destinationDirectory,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return expectedURL
}
return contents
.filter { $0.deletingPathExtension().lastPathComponent == baseName }
.max { lhs, rhs in
let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return lhsDate < rhsDate
} ?? expectedURL
}
private static func cleanErrorMessage(_ message: String, status: Int32) -> String { private static func cleanErrorMessage(_ message: String, status: Int32) -> String {
guard !message.isEmpty else { guard !message.isEmpty else {
return "Exit code \(status)" return "Exit code \(status)"
@@ -164,15 +194,18 @@ final class MediaDownloadEngine: @unchecked Sendable {
final class YTDLPOutputHandler: @unchecked Sendable { final class YTDLPOutputHandler: @unchecked Sendable {
private let parser: YTDLPProgressParser private let parser: YTDLPProgressParser
private let outputPathTracker: YTDLPOutputPathTracker
private let progress: @Sendable (DownloadProgress) -> Void private let progress: @Sendable (DownloadProgress) -> Void
private let messageUpdate: @Sendable (String) -> Void private let messageUpdate: @Sendable (String) -> Void
init( init(
parser: YTDLPProgressParser, parser: YTDLPProgressParser,
outputPathTracker: YTDLPOutputPathTracker,
progress: @escaping @Sendable (DownloadProgress) -> Void, progress: @escaping @Sendable (DownloadProgress) -> Void,
messageUpdate: @escaping @Sendable (String) -> Void messageUpdate: @escaping @Sendable (String) -> Void
) { ) {
self.parser = parser self.parser = parser
self.outputPathTracker = outputPathTracker
self.progress = progress self.progress = progress
self.messageUpdate = messageUpdate self.messageUpdate = messageUpdate
} }
@@ -180,6 +213,7 @@ final class YTDLPOutputHandler: @unchecked Sendable {
func handle(_ text: String) { func handle(_ text: String) {
for line in text.split(whereSeparator: \.isNewline) { for line in text.split(whereSeparator: \.isNewline) {
let stringLine = String(line) let stringLine = String(line)
outputPathTracker.observe(stringLine)
if let update = parser.parse(stringLine) { if let update = parser.parse(stringLine) {
progress(update) progress(update)
messageUpdate("Downloading Media") messageUpdate("Downloading Media")
@@ -215,6 +249,60 @@ final class YTDLPOutputHandler: @unchecked Sendable {
} }
} }
final class YTDLPOutputPathTracker: @unchecked Sendable {
private let lock = NSLock()
private var observedPaths: [String] = []
private let quotedPathRegex = try? NSRegularExpression(pattern: #""([^"]+)""#)
var lastExistingOutputURL: URL? {
lock.withLock {
observedPaths
.reversed()
.map { URL(fileURLWithPath: $0) }
.first { FileManager.default.fileExists(atPath: $0.path) }
}
}
func observe(_ line: String) {
let candidates = pathCandidates(from: line)
guard !candidates.isEmpty else { return }
lock.withLock {
for candidate in candidates where !observedPaths.contains(candidate) {
observedPaths.append(candidate)
}
}
}
private func pathCandidates(from line: String) -> [String] {
var paths: [String] = []
if line.contains("Destination:"),
let destination = line.components(separatedBy: "Destination:").last?.trimmingCharacters(in: .whitespacesAndNewlines),
destination.hasPrefix("/") {
paths.append(destination.trimmingCharacters(in: CharacterSet(charactersIn: "\"")))
}
for quoted in quotedCaptures(in: line) where quoted.hasPrefix("/") {
paths.append(quoted)
}
return paths
}
private func quotedCaptures(in text: String) -> [String] {
guard let quotedPathRegex else { return [] }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return quotedPathRegex.matches(in: text, range: range).compactMap { match in
guard match.numberOfRanges > 1,
let captureRange = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[captureRange])
}
}
}
private extension String { private extension String {
func fileExtension(defaultValue: String) -> String { func fileExtension(defaultValue: String) -> String {
let ext = (self as NSString).pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let ext = (self as NSString).pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+28 -6
View File
@@ -38,6 +38,7 @@ final class MediaEngineManager: ObservableObject {
let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app" let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app"
return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true) return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true)
} }
private var installTasks: [AddonType: Task<Void, Error>] = [:]
private init() { private init() {
checkLocalInstallation() checkLocalInstallation()
@@ -49,6 +50,7 @@ final class MediaEngineManager: ObservableObject {
func checkLocalInstallation() { func checkLocalInstallation() {
for addon in AddonType.allCases { for addon in AddonType.allCases {
guard installTasks[addon] == nil else { continue }
let path = binaryPath(for: addon) let path = binaryPath(for: addon)
if FileManager.default.isExecutableFile(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) {
@@ -79,9 +81,10 @@ final class MediaEngineManager: ObservableObject {
let config = try await fetchLatestConfig() let config = try await fetchLatestConfig()
try await withThrowingTaskGroup(of: Void.self) { group in try await withThrowingTaskGroup(of: Void.self) { group in
for addon in requiredAddons where shouldInstall(addon: addon, config: config) { for addon in requiredAddons where shouldInstall(addon: addon, config: config) || installTasks[addon] != nil {
let task = installationTask(for: addon, from: config)
group.addTask { group.addTask {
try await self.install(addon: addon, from: config) try await task.value
} }
} }
@@ -93,9 +96,9 @@ final class MediaEngineManager: ObservableObject {
checkLocalInstallation() checkLocalInstallation()
let missingAddons = requiredAddons.filter { addon in let missingAddons = requiredAddons.filter { addon in
switch state(for: addon) { switch state(for: addon) {
case .installed, .downloading: case .installed:
return false return false
case .notInstalled, .failed: case .downloading, .notInstalled, .failed:
return true return true
} }
} }
@@ -120,13 +123,26 @@ final class MediaEngineManager: ObservableObject {
case .notInstalled, .failed: case .notInstalled, .failed:
return true return true
case .downloading: case .downloading:
return false return true
case .installed(let version): case .installed(let version):
guard let configVersion else { return false } guard let configVersion else { return false }
return version != configVersion return version != configVersion
} }
} }
private func installationTask(for addon: AddonType, from config: GatekeeperConfig) -> Task<Void, Error> {
if let task = installTasks[addon] {
return task
}
let task = Task { @MainActor in
defer { self.installTasks[addon] = nil }
try await self.install(addon: addon, from: config)
}
installTasks[addon] = task
return task
}
private func state(for addon: AddonType) -> AddonState { private func state(for addon: AddonType) -> AddonState {
switch addon { switch addon {
case .ytDlp: return ytDlpState case .ytDlp: return ytDlpState
@@ -159,6 +175,12 @@ final class MediaEngineManager: ObservableObject {
throw URLError(.badURL) throw URLError(.badURL)
} }
guard let expectedSHA256 = addonConfig.currentArchSHA256?.trimmingCharacters(in: .whitespacesAndNewlines),
!expectedSHA256.isEmpty else {
setState(for: addon, to: .failed(error: "Missing SHA-256 checksum for add-on"))
throw BinaryDownloaderError.missingChecksum
}
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)
@@ -166,7 +188,7 @@ final class MediaEngineManager: ObservableObject {
try await BinaryDownloader.download( try await BinaryDownloader.download(
from: downloadURL, from: downloadURL,
to: destination, to: destination,
expectedSHA256: addonConfig.currentArchSHA256 expectedSHA256: expectedSHA256
) { progress in ) { progress in
Task { @MainActor in Task { @MainActor in
self.setState(for: addon, to: .downloading(progress: progress)) self.setState(for: addon, to: .downloading(progress: progress))
+121 -29
View File
@@ -34,6 +34,7 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable {
let symbol: String let symbol: String
let outputExtension: String let outputExtension: String
let detail: String let detail: String
let estimatedBytes: Int64?
} }
enum MediaExtractionEngine { enum MediaExtractionEngine {
@@ -151,9 +152,6 @@ enum MediaExtractionEngine {
var options: [CleanFormatOption] = [] var options: [CleanFormatOption] = []
let rawFormats = metadata.formats ?? [] let rawFormats = metadata.formats ?? []
let heights = rawFormats.compactMap { $0.height }.filter { $0 > 0 }
let maxHeight = heights.max() ?? 0
let standardResolutions = [ let standardResolutions = [
(2160, "4K"), (2160, "4K"),
(1440, "1440p"), (1440, "1440p"),
@@ -164,7 +162,9 @@ enum MediaExtractionEngine {
] ]
let availableResolutions = standardResolutions.filter { resolution, _ in let availableResolutions = standardResolutions.filter { resolution, _ in
maxHeight == 0 || maxHeight >= resolution - 100 rawFormats.contains { format in
isVideo(format) && (format.height ?? 0) > 0 && (format.height ?? 0) <= resolution && (format.height ?? 0) >= resolution - 100
}
} }
let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) } let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) }
let videoContainers = [ let videoContainers = [
@@ -175,47 +175,139 @@ enum MediaExtractionEngine {
for (height, qualityName) in videoQualities { for (height, qualityName) in videoQualities {
for (container, containerName) in videoContainers { for (container, containerName) in videoContainers {
guard hasVideoFormat(rawFormats, height: height, container: container) else { continue }
let estimatedBytes = estimatedVideoBytes(rawFormats, height: height, container: container)
options.append(CleanFormatOption( options.append(CleanFormatOption(
name: "\(qualityName) \(containerName)", name: "\(qualityName) \(containerName)",
formatSelector: videoSelector(height: height, container: container), formatSelector: videoSelector(height: height, container: container),
isAudioOnly: false, isAudioOnly: false,
symbol: "play.tv.fill", symbol: "play.tv.fill",
outputExtension: container, outputExtension: container,
detail: height == nil ? "Best available video" : "Up to \(qualityName)" detail: optionDetail(
base: height == nil ? "Best available video" : "Up to \(qualityName)",
estimatedBytes: estimatedBytes
),
estimatedBytes: estimatedBytes
)) ))
} }
} }
options.append(CleanFormatOption( if hasAudioFormat(rawFormats, preferredExtension: nil) {
name: "Audio MP3", let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: nil)
formatSelector: "bestaudio/best", options.append(CleanFormatOption(
isAudioOnly: true, name: "Audio MP3",
symbol: "music.note", formatSelector: "bestaudio/best",
outputExtension: "mp3", isAudioOnly: true,
detail: "Converted with ffmpeg" symbol: "music.note",
)) outputExtension: "mp3",
detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
))
}
options.append(CleanFormatOption( if hasAudioFormat(rawFormats, preferredExtension: "m4a") {
name: "Audio M4A", let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "m4a")
formatSelector: "bestaudio[ext=m4a]/bestaudio/best", options.append(CleanFormatOption(
isAudioOnly: true, name: "Audio M4A",
symbol: "waveform", formatSelector: "bestaudio[ext=m4a]/bestaudio/best",
outputExtension: "m4a", isAudioOnly: true,
detail: "Prefer native M4A" symbol: "waveform",
)) outputExtension: "m4a",
detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
))
}
options.append(CleanFormatOption( if hasAudioFormat(rawFormats, preferredExtension: "webm") {
name: "Audio Opus", let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "webm")
formatSelector: "bestaudio[ext=webm]/bestaudio/best", options.append(CleanFormatOption(
isAudioOnly: true, name: "Audio Opus",
symbol: "waveform", formatSelector: "bestaudio[ext=webm]/bestaudio/best",
outputExtension: "opus", isAudioOnly: true,
detail: "Efficient audio" symbol: "waveform",
)) outputExtension: "opus",
detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
))
}
return options return options
} }
private static func hasVideoFormat(_ formats: [RawMediaFormat], height: Int?, container: String) -> Bool {
formats.contains { format in
guard isVideo(format), matchesHeight(format, height: height) else { return false }
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
}
}
private static func hasAudioFormat(_ formats: [RawMediaFormat], preferredExtension: String?) -> Bool {
formats.contains { format in
guard isAudio(format) else { return false }
guard let preferredExtension else { return true }
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
}
}
private static func estimatedVideoBytes(_ formats: [RawMediaFormat], height: Int?, container: String) -> Int64? {
let videoBytes = formats
.filter { format in
guard isVideo(format), matchesHeight(format, height: height) else { return false }
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
}
.compactMap { formatSize($0) }
.max()
guard let videoBytes else { return nil }
let audioBytes = estimatedAudioBytes(formats, preferredExtension: container == "webm" ? "webm" : "m4a") ??
estimatedAudioBytes(formats, preferredExtension: nil) ??
0
return videoBytes + audioBytes
}
private static func estimatedAudioBytes(_ formats: [RawMediaFormat], preferredExtension: String?) -> Int64? {
let preferred = formats
.filter { format in
guard isAudio(format) else { return false }
guard let preferredExtension else { return true }
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
}
.compactMap { formatSize($0) }
.max()
if preferred != nil || preferredExtension == nil {
return preferred
}
return estimatedAudioBytes(formats, preferredExtension: nil)
}
private static func isVideo(_ format: RawMediaFormat) -> Bool {
guard let vcodec = format.vcodec?.lowercased(), vcodec != "none" else { return false }
return true
}
private static func isAudio(_ format: RawMediaFormat) -> Bool {
let acodec = format.acodec?.lowercased()
let vcodec = format.vcodec?.lowercased()
return acodec != nil && acodec != "none" && (vcodec == nil || vcodec == "none")
}
private static func matchesHeight(_ format: RawMediaFormat, height: Int?) -> Bool {
guard let height else { return true }
guard let formatHeight = format.height else { return false }
return formatHeight <= height && formatHeight >= height - 100
}
private static func formatSize(_ format: RawMediaFormat) -> Int64? {
format.filesize ?? format.filesize_approx
}
private static func optionDetail(base: String, estimatedBytes: Int64?) -> String {
guard let estimatedBytes, estimatedBytes > 0 else { return base }
return "\(base) - ~\(ByteFormatter.string(estimatedBytes))"
}
private static func videoSelector(height: Int?, container: String) -> String { private static func videoSelector(height: Int?, container: String) -> String {
let filter = heightFilter(height) let filter = heightFilter(height)
switch container { switch container {
@@ -117,6 +117,13 @@ struct MediaInspectorInlineView: View {
.frame(width: 90) .frame(width: 90)
} }
} }
if let selected = resolveSelectedOption() {
Text(selected.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
} }
Spacer(minLength: 16) Spacer(minLength: 16)
@@ -147,6 +154,10 @@ struct MediaInspectorInlineView: View {
loadTask?.cancel() loadTask?.cancel()
loadTask = nil loadTask = nil
} }
.onChange(of: url) { _, _ in loadMetadata() }
.onChange(of: cookieSource) { _, _ in loadMetadata() }
.onChange(of: credentials) { _, _ in loadMetadata() }
.onChange(of: transferOptions) { _, _ in loadMetadata() }
.onChange(of: selectedType) { _, _ in ensureValidSelection() } .onChange(of: selectedType) { _, _ in ensureValidSelection() }
.onChange(of: options) { _, _ in ensureValidSelection() } .onChange(of: options) { _, _ in ensureValidSelection() }
} }
@@ -216,6 +227,8 @@ struct MediaInspectorInlineView: View {
loadTask?.cancel() loadTask?.cancel()
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
metadata = nil
options = []
loadTask = Task { loadTask = Task {
do { do {
@@ -233,9 +246,13 @@ struct MediaInspectorInlineView: View {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
self.metadata = fetchedMetadata if fetchedOptions.isEmpty {
self.options = fetchedOptions self.errorMessage = "No downloadable media formats were found."
self.ensureValidSelection() } else {
self.metadata = fetchedMetadata
self.options = fetchedOptions
self.ensureValidSelection()
}
self.loadTask = nil self.loadTask = nil
withAnimation { self.isLoading = false } withAnimation { self.isLoading = false }
} }
-63
View File
@@ -1,63 +0,0 @@
import sys
from PIL import Image, ImageDraw
def generate_gradient_preview(src_path, dest_path):
img = Image.open(src_path).convert("RGBA")
width, height = img.size
pixels = img.load()
# Background color from an inner point
bg_color = pixels[100, 100]
# Define gradient colors
# Top color: slightly lighter/richer blue (e.g. #1E2541)
# Bottom color: darker navy (e.g. #0A0D1A)
top_color = (30, 37, 65, 255)
bottom_color = (10, 13, 26, 255)
# Create new image
new_img = Image.new("RGBA", (width, height))
new_pixels = new_img.load()
for y in range(height):
# Interpolate background color for this row
ratio = y / float(height - 1)
grad_r = int(top_color[0] * (1 - ratio) + bottom_color[0] * ratio)
grad_g = int(top_color[1] * (1 - ratio) + bottom_color[1] * ratio)
grad_b = int(top_color[2] * (1 - ratio) + bottom_color[2] * ratio)
grad_color = (grad_r, grad_g, grad_b, 255)
for x in range(width):
p = pixels[x, y]
# distance from original background color
dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2]))
if dist < 15:
# purely background
new_pixels[x, y] = grad_color
elif dist < 60:
# anti-aliased edge, blend
alpha = (dist - 15) / 45.0
r = int(p[0] * alpha + grad_color[0] * (1 - alpha))
g = int(p[1] * alpha + grad_color[1] * (1 - alpha))
b = int(p[2] * alpha + grad_color[2] * (1 - alpha))
new_pixels[x, y] = (r, g, b, 255)
else:
# purely logo
new_pixels[x, y] = p
# Apply rounded rectangle mask
radius = int(width * 0.225)
mask = Image.new("L", (width, height), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
new_img.putalpha(mask)
# Resize to something reasonable for preview
preview = new_img.resize((512, 512), Image.Resampling.LANCZOS)
preview.save(dest_path)
print("Preview saved to", dest_path)
if __name__ == '__main__':
generate_gradient_preview(sys.argv[1], sys.argv[2])
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB