mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +00:00
fix: harden media download flow
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.3 KiB |
Reference in New Issue
Block a user