importer chnges
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user