feat: enhance mixed media support and add duplicate resolution

This commit is contained in:
nimbold
2026-06-08 05:37:27 +03:30
parent 34ca209e09
commit baddf0da6d
9 changed files with 302 additions and 96 deletions
+11
View File
@@ -99,6 +99,17 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<string>https://raw.githubusercontent.com/nimbold/Firelink/main/appcast.xml</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>local.firelink.swiftui</string>
<key>CFBundleURLSchemes</key>
<array>
<string>firelink</string>
</array>
</dict>
</array>
</dict>
</plist>
PLIST
+167 -89
View File
@@ -26,7 +26,9 @@ struct AddDownloadsView: View {
@State private var authPassword = ""
@State private var saveLogin = false
@State private var detectedMediaURL: URL? = nil
@State private var conflictingDownloads: [DuplicateDownloadItem] = []
@State private var showingDuplicates = false
@State private var pendingStartFlag = false
var body: some View {
VStack(spacing: 0) {
@@ -34,60 +36,33 @@ struct AddDownloadsView: View {
VStack(alignment: .leading, spacing: 12) {
linkSection
if let mediaURL = detectedMediaURL {
MediaInspectorInlineView(
url: mediaURL,
cookieSource: settings.mediaCookieSource,
credentials: metadataCredentials(for: mediaURL),
transferOptions: transferOptions,
onCancel: { dismiss() }
) { selectedFormat, metadata in
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
let ext = selectedFormat.outputExtension
let fileName = "\(cleanTitle).\(ext)"
let category = FileClassifier.category(forFileName: fileName)
let item = DownloadItem(
url: mediaURL,
fileName: fileName,
category: category,
destinationDirectory: overrideDirectory ?? settings.destinationDirectory(for: category),
connectionsPerServer: Int(connectionsPerServer),
credentials: explicitCredentials(for: [mediaURL]) ?? settings.credentials(for: mediaURL),
checksum: transferOptions.checksum,
requestHeaders: transferOptions.requestHeaders,
cookieHeader: transferOptions.cookieHeader,
mirrorURLs: transferOptions.mirrorURLs,
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil,
message: "Added to queue",
queueID: targetQueueID,
mediaFormatSelector: selectedFormat.formatSelector,
isAudioOnlyMedia: selectedFormat.isAudioOnly
)
controller.addMediaDownload(item, startImmediately: true)
dismiss()
}
.transition(.scale(scale: 0.95).combined(with: .opacity))
}
optionsSection
advancedTransferSection
if detectedMediaURL == nil {
summarySection
previewSection
}
summarySection
previewSection
}
.padding(12)
}
if detectedMediaURL == nil {
Divider()
actionBar
.padding(16)
.background(.background)
}
Divider()
actionBar
.padding(16)
.background(.background)
}
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
.sheet(isPresented: $showingDuplicates) {
DuplicateResolutionView(
conflicts: $conflictingDownloads,
onConfirm: {
showingDuplicates = false
executeAddDownloads(start: pendingStartFlag, conflicts: conflictingDownloads)
},
onCancel: {
showingDuplicates = false
}
)
}
.onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue)
}
@@ -248,8 +223,8 @@ struct AddDownloadsView: View {
Label("Preview", systemImage: "list.bullet.rectangle")
.font(.subheadline.weight(.semibold))
Table(pendingDownloads) {
TableColumn("File") { item in
Table($pendingDownloads) {
TableColumn("File") { $item in
HStack {
Image(systemName: item.category.symbolName)
.foregroundStyle(categoryColor(item.category))
@@ -260,23 +235,55 @@ struct AddDownloadsView: View {
}
}
TableColumn("Size") { item in
TableColumn("Size") { $item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
}
.width(86)
TableColumn("Save To") { item in
Text(destinationDirectory(for: item).path)
TableColumn("Save To") { $item in
Text(item.destinationPath)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
TableColumn("Status") { item in
MetadataStatusView(state: item.state)
TableColumn("Status") { $item in
if item.isMedia {
if !item.mediaOptions.isEmpty {
Menu {
ForEach(item.mediaOptions) { option in
Button {
item.selectedMediaOption = option
if let metadata = item.mediaMetadata {
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
item.fileName = "\(cleanTitle).\(option.outputExtension)"
item.category = FileClassifier.category(forFileName: item.fileName)
}
} label: {
Text(option.name)
}
}
} label: {
Text(item.selectedMediaOption?.name ?? "Select Format")
.lineLimit(1)
}
.menuStyle(.borderlessButton)
.buttonStyle(.plain)
.fixedSize()
} else if case .loading = item.state {
HStack {
ProgressView().controlSize(.small)
Text("Checking")
}.foregroundStyle(.secondary)
} else {
MetadataStatusView(state: item.state)
}
} else {
MetadataStatusView(state: item.state)
}
}
.width(110)
.width(min: 110, ideal: 140, max: 200)
}
.frame(minHeight: 160)
}
@@ -493,24 +500,8 @@ struct AddDownloadsView: View {
let urls = DownloadURLParser.parse(text)
metadataTask?.cancel()
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)) {
detectedMediaURL = mediaURL
}
} else {
withAnimation {
detectedMediaURL = nil
}
}
pendingDownloads = urls.map { url in
let isMedia = MediaDetector.isSupportedMedia(url: url)
let fileName = FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: fileName)
return PendingDownload(
@@ -518,7 +509,8 @@ struct AddDownloadsView: View {
fileName: fileName,
category: category,
defaultDirectory: settings.destinationDirectory(for: category),
state: .loading
state: .loading,
isMedia: isMedia
)
}
@@ -529,26 +521,53 @@ struct AddDownloadsView: View {
saveLogin = false
}
guard mediaURL == nil else {
pendingDownloads = []
guard !urls.isEmpty else {
metadataTask = nil
return
}
metadataTask = Task {
var loaded: [PendingDownload] = []
for url in urls {
guard !Task.isCancelled else { return }
let item = await DownloadMetadataFetcher.fetch(
for: url,
settings: settings,
credentials: metadataCredentials(for: url),
transferOptions: transferOptions,
isAutoFetch: isAutoFetch
)
loaded.append(item)
await MainActor.run {
for loadedItem in loaded {
await withTaskGroup(of: PendingDownload.self) { group in
for item in pendingDownloads {
group.addTask {
if item.isMedia {
var fetchedItem = item
do {
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
let (metadata, options) = try await MediaExtractionEngine.fetchMetadata(
for: item.url,
cookieSource: settings.mediaCookieSource,
credentials: metadataCredentials(for: item.url),
transferOptions: transferOptions
)
fetchedItem.mediaMetadata = metadata
fetchedItem.mediaOptions = options
if let bestVideo = options.first(where: { !$0.isAudioOnly && $0.name.contains("Best") }) ?? options.first(where: { !$0.isAudioOnly }) ?? options.first {
fetchedItem.selectedMediaOption = bestVideo
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
fetchedItem.fileName = "\(cleanTitle).\(bestVideo.outputExtension)"
fetchedItem.category = FileClassifier.category(forFileName: fetchedItem.fileName)
}
fetchedItem.state = .loaded
} catch {
fetchedItem.state = .failed(error.localizedDescription)
}
return fetchedItem
} else {
return await DownloadMetadataFetcher.fetch(
for: item.url,
settings: settings,
credentials: metadataCredentials(for: item.url),
transferOptions: transferOptions,
isAutoFetch: isAutoFetch
)
}
}
}
for await loadedItem in group {
guard !Task.isCancelled else { break }
await MainActor.run {
if let index = pendingDownloads.firstIndex(where: { $0.url == loadedItem.url }) {
pendingDownloads[index] = loadedItem
}
@@ -579,10 +598,69 @@ struct AddDownloadsView: View {
}
private func addDownloads(start: Bool) {
let explicitCredentials = explicitCredentials(for: pendingDownloads.map(\.url))
var conflicts: [DuplicateDownloadItem] = []
for pending in pendingDownloads {
let destURL = overrideDirectory ?? pending.defaultDirectory
let destPath = destURL.appendingPathComponent(pending.fileName).path
if controller.downloads.contains(where: { $0.url == pending.url && $0.status != .canceled && $0.status != .completed }) {
conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingURL("URL already in queue")))
} else if controller.downloads.contains(where: { $0.destinationPath == destPath && $0.status != .canceled }) || FileManager.default.fileExists(atPath: destPath) {
conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingFile("File exists at destination")))
}
}
if !conflicts.isEmpty {
conflictingDownloads = conflicts
pendingStartFlag = start
showingDuplicates = true
return
}
executeAddDownloads(start: start)
}
private func executeAddDownloads(start: Bool, conflicts: [DuplicateDownloadItem]? = nil) {
var finalDownloads = pendingDownloads
if let conflicts {
for conflict in conflicts {
guard let index = finalDownloads.firstIndex(where: { $0.id == conflict.id }) else { continue }
switch conflict.resolution {
case .skip:
finalDownloads.remove(at: index)
case .rename:
let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
var newName = finalDownloads[index].fileName
var count = 1
let base = URL(fileURLWithPath: newName).deletingPathExtension().lastPathComponent
let ext = URL(fileURLWithPath: newName).pathExtension
while controller.downloads.contains(where: { $0.destinationDirectory == destURL && $0.fileName == newName }) || FileManager.default.fileExists(atPath: destURL.appendingPathComponent(newName).path) {
newName = "\(base) (\(count))" + (ext.isEmpty ? "" : ".\(ext)")
count += 1
}
finalDownloads[index].fileName = newName
case .replace:
let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
let path = destURL.appendingPathComponent(finalDownloads[index].fileName).path
if let existingIndex = controller.downloads.firstIndex(where: { ($0.destinationPath == path || $0.url == finalDownloads[index].url) && $0.status != .canceled }) {
controller.delete(controller.downloads[existingIndex], deleteFiles: true)
} else if FileManager.default.fileExists(atPath: path) {
try? FileManager.default.removeItem(atPath: path)
}
}
}
}
guard !finalDownloads.isEmpty else {
dismiss()
return
}
let explicitCredentials = explicitCredentials(for: finalDownloads.map(\.url))
controller.addPendingDownloads(
pendingDownloads,
finalDownloads,
connectionsPerServer: Int(connectionsPerServer),
overrideDirectory: overrideDirectory,
startImmediately: start,
+3 -1
View File
@@ -187,7 +187,9 @@ final class DownloadController: ObservableObject {
sizeBytes: pending.sizeBytes,
bytesText: ByteFormatter.string(pending.sizeBytes),
message: startImmediately ? "Queued to start" : "Added to queue",
queueID: targetQueueID
queueID: targetQueueID,
mediaFormatSelector: pending.selectedMediaOption?.formatSelector,
isAudioOnlyMedia: pending.selectedMediaOption?.isAudioOnly
)
}
+7
View File
@@ -106,6 +106,13 @@ struct DownloadTable: View {
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
} primaryAction: { itemIDs in
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
for target in targetItems {
if target.status == .completed {
openFile(target)
}
}
}
.overlay {
if items.isEmpty {
@@ -0,0 +1,87 @@
import SwiftUI
struct DuplicateDownloadItem: Identifiable, Equatable {
let id: UUID
var pendingItem: PendingDownload
var resolution: DuplicateResolutionAction = .rename
let reason: DuplicateReason
enum DuplicateReason: Equatable {
case existingURL(String)
case existingFile(String)
}
}
enum DuplicateResolutionAction: String, CaseIterable, Identifiable {
case rename = "Rename"
case replace = "Replace"
case skip = "Skip"
var id: String { rawValue }
}
struct DuplicateResolutionView: View {
@Binding var conflicts: [DuplicateDownloadItem]
let onConfirm: () -> Void
let onCancel: () -> Void
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text("Duplicate Downloads Detected")
.font(.headline)
Text("Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
Divider()
List($conflicts) { $conflict in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(conflict.pendingItem.fileName)
.lineLimit(1)
.font(.body.weight(.medium))
Text(reasonText(for: conflict.reason))
.font(.caption)
.foregroundStyle(.orange)
}
Spacer()
Picker("", selection: $conflict.resolution) {
ForEach(DuplicateResolutionAction.allCases) { action in
Text(action.rawValue).tag(action)
}
}
.labelsHidden()
.frame(width: 100)
}
.padding(.vertical, 4)
}
.frame(minHeight: 200)
Divider()
HStack {
Button("Cancel", action: onCancel)
.keyboardShortcut(.cancelAction)
Spacer()
Button("Continue", action: onConfirm)
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
.padding(16)
}
.frame(width: 500)
}
private func reasonText(for reason: DuplicateDownloadItem.DuplicateReason) -> String {
switch reason {
case .existingURL(let msg): return msg
case .existingFile(let msg): return msg
}
}
}
+19 -3
View File
@@ -102,9 +102,25 @@ struct FirelinkApp: App {
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.onOpenURL { url in
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
if url.scheme == "firelink" {
if url.host == "add",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems,
let link = queryItems.first(where: { $0.name == "url" })?.value {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
controller.pendingPasteboardText = link
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
}
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
}
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
+1 -1
View File
@@ -125,7 +125,7 @@ final class LocalExtensionServer: @unchecked Sendable {
}
let host = request.header(named: "host") ?? ""
let isLocalhost = host.hasPrefix("127.0.0.1:") || host.hasPrefix("localhost:") || host == "127.0.0.1" || host == "localhost"
let isLocalhost = host == "127.0.0.1:\(self.port)" || host == "localhost:\(self.port)" || host == "127.0.0.1" || host == "localhost"
guard isLocalhost else {
return .forbidden
}
+2 -2
View File
@@ -1,6 +1,6 @@
import Foundation
struct RawMediaFormat: Decodable, Sendable {
struct RawMediaFormat: Decodable, Sendable, Equatable {
let format_id: String?
let ext: String?
let resolution: String?
@@ -12,7 +12,7 @@ struct RawMediaFormat: Decodable, Sendable {
let filesize_approx: Int64?
}
struct MediaMetadata: Decodable, Sendable {
struct MediaMetadata: Decodable, Sendable, Equatable {
let id: String?
let title: String?
let uploader: String?
+5
View File
@@ -269,6 +269,11 @@ struct PendingDownload: Identifiable, Equatable, Sendable {
var sizeBytes: Int64?
var mimeType: String?
var state: MetadataState = .pending
var isMedia: Bool = false
var mediaOptions: [CleanFormatOption] = []
var selectedMediaOption: CleanFormatOption?
var mediaMetadata: MediaMetadata?
var destinationPath: String {
defaultDirectory.appendingPathComponent(fileName).path