importer chnges

This commit is contained in:
Mark Gregory
2026-11-05 10:18:41 +00:00
parent 1c509b2dad
commit 38a99d94ba
7 changed files with 479 additions and 31 deletions
+91 -17
View File
@@ -88,7 +88,7 @@ struct SongDragReference: Codable, Sendable {
private var waveformJobTask: Task<Void,Never>?
private var waveformJobToken: UUID?
func generateWaveforms(_ ids: Set<Int64>? = nil,force: Bool = false) {
guard !generatingWaveforms,scanningSource == nil,!isImporting else { error = "Wait for the current waveform or location scan to finish, or cancel it first.";return }
guard !generatingWaveforms,scanningSource == nil,!isImporting,!movingMedia else { error = "Wait for the current waveform or location scan to finish, or cancel it first.";return }
let tracks = songs.filter{ids == nil || ids!.contains($0.id)}
guard !tracks.isEmpty else { error = "Select a library track first.";return }
let token = UUID(),locations = mediaLocations,cache = root.appendingPathComponent("Waveforms")
@@ -109,13 +109,20 @@ struct SongDragReference: Codable, Sendable {
let player: Player
private(set) var library: Library?
let root: URL
@Published private(set) var managedMediaDirectory: URL
@Published private(set) var movingMedia = false
@Published private(set) var storageProgress: ImportProgress?
private var storageTask: Task<Void,Never>?
private var storageToken: UUID?
private var mediaAccessURLs = [URL]()
var currentVenueName: String { venues.first { $0.id == currentVenue }?.name ?? "No venue selected" }
init(storageURL: URL? = nil) {
root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone")
managedMediaDirectory = root.appendingPathComponent("Media")
player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"))
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite")); try refresh()
library = try Library(url:root.appendingPathComponent("Library.sqlite"));loadMediaStorage();try refresh()
if try library?.setting("store.profiles.v1") == nil { installStoreProfiles();try library?.setSetting("store.profiles.v1","1") }
}
if let data = try? Data(contentsOf:root.appendingPathComponent("StudioSettings.json")),let saved = try? JSONDecoder().decode(StudioSettings.self,from:data) { studio = saved.sanitized }
@@ -342,10 +349,10 @@ struct SongDragReference: Codable, Sendable {
try refresh();return id
}
func startSourceScan(_ id: Int64,forceWaveforms: Bool = false) {
guard scanningSource == nil,!generatingWaveforms,!isImporting else { error = "A waveform or location scan is already running. Cancel it or wait for it to finish.";return }
guard scanningSource == nil,!generatingWaveforms,!isImporting,!movingMedia else { error = "A waveform or location scan is already running. Cancel it or wait for it to finish.";return }
guard let source = mediaLocations.first(where:{ $0.id == id }) else { return }
let token = UUID();scanToken = token;scanningSource = id;scanProgress = SourceScanProgress(source:id)
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms")
let database = root.appendingPathComponent("Library.sqlite"),media = managedMediaDirectory,cache = root.appendingPathComponent("Waveforms")
scanTask = Task.detached(priority:.utility) { [weak self] in
let result: Result<SourceScanProgress,Error>
do {
@@ -370,14 +377,80 @@ struct SongDragReference: Codable, Sendable {
guard scanningSource == nil else { error = "Finish or cancel the scan before removing a location";return }
perform { try library?.removeMediaLocation(id);try refresh() }
}
deinit { for url in mediaAccessURLs { url.stopAccessingSecurityScopedResource() } }
private func mediaStorageLocation() -> MediaStorageLocation? {
guard let json = try? library?.setting(MediaStorageLocation.key),let data = json.data(using:.utf8) else { return nil }
return try? JSONDecoder().decode(MediaStorageLocation.self,from:data)
}
private func loadMediaStorage() {
guard let location = mediaStorageLocation() else { return }
var url = URL(fileURLWithPath:location.path)
if let bookmark = location.bookmark {
var stale = false
if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { url = resolved }
}
managedMediaDirectory = url
if !mediaAccessURLs.contains(url),url.startAccessingSecurityScopedResource() { mediaAccessURLs.append(url) }
}
private var storageBusy: Bool { isImporting || movingMedia || scanningSource != nil || generatingWaveforms }
func chooseMediaStorage(moveExisting:Bool = false) {
guard !storageBusy else { error = "Finish or cancel the current import, scan or move first.";return }
let panel = NSOpenPanel();panel.canChooseFiles = false;panel.canChooseDirectories = true;panel.canCreateDirectories = true;panel.allowsMultipleSelection = false
panel.title = moveExisting ? "Move Imported Media" : "Choose Import Folder"
panel.prompt = moveExisting ? "Move Media Here" : "Use Folder"
panel.message = moveExisting ? "Existing imported audio and CDG files will be copied, verified and moved here. Library links will update automatically." : "New imports will be stored in this folder. Use Move Existing Media to relocate previous imports too."
panel.directoryURL = managedMediaDirectory
panel.begin { [weak self] response in
guard response == .OK,let url = panel.url else { return }
Task { @MainActor in
guard let self else { return }
if moveExisting { self.moveMedia(to:url) } else { self.perform { try self.setMediaDirectory(url) } }
}
}
}
func setMediaDirectory(_ url:URL) throws {
guard !storageBusy,let library else { throw LibraryError.message("Finish the current library job before changing storage") }
let scoped = url.startAccessingSecurityScopedResource();defer { if scoped { url.stopAccessingSecurityScopedResource() } }
try ManagedMediaStorage.prepare(url)
let roots = (mediaStorageLocation()?.roots ?? [])+[root.appendingPathComponent("Media").path,managedMediaDirectory.path]
try ManagedMediaStorage.save(ManagedMediaStorage.location(url,roots:roots),to:library)
loadMediaStorage()
}
func moveMedia(to url:URL) {
guard !storageBusy else { error = "Finish or cancel the current library job first.";return }
guard !player.playing,!backgroundDeck.playing else { error = "Stop playback before moving imported media.";return }
let roots = Array(Set((mediaStorageLocation()?.roots ?? [])+[root.appendingPathComponent("Media").path,managedMediaDirectory.path])).map{URL(fileURLWithPath:$0)}
let token = UUID(),database = root.appendingPathComponent("Library.sqlite")
storageToken = token;movingMedia = true;storageProgress = ImportProgress()
storageTask = Task.detached(priority:.utility) { [weak self] in
let scoped = url.startAccessingSecurityScopedResource();defer { if scoped { url.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress()
do { latest = try ManagedMediaStorage.move(database:database,roots:roots,destination:url,cancelled:{Task.isCancelled},progress:{ update in
latest = update
Task { @MainActor [weak self] in guard let self,self.storageToken == token else { return };self.storageProgress = update }
}) } catch is CancellationError { latest.cancelled = true;latest.current = "Move cancelled; original media kept" }
catch { latest.failure = error.localizedDescription;latest.current = "Move stopped; original media kept" }
latest.finished = true;let result = latest
await MainActor.run { [weak self] in
guard let self,self.storageToken == token else { return }
self.storageToken = nil;self.storageTask = nil;self.movingMedia = false;self.loadMediaStorage()
if let data = try? Data(contentsOf:self.root.appendingPathComponent("StudioSettings.json")),let saved = try? JSONDecoder().decode(StudioSettings.self,from:data),saved.backgroundPath != self.studio.backgroundPath {
var next = self.studio;next.backgroundPath = saved.backgroundPath;next.backgroundBookmark = saved.backgroundBookmark;self.studio = next
}
self.perform { try self.refresh() };self.storageProgress = result
}
}
}
func cancelMediaMove() { storageTask?.cancel();storageProgress?.current = "Cancelling after the current copy…" }
func dismissMediaMove() { if !movingMedia { storageProgress = nil } }
@Published private(set) var importProgress: ImportProgress?
@Published private(set) var isImporting = false
private var importTask: Task<Void,Never>?
private var importToken: UUID?
func importFiles(_ urls: [URL]) {
guard !isImporting,scanningSource == nil,!generatingWaveforms else { error = "Wait for the current import, scan or waveform job to finish first.";return }
guard !isImporting,scanningSource == nil,!generatingWaveforms,!movingMedia else { error = "Wait for the current import, scan or waveform job to finish first.";return }
guard !urls.isEmpty else { return }
let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media")
let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = managedMediaDirectory
importToken = token;isImporting = true;importProgress = ImportProgress()
importTask = Task.detached(priority:.utility) { [weak self] in
let accessed = urls.filter { $0.startAccessingSecurityScopedResource() }
@@ -472,29 +545,30 @@ private extension FocusedValues {
}
private struct ImportProgressDialog: View {
@ObservedObject var model: AppModel
var storageMode = false
var body: some View {
ZStack {
Color.black.opacity(0.16).ignoresSafeArea().contentShape(Rectangle()).onTapGesture { }
if let progress = model.importProgress {
if let progress = storageMode ? model.storageProgress : model.importProgress {
VStack(alignment:.leading,spacing:16) {
HStack(spacing:12) {
Image(systemName:progress.finished ? (progress.failure == nil ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") : "square.and.arrow.down")
.font(.system(size:28)).foregroundStyle(Color.accentColor)
VStack(alignment:.leading,spacing:3) {
Text(progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Stopped") : "Importing Music").font(.headline)
Text(progress.total > 0 ? "\(progress.completed) of \(progress.total) tracks checked" : "Preparing files and archives…").font(.subheadline).foregroundStyle(.secondary)
Text(storageMode ? (progress.finished ? (progress.cancelled ? "Move Cancelled" : progress.failure == nil ? "Media Moved" : "Move Needs Attention") : "Moving Imported Media") : (progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Stopped") : "Importing Music")).font(.headline)
Text(progress.total > 0 ? "\(progress.completed) of \(progress.total) \(storageMode ? "files verified" : "tracks checked")" : "Preparing files…").font(.subheadline).foregroundStyle(.secondary)
}
}
if progress.total > 0 { ProgressView(value:Double(progress.completed),total:Double(progress.total)) }
else if !progress.finished { ProgressView().controlSize(.small) }
if progress.completed > 0 { Text("\(progress.completed-progress.skipped) imported · \(progress.skipped) already in library").font(.callout.weight(.medium)) }
if progress.completed > 0 && !storageMode { Text("\(progress.completed-progress.skipped) imported · \(progress.skipped) already in library").font(.callout.weight(.medium)) }
Text(progress.failure ?? progress.current).font(.callout).foregroundStyle(.secondary).lineLimit(3).frame(maxWidth:.infinity,alignment:.leading)
if progress.cancelled || progress.failure != nil { Text("Tracks already imported are saved in your library.").font(.caption).foregroundStyle(.secondary) }
HStack { Spacer();if progress.finished { Button("Done") { model.dismissImport() }.keyboardShortcut(.defaultAction) } else { Button("Cancel Import") { model.cancelImport() }.keyboardShortcut(.cancelAction) } }
if progress.cancelled || progress.failure != nil { Text(storageMode ? "Original files are kept until their copies and library links are verified." : "Tracks already imported are saved in your library.").font(.caption).foregroundStyle(.secondary) }
HStack { Spacer();if progress.finished { Button("Done") { if storageMode { model.dismissMediaMove() } else { model.dismissImport() } }.keyboardShortcut(.defaultAction) } else { Button(storageMode ? "Cancel Move" : "Cancel Import") { if storageMode { model.cancelMediaMove() } else { model.cancelImport() } }.keyboardShortcut(.cancelAction) } }
}.padding(24).frame(width:380).background(.regularMaterial,in:RoundedRectangle(cornerRadius:14))
.overlay(RoundedRectangle(cornerRadius:14).strokeBorder(.primary.opacity(0.08)))
.shadow(color:.black.opacity(0.2),radius:24,y:8)
.accessibilityElement(children:.contain).accessibilityLabel("Music import progress")
.accessibilityElement(children:.contain).accessibilityLabel(storageMode ? "Media move progress" : "Music import progress")
}
}
}
@@ -662,8 +736,8 @@ struct LibraryView: View {
}
var body: some View {
workspace
.disabled(model.importProgress != nil)
.overlay { if model.importProgress != nil { ImportProgressDialog(model:model) } }
.disabled(model.importProgress != nil || model.storageProgress != nil)
.overlay { if model.storageProgress != nil { ImportProgressDialog(model:model,storageMode:true) } else if model.importProgress != nil { ImportProgressDialog(model:model) } }
.onAppear { updateLibraryRows() }
.onChange(of:model.songs) { _ in updateLibraryRows() }
.onChange(of:model.tuningCache) { _ in updateLibraryRows() }
@@ -1349,8 +1423,8 @@ private struct SingerSongsSheet: View {
}.frame(height:270)
HStack { Button("Import Songs…") { importing = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:520).background(StudioTheme.frame)
.disabled(model.importProgress != nil)
.overlay { if model.importProgress != nil { ImportProgressDialog(model:model) } }
.disabled(model.importProgress != nil || model.storageProgress != nil)
.overlay { if model.storageProgress != nil { ImportProgressDialog(model:model,storageMode:true) } else if model.importProgress != nil { ImportProgressDialog(model:model) } }
.fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.movie,.zip,UTType(filenameExtension:"cdg") ?? .data],allowsMultipleSelection:true) { result in
switch result { case .success(let urls):model.importFiles(urls);case .failure(let error):model.error = error.localizedDescription }
}