removed unwanted bar now with waveforms, major updates
This commit is contained in:
@@ -666,9 +666,6 @@ struct StudioSettingsDialog: View {
|
||||
Divider()
|
||||
HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Changes apply immediately" : (scope == "DJ" ? "DJ Studio" : "Karaoke Studio")+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
|
||||
}.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor))
|
||||
.sheet(isPresented:Binding(get:{model.dj.storageProgress != nil},set:{if !$0,!model.dj.movingStorage { model.dj.storageProgress = nil }})) {
|
||||
if let progress = model.dj.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Storage",cancel:{model.dj.cancelStorageMove()},close:{model.dj.storageProgress = nil},isMove:true).interactiveDismissDisabled(model.dj.movingStorage) }
|
||||
}
|
||||
.onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } }
|
||||
.onChange(of:category) { _ in model.dj.midi.learning = nil }
|
||||
.onDisappear { model.dj.midi.learning = nil }
|
||||
|
||||
@@ -172,6 +172,16 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
@MainActor final class DJSystem: ObservableObject {
|
||||
@Published var copyImportedMedia = false { didSet { perform { try library?.setSetting("DJ.CopyImportedMedia.v1",copyImportedMedia ? "1" : "0") } } }
|
||||
private var referenceAccess = [String:URL]()
|
||||
private var referenceLocations = [MediaLocation]()
|
||||
private func ensureReferenceAccess(_ path: String) {
|
||||
for location in referenceLocations where path == location.path || path.hasPrefix(location.path+"/") {
|
||||
guard referenceAccess[location.path] == nil,let bookmark = location.bookmark else { continue }
|
||||
var stale = false
|
||||
if let url = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale),url.startAccessingSecurityScopedResource() { referenceAccess[location.path] = url }
|
||||
}
|
||||
}
|
||||
@Published private(set) var mediaDirectory: URL
|
||||
@Published private(set) var waveformDirectory: URL
|
||||
@Published private(set) var movingStorage = false
|
||||
@@ -199,12 +209,12 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
func moveStorage(to destination: URL) {
|
||||
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy,!a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback and finish the current library job before moving DJ files.";return }
|
||||
let loadedA = a.song?.id,loadedB = b.song?.id,database = root.appendingPathComponent("Library.sqlite"),localRoot = root
|
||||
a.unload();b.unload();stopPreview();movingStorage = true;storageProgress = .init()
|
||||
a.unload();b.unload();stopPreview();movingStorage = true;storageProgress = .init(current:"Preparing DJ storage move…")
|
||||
storageTask = Task.detached(priority:.utility) { [weak self] in
|
||||
let access = destination.startAccessingSecurityScopedResource();defer { if access { destination.stopAccessingSecurityScopedResource() } }
|
||||
var latest = ImportProgress()
|
||||
do { latest = try DJStorageMove.run(database:database,defaultRoot:localRoot,destination:destination,cancelled:{Task.isCancelled}) { value in
|
||||
latest = value;Task { @MainActor [weak self] in self?.storageProgress = value }
|
||||
latest = value;Task { @MainActor [weak self] in guard let self,self.movingStorage,self.storageTask?.isCancelled != true else { return };self.storageProgress = value }
|
||||
} } catch is CancellationError { latest.cancelled = true;latest.current = "Move cancelled; verified files kept" }
|
||||
catch { latest.failure = error.localizedDescription;latest.current = "Move needs attention" }
|
||||
latest.finished = true;let result = latest
|
||||
@@ -216,7 +226,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
}
|
||||
}
|
||||
}
|
||||
func cancelStorageMove() { storageTask?.cancel() }
|
||||
func cancelStorageMove() { storageTask?.cancel();storageProgress?.current = "Cancelling… Originals and verified copies will be kept." }
|
||||
@Published var namesBusy = false
|
||||
private var graphicSaveTask: Task<Void,Never>?
|
||||
@Published var graphicEQ = DJGraphicEQSettings() { didSet {
|
||||
@@ -241,10 +251,10 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
guard !generatingWaveforms,!importing,!movingStorage else { error = "Wait for the current import or waveform job to finish first.";return }
|
||||
let tracks = songs.filter { ids == nil || ids!.contains($0.id) }
|
||||
guard !tracks.isEmpty else { error = "Choose a DJ library track first.";return }
|
||||
let cache = waveformDirectory
|
||||
let cache = waveformDirectory,locations = referenceLocations
|
||||
generatingWaveforms = true;waveformJob = .init(total:tracks.count)
|
||||
waveformTask = Task.detached(priority:.utility) { [weak self] in
|
||||
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:[],cache:cache,force:force) { update in
|
||||
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:locations,cache:cache,force:force) { update in
|
||||
Task { @MainActor [weak self] in self?.waveformJob = update }
|
||||
}
|
||||
await MainActor.run { [weak self] in
|
||||
@@ -312,7 +322,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
}.store(in:&syncObservers)
|
||||
perform {
|
||||
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
|
||||
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try loadStorage();try refresh()
|
||||
library = try Library(url:root.appendingPathComponent("Library.sqlite"));copyImportedMedia = try library?.setting("DJ.CopyImportedMedia.v1") == "1";try loadStorage();try refresh()
|
||||
if let data = try? Data(contentsOf:root.appendingPathComponent("DJGraphicEQ.json")),let saved = try? JSONDecoder().decode(DJGraphicEQSettings.self,from:data) { graphicEQ = saved.sanitized }
|
||||
applyGraphicEQ()
|
||||
favourites = Set((try library?.setting("DJ.Favourites.v1") ?? "").split(separator:",").compactMap{Int64($0)})
|
||||
@@ -323,7 +333,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
audioDeviceMonitor = DJAudioDeviceMonitor { [weak self] in self?.refreshDevices() }
|
||||
}
|
||||
private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } }
|
||||
func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [] }
|
||||
func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [];referenceLocations = try library?.mediaLocations() ?? [] }
|
||||
func toggleFavourite(_ song: Int64) {
|
||||
var updated = favourites
|
||||
if !updated.insert(song).inserted { updated.remove(song) }
|
||||
@@ -340,7 +350,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
|
||||
guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return }
|
||||
importing = true;importProgress = .init();importLogVisible = true
|
||||
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,cache = waveformDirectory,replacedSong = loadInto.flatMap{channel($0).song?.id}
|
||||
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,cache = waveformDirectory,copyMedia = copyImportedMedia,replacedSong = loadInto.flatMap{channel($0).song?.id}
|
||||
importTask = Task.detached(priority:.utility) { [weak self] in
|
||||
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
|
||||
defer { for url in access { url.stopAccessingSecurityScopedResource() } }
|
||||
@@ -348,7 +358,14 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
var importedIDs = [Int64]()
|
||||
do {
|
||||
let db = try Library(url:database)
|
||||
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,waveformCache:cache).run(urls,library:db,cancelled:{Task.isCancelled}) { value in
|
||||
if !copyMedia {
|
||||
for url in urls {
|
||||
if Task.isCancelled { throw CancellationError() }
|
||||
let bookmark = (try? url.bookmarkData(options:.withSecurityScope,includingResourceValuesForKeys:nil,relativeTo:nil)) ?? (try? url.bookmarkData(options:.minimalBookmark,includingResourceValuesForKeys:nil,relativeTo:nil))
|
||||
_ = try db.saveMediaLocation(name:url.lastPathComponent,url:url,bookmark:bookmark,external:true)
|
||||
}
|
||||
}
|
||||
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,waveformCache:cache).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in
|
||||
latest = value
|
||||
Task { @MainActor [weak self] in if self?.importing == true { self?.importProgress = value } }
|
||||
}
|
||||
@@ -372,7 +389,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
requestFileLoad(next.urls,into:next.deck,override:next.override && channel(next.deck).song?.id == next.song)
|
||||
}
|
||||
func cancelReplacement() { pendingReplacement = nil;continueFileLoads() }
|
||||
deinit { importTask?.cancel();waveformTask?.cancel();storageTask?.cancel();storageAccess?.stopAccessingSecurityScopedResource() }
|
||||
deinit { importTask?.cancel();waveformTask?.cancel();storageTask?.cancel();storageAccess?.stopAccessingSecurityScopedResource();for url in referenceAccess.values { url.stopAccessingSecurityScopedResource() } }
|
||||
|
||||
func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } }
|
||||
func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 }
|
||||
@@ -389,6 +406,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) {
|
||||
guard !movingStorage else { error = "Wait for the DJ storage move to finish.";return }
|
||||
perform {
|
||||
ensureReferenceAccess(song.audio)
|
||||
let deck = channel(letter)
|
||||
if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return }
|
||||
if !automated { automix.manualTakeover() }
|
||||
@@ -441,6 +459,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
try config.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput())
|
||||
}
|
||||
func previewLibrary(_ song: Song) {
|
||||
ensureReferenceAccess(song.audio)
|
||||
perform {
|
||||
guard !cueUID.isEmpty else { throw LibraryError.message("Choose a headphone output in DJ Devices first.") }
|
||||
let output = try device(cueUID)
|
||||
@@ -1617,7 +1636,11 @@ struct DJPreferencesPane: View {
|
||||
}.padding(26).frame(maxWidth:.infinity,alignment:.leading)
|
||||
}
|
||||
}
|
||||
}.alert("DJ settings",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") }
|
||||
}
|
||||
.sheet(isPresented:Binding(get:{system.storageProgress != nil},set:{if !$0,!system.movingStorage { system.storageProgress = nil }})) {
|
||||
if let progress = system.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Storage",cancel:{system.cancelStorageMove()},close:{system.storageProgress = nil},isMove:true).interactiveDismissDisabled(system.movingStorage) }
|
||||
}
|
||||
.alert("DJ settings",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") }
|
||||
}
|
||||
private var general: some View {
|
||||
VStack(alignment:.leading,spacing:26) {
|
||||
@@ -1656,6 +1679,12 @@ struct DJPreferencesPane: View {
|
||||
}
|
||||
private var library: some View {
|
||||
VStack(alignment:.leading,spacing:26) {
|
||||
PreferencesSection("Import Mode") {
|
||||
VStack(alignment:.leading,spacing:12) {
|
||||
Picker("Media files",selection:$system.copyImportedMedia) { Text("Reference files in their original location").tag(false);Text("Copy files into DJ storage").tag(true) }.pickerStyle(.radioGroup)
|
||||
Text("Referencing avoids duplicate media. Keep the source drive connected. Saved waveforms stay in your selected waveform folder. Audio inside ZIP archives is extracted into DJ storage.").font(.caption).foregroundStyle(.secondary)
|
||||
}.padding(12)
|
||||
}
|
||||
PreferencesSection("Media and Waveform Storage") {
|
||||
VStack(alignment:.leading,spacing:14) {
|
||||
LabeledContent("Media") { Text(system.mediaDirectory.path).textSelection(.enabled) }
|
||||
|
||||
+47
-17
@@ -27,7 +27,7 @@ struct Importer {
|
||||
var includeGraphics = true
|
||||
var waveformCache: URL? = nil
|
||||
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] {
|
||||
try ManagedMediaStorage.prepare(media)
|
||||
if copyMedia { try ManagedMediaStorage.prepare(media) }
|
||||
var state = ImportProgress()
|
||||
var lastReport = Date.distantPast
|
||||
func report(_ current: String,force: Bool = false) {
|
||||
@@ -213,6 +213,7 @@ struct Importer {
|
||||
if includeGraphics { cdg = try companions(of:audio)["cdg"] } else { cdg = nil }
|
||||
let archive = archiveForFile[audio.resolvingSymlinksInPath().path]
|
||||
let copy = copyMedia || archive != nil
|
||||
if copy { try ManagedMediaStorage.prepare(media) }
|
||||
let signature = try importSignature(audio,cdg:cdg)
|
||||
if let existing = try library.importedSongs(path:audio.standardizedFileURL.path,signature:signature) {
|
||||
songIDs.formUnion(existing)
|
||||
@@ -360,13 +361,20 @@ enum ManagedMediaStorage {
|
||||
static func save(_ location:MediaStorageLocation,to library:Library) throws {
|
||||
try library.setSetting(MediaStorageLocation.key,String(decoding:try JSONEncoder().encode(location),as:UTF8.self))
|
||||
}
|
||||
static func hash(_ url:URL,cancelled:()->Bool) throws -> SHA256.Digest {
|
||||
static func hash(_ url:URL,cancelled:()->Bool,bytesRead:(Int64)->Void = {_ in}) throws -> SHA256.Digest {
|
||||
let file = try FileHandle(forReadingFrom:url);defer { try? file.close() }
|
||||
var result = SHA256()
|
||||
while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };result.update(data:data) }
|
||||
var result = SHA256(),total: Int64 = 0
|
||||
while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };result.update(data:data);total += Int64(data.count);bytesRead(total) }
|
||||
return result.finalize()
|
||||
}
|
||||
static func move(database:URL,roots:[URL],destination:URL,cancelled:()->Bool = {false},progress:(ImportProgress)->Void = {_ in}) throws -> ImportProgress {
|
||||
var state = ImportProgress(current:"Checking media locations…"),lastReport = Date.distantPast
|
||||
func scan(_ text: String) throws {
|
||||
if cancelled() { throw CancellationError() }
|
||||
if Date().timeIntervalSince(lastReport) >= 0.1 { state.current = text;progress(state);lastReport = Date() }
|
||||
if cancelled() { throw CancellationError() }
|
||||
}
|
||||
try scan("Checking media locations…")
|
||||
try prepare(destination)
|
||||
let targetRoot = destination.resolvingSymlinksInPath()
|
||||
let sources = Set(roots.map{$0.resolvingSymlinksInPath()}).filter{$0 != targetRoot}
|
||||
@@ -374,6 +382,7 @@ enum ManagedMediaStorage {
|
||||
let db = try Library(url:database),songs = try db.songs()
|
||||
var files = Set<URL>()
|
||||
for song in songs {
|
||||
try scan("Finding media: "+song.title)
|
||||
for path in [song.audio,song.cdg].compactMap({$0}) {
|
||||
let file = URL(fileURLWithPath:path).resolvingSymlinksInPath()
|
||||
if sources.contains(file.deletingLastPathComponent()) { files.insert(file) }
|
||||
@@ -381,7 +390,9 @@ enum ManagedMediaStorage {
|
||||
}
|
||||
// Include managed hash-named remnants of previous cancelled imports.
|
||||
for root in sources where FileManager.default.fileExists(atPath:root.path) {
|
||||
try scan("Checking folder: "+root.path)
|
||||
for file in try FileManager.default.contentsOfDirectory(at:root,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey]) {
|
||||
try scan("Checking media: "+file.lastPathComponent)
|
||||
let stem = file.deletingPathExtension().lastPathComponent
|
||||
if [64,129].contains(stem.count),stem.allSatisfy({$0.isHexDigit || $0 == "-"}),["mp3","wav","m4a","mp4","m4v","mov","cdg"].contains(file.pathExtension.lowercased()) {
|
||||
let values = try file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey])
|
||||
@@ -389,7 +400,8 @@ enum ManagedMediaStorage {
|
||||
}
|
||||
}
|
||||
}
|
||||
var state = ImportProgress(total:files.count),verified = [(URL,URL)](),lastReport = Date.distantPast
|
||||
state.total = files.count
|
||||
var verified = [(URL,URL)]()
|
||||
defer { progress(state) }
|
||||
var sourceStamps = [URL:String]()
|
||||
func stamp(_ url:URL) throws -> String { let info = try FileManager.default.attributesOfItem(atPath:url.path);return "\(info[.size] ?? 0)|\(info[.modificationDate] ?? "")|\(info[.systemFileNumber] ?? 0)" }
|
||||
@@ -400,16 +412,16 @@ enum ManagedMediaStorage {
|
||||
if Date().timeIntervalSince(lastReport) >= 0.1 { progress(state);lastReport = Date() }
|
||||
let target = targetRoot.appendingPathComponent(source.lastPathComponent)
|
||||
sourceStamps[source] = try stamp(source)
|
||||
let expected = try hash(source,cancelled:cancelled)
|
||||
let expected = try hash(source,cancelled:cancelled,bytesRead:{ bytes in
|
||||
if Date().timeIntervalSince(lastReport) >= 0.1 { state.current = "Verifying "+source.lastPathComponent+" · "+ByteCountFormatter.string(fromByteCount:bytes,countStyle:.file);progress(state);lastReport = Date() }
|
||||
})
|
||||
if FileManager.default.fileExists(atPath:target.path) {
|
||||
guard try hash(target,cancelled:cancelled) == expected else { throw LibraryError.message("A different file already exists at \(target.path). Choose an empty folder or resolve the conflict first.") }
|
||||
state.skipped += 1
|
||||
} else {
|
||||
let temporary = targetRoot.appendingPathComponent(".kjmix-moving-"+UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:temporary) }
|
||||
try FileManager.default.copyItem(at:source,to:temporary)
|
||||
guard try hash(temporary,cancelled:cancelled) == expected else { throw LibraryError.message("Verification failed for \(source.lastPathComponent). The original has been kept.") }
|
||||
try FileManager.default.moveItem(at:temporary,to:target)
|
||||
try MediaByteCopy.copy(source,to:target,expectedHash:expected.map{String(format:"%02x",$0)}.joined(),cancelled:cancelled,bytesCopied:{ bytes in
|
||||
if Date().timeIntervalSince(lastReport) >= 0.1 { state.current = "Copying "+source.lastPathComponent+" · "+ByteCountFormatter.string(fromByteCount:bytes,countStyle:.file);progress(state);lastReport = Date() }
|
||||
})
|
||||
}
|
||||
verified.append((source,target));state.completed += 1;state.logLines.append("[COPIED] "+source.path)
|
||||
if Date().timeIntervalSince(lastReport) >= 0.1 || state.completed == 1 || state.completed == state.total { progress(state);lastReport = Date() }
|
||||
@@ -463,7 +475,7 @@ enum MediaByteCopy {
|
||||
}
|
||||
return hash.finalize().map{String(format:"%02x",$0)}.joined()
|
||||
}
|
||||
static func copy(_ source: URL,to destination: URL,expectedHash: String,cancelled: () -> Bool = { false }) throws {
|
||||
static func copy(_ source: URL,to destination: URL,expectedHash: String,cancelled: () -> Bool = { false },bytesCopied: (Int64) -> Void = {_ in}) throws {
|
||||
if cancelled() { throw CancellationError() }
|
||||
if nonempty(destination),try digest(destination,cancelled:cancelled) == expectedHash { return }
|
||||
let temporary = destination.deletingLastPathComponent().appendingPathComponent(".kjmix-import-"+UUID().uuidString)
|
||||
@@ -476,8 +488,9 @@ enum MediaByteCopy {
|
||||
while true {
|
||||
if cancelled() { throw CancellationError() }
|
||||
guard let data = try input.read(upToCount:1_048_576),!data.isEmpty else { break }
|
||||
try output.write(contentsOf:data);bytesWritten += data.count
|
||||
try output.write(contentsOf:data);bytesWritten += data.count;bytesCopied(Int64(bytesWritten))
|
||||
}
|
||||
if cancelled() { throw CancellationError() }
|
||||
try output.synchronize();try output.close()
|
||||
guard bytesWritten > 0,try digest(temporary,cancelled:cancelled) == expectedHash else { throw LibraryError.message("Audio data was empty or changed during copying. Retry the import.") }
|
||||
if cancelled() { throw CancellationError() }
|
||||
@@ -497,27 +510,44 @@ enum DJStorageMove {
|
||||
return try JSONDecoder().decode(MediaStorageLocation.self,from:Data(text.utf8))
|
||||
}
|
||||
static func run(database: URL,defaultRoot: URL,destination: URL,cancelled: () -> Bool = {false},progress: (ImportProgress) -> Void = {_ in}) throws -> ImportProgress {
|
||||
var discovery = ImportProgress(current:"Checking DJ storage…"),lastDiscovery = Date.distantPast
|
||||
func reportDiscovery(_ text: String,force: Bool = false) throws {
|
||||
if cancelled() { throw CancellationError() }
|
||||
if force || Date().timeIntervalSince(lastDiscovery) >= 0.1 { discovery.current = text;progress(discovery);lastDiscovery = Date() }
|
||||
if cancelled() { throw CancellationError() }
|
||||
}
|
||||
try reportDiscovery("Checking DJ storage…",force:true)
|
||||
let db = try Library(url:database),old = try location(in:db)
|
||||
let oldRoots = Array(Set((old?.roots ?? [])+[defaultRoot.path,old?.path ?? defaultRoot.path])).map{URL(fileURLWithPath:$0)}
|
||||
let target = destination.resolvingSymlinksInPath(),media = target.appendingPathComponent("Media"),waves = target.appendingPathComponent("Waveforms")
|
||||
try ManagedMediaStorage.prepare(media);try ManagedMediaStorage.prepare(waves)
|
||||
let oldSongs = try db.songs()
|
||||
var cacheSources = [Int64:URL](),allCaches = Set<URL>()
|
||||
for folder in oldRoots.map({$0.appendingPathComponent("Waveforms")}) {
|
||||
var cacheFolders = Set<URL>()
|
||||
for folder in Set(oldRoots.map({$0.appendingPathComponent("Waveforms").resolvingSymlinksInPath()})) {
|
||||
try reportDiscovery("Scanning saved waveforms: "+folder.path)
|
||||
if let files = try? FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey]) {
|
||||
for file in files where file.pathExtension == "waveform" {
|
||||
try reportDiscovery("Checking saved waveforms: \(allCaches.count.formatted()) found")
|
||||
let attributes = try file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey])
|
||||
if attributes.isRegularFile == true,attributes.isSymbolicLink != true { allCaches.insert(file.resolvingSymlinksInPath()) }
|
||||
if attributes.isRegularFile == true,attributes.isSymbolicLink != true { allCaches.insert(file.resolvingSymlinksInPath());cacheFolders.insert(folder) }
|
||||
}
|
||||
}
|
||||
for song in oldSongs where cacheSources[song.id] == nil {
|
||||
if let file = try? WaveformCache.location(audio:URL(fileURLWithPath:song.audio),directory:folder),FileManager.default.fileExists(atPath:file.path) { cacheSources[song.id] = file.resolvingSymlinksInPath() }
|
||||
}
|
||||
if !cacheFolders.isEmpty {
|
||||
let filesByName = Dictionary(grouping:allCaches,by: \.lastPathComponent)
|
||||
for (index,song) in oldSongs.enumerated() {
|
||||
try reportDiscovery("Matching waveform \(index+1) of \(oldSongs.count): "+song.title)
|
||||
// The key filename does not depend on its cache directory.
|
||||
if let key = try? WaveformCache.location(audio:URL(fileURLWithPath:song.audio),directory:waves).lastPathComponent,
|
||||
let file = filesByName[key]?.first { cacheSources[song.id] = file }
|
||||
}
|
||||
}
|
||||
let manifestKey = "dj.storage.waveform.sources.v1"
|
||||
if let json = try db.setting(manifestKey),let saved = try? JSONDecoder().decode([String:String].self,from:Data(json.utf8)) {
|
||||
let folders = Set(oldRoots.map{$0.appendingPathComponent("Waveforms").resolvingSymlinksInPath()})
|
||||
for song in oldSongs where cacheSources[song.id] == nil {
|
||||
try reportDiscovery("Checking interrupted transfers: "+song.title)
|
||||
if let path = saved[String(song.id)] {
|
||||
let file = URL(fileURLWithPath:path).resolvingSymlinksInPath()
|
||||
if folders.contains(file.deletingLastPathComponent()),file.pathExtension == "waveform",FileManager.default.fileExists(atPath:file.path) { cacheSources[song.id] = file;allCaches.insert(file) }
|
||||
|
||||
@@ -712,13 +712,13 @@ struct ImportProgressPanel: View {
|
||||
ImportLogView(progress:progress,title:isMove ? "Transfer Log" : "Import Log").frame(height:260)
|
||||
Divider()
|
||||
HStack {
|
||||
Text(progress.finished ? "Review the log or copy it before closing." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary)
|
||||
Text(progress.finished ? "Review the log or copy it before closing." : isMove ? "Copies are verified before originals are removed." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if progress.finished {
|
||||
Button("Done",action:close).buttonStyle(.borderedProminent).tint(accent).keyboardShortcut(.defaultAction)
|
||||
} else {
|
||||
if let background { Button("Run in Background",action:background) }
|
||||
Button(isMove ? "Cancel Move" : "Cancel Import",action:cancel).keyboardShortcut(.cancelAction)
|
||||
Button(progress.current.hasPrefix("Cancelling") ? "Cancelling…" : isMove ? "Cancel Move" : "Cancel Import",action:cancel).keyboardShortcut(.cancelAction).disabled(progress.current.hasPrefix("Cancelling"))
|
||||
}
|
||||
}
|
||||
}.padding(26).frame(width:660)
|
||||
|
||||
Reference in New Issue
Block a user