mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +00:00
feat: enhance mixed media support and add duplicate resolution
This commit is contained in:
@@ -99,6 +99,17 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
|||||||
<string>https://raw.githubusercontent.com/nimbold/Firelink/main/appcast.xml</string>
|
<string>https://raw.githubusercontent.com/nimbold/Firelink/main/appcast.xml</string>
|
||||||
<key>SUEnableAutomaticChecks</key>
|
<key>SUEnableAutomaticChecks</key>
|
||||||
<true/>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ struct AddDownloadsView: View {
|
|||||||
@State private var authPassword = ""
|
@State private var authPassword = ""
|
||||||
@State private var saveLogin = false
|
@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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -34,60 +36,33 @@ struct AddDownloadsView: View {
|
|||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
linkSection
|
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
|
optionsSection
|
||||||
advancedTransferSection
|
advancedTransferSection
|
||||||
|
|
||||||
if detectedMediaURL == nil {
|
summarySection
|
||||||
summarySection
|
previewSection
|
||||||
previewSection
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
}
|
}
|
||||||
if detectedMediaURL == nil {
|
Divider()
|
||||||
Divider()
|
actionBar
|
||||||
actionBar
|
.padding(16)
|
||||||
.padding(16)
|
.background(.background)
|
||||||
.background(.background)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
|
.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
|
.onChange(of: linkText) { _, newValue in
|
||||||
scheduleMetadataRefresh(for: newValue)
|
scheduleMetadataRefresh(for: newValue)
|
||||||
}
|
}
|
||||||
@@ -248,8 +223,8 @@ struct AddDownloadsView: View {
|
|||||||
Label("Preview", systemImage: "list.bullet.rectangle")
|
Label("Preview", systemImage: "list.bullet.rectangle")
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
|
|
||||||
Table(pendingDownloads) {
|
Table($pendingDownloads) {
|
||||||
TableColumn("File") { item in
|
TableColumn("File") { $item in
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: item.category.symbolName)
|
Image(systemName: item.category.symbolName)
|
||||||
.foregroundStyle(categoryColor(item.category))
|
.foregroundStyle(categoryColor(item.category))
|
||||||
@@ -260,23 +235,55 @@ struct AddDownloadsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TableColumn("Size") { item in
|
TableColumn("Size") { $item in
|
||||||
Text(ByteFormatter.string(item.sizeBytes))
|
Text(ByteFormatter.string(item.sizeBytes))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
.width(86)
|
.width(86)
|
||||||
|
|
||||||
TableColumn("Save To") { item in
|
TableColumn("Save To") { $item in
|
||||||
Text(destinationDirectory(for: item).path)
|
Text(item.destinationPath)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
TableColumn("Status") { item in
|
TableColumn("Status") { $item in
|
||||||
MetadataStatusView(state: item.state)
|
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)
|
.frame(minHeight: 160)
|
||||||
}
|
}
|
||||||
@@ -493,24 +500,8 @@ struct AddDownloadsView: View {
|
|||||||
let urls = DownloadURLParser.parse(text)
|
let urls = DownloadURLParser.parse(text)
|
||||||
metadataTask?.cancel()
|
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
|
pendingDownloads = urls.map { url in
|
||||||
|
let isMedia = MediaDetector.isSupportedMedia(url: url)
|
||||||
let fileName = FileClassifier.fileName(from: url)
|
let fileName = FileClassifier.fileName(from: url)
|
||||||
let category = FileClassifier.category(forFileName: fileName)
|
let category = FileClassifier.category(forFileName: fileName)
|
||||||
return PendingDownload(
|
return PendingDownload(
|
||||||
@@ -518,7 +509,8 @@ struct AddDownloadsView: View {
|
|||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
category: category,
|
category: category,
|
||||||
defaultDirectory: settings.destinationDirectory(for: category),
|
defaultDirectory: settings.destinationDirectory(for: category),
|
||||||
state: .loading
|
state: .loading,
|
||||||
|
isMedia: isMedia
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,26 +521,53 @@ struct AddDownloadsView: View {
|
|||||||
saveLogin = false
|
saveLogin = false
|
||||||
}
|
}
|
||||||
|
|
||||||
guard mediaURL == nil else {
|
guard !urls.isEmpty else {
|
||||||
pendingDownloads = []
|
|
||||||
metadataTask = nil
|
metadataTask = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataTask = Task {
|
metadataTask = Task {
|
||||||
var loaded: [PendingDownload] = []
|
await withTaskGroup(of: PendingDownload.self) { group in
|
||||||
for url in urls {
|
for item in pendingDownloads {
|
||||||
guard !Task.isCancelled else { return }
|
group.addTask {
|
||||||
let item = await DownloadMetadataFetcher.fetch(
|
if item.isMedia {
|
||||||
for: url,
|
var fetchedItem = item
|
||||||
settings: settings,
|
do {
|
||||||
credentials: metadataCredentials(for: url),
|
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
|
||||||
transferOptions: transferOptions,
|
let (metadata, options) = try await MediaExtractionEngine.fetchMetadata(
|
||||||
isAutoFetch: isAutoFetch
|
for: item.url,
|
||||||
)
|
cookieSource: settings.mediaCookieSource,
|
||||||
loaded.append(item)
|
credentials: metadataCredentials(for: item.url),
|
||||||
await MainActor.run {
|
transferOptions: transferOptions
|
||||||
for loadedItem in loaded {
|
)
|
||||||
|
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 }) {
|
if let index = pendingDownloads.firstIndex(where: { $0.url == loadedItem.url }) {
|
||||||
pendingDownloads[index] = loadedItem
|
pendingDownloads[index] = loadedItem
|
||||||
}
|
}
|
||||||
@@ -579,10 +598,69 @@ struct AddDownloadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func addDownloads(start: Bool) {
|
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(
|
controller.addPendingDownloads(
|
||||||
pendingDownloads,
|
finalDownloads,
|
||||||
connectionsPerServer: Int(connectionsPerServer),
|
connectionsPerServer: Int(connectionsPerServer),
|
||||||
overrideDirectory: overrideDirectory,
|
overrideDirectory: overrideDirectory,
|
||||||
startImmediately: start,
|
startImmediately: start,
|
||||||
|
|||||||
@@ -187,7 +187,9 @@ final class DownloadController: ObservableObject {
|
|||||||
sizeBytes: pending.sizeBytes,
|
sizeBytes: pending.sizeBytes,
|
||||||
bytesText: ByteFormatter.string(pending.sizeBytes),
|
bytesText: ByteFormatter.string(pending.sizeBytes),
|
||||||
message: startImmediately ? "Queued to start" : "Added to queue",
|
message: startImmediately ? "Queued to start" : "Added to queue",
|
||||||
queueID: targetQueueID
|
queueID: targetQueueID,
|
||||||
|
mediaFormatSelector: pending.selectedMediaOption?.formatSelector,
|
||||||
|
isAudioOnlyMedia: pending.selectedMediaOption?.isAudioOnly
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ struct DownloadTable: View {
|
|||||||
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
|
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
|
||||||
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
||||||
rowContextMenu(for: itemIDs)
|
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 {
|
.overlay {
|
||||||
if items.isEmpty {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,9 +102,25 @@ struct FirelinkApp: App {
|
|||||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
controller.pendingPasteboardText = url.absoluteString
|
if url.scheme == "firelink" {
|
||||||
controller.pendingReferer = nil
|
if url.host == "add",
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
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)
|
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let host = request.header(named: "host") ?? ""
|
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 {
|
guard isLocalhost else {
|
||||||
return .forbidden
|
return .forbidden
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct RawMediaFormat: Decodable, Sendable {
|
struct RawMediaFormat: Decodable, Sendable, Equatable {
|
||||||
let format_id: String?
|
let format_id: String?
|
||||||
let ext: String?
|
let ext: String?
|
||||||
let resolution: String?
|
let resolution: String?
|
||||||
@@ -12,7 +12,7 @@ struct RawMediaFormat: Decodable, Sendable {
|
|||||||
let filesize_approx: Int64?
|
let filesize_approx: Int64?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MediaMetadata: Decodable, Sendable {
|
struct MediaMetadata: Decodable, Sendable, Equatable {
|
||||||
let id: String?
|
let id: String?
|
||||||
let title: String?
|
let title: String?
|
||||||
let uploader: String?
|
let uploader: String?
|
||||||
|
|||||||
@@ -269,6 +269,11 @@ struct PendingDownload: Identifiable, Equatable, Sendable {
|
|||||||
var sizeBytes: Int64?
|
var sizeBytes: Int64?
|
||||||
var mimeType: String?
|
var mimeType: String?
|
||||||
var state: MetadataState = .pending
|
var state: MetadataState = .pending
|
||||||
|
|
||||||
|
var isMedia: Bool = false
|
||||||
|
var mediaOptions: [CleanFormatOption] = []
|
||||||
|
var selectedMediaOption: CleanFormatOption?
|
||||||
|
var mediaMetadata: MediaMetadata?
|
||||||
|
|
||||||
var destinationPath: String {
|
var destinationPath: String {
|
||||||
defaultDirectory.appendingPathComponent(fileName).path
|
defaultDirectory.appendingPathComponent(fileName).path
|
||||||
|
|||||||
Reference in New Issue
Block a user