importer chnges

This commit is contained in:
Mark Gregory
2026-11-05 10:42:21 +00:00
parent faa1fe43d0
commit 1d6819d99e
3 changed files with 81 additions and 2 deletions
+69 -2
View File
@@ -651,6 +651,8 @@ struct LibraryView: View {
@State private var search = ""
@State private var name = ""
@State private var importing = false
@State private var importingServer = false
@State private var serverImportURLs = [URL]()
private var source: String { get { model.activeSection } nonmutating set { model.activeSection = newValue } }
@State private var singersExpanded = true
private var expandedSingers: Set<Int64> { get { model.expandedSingerIDs } nonmutating set { model.expandedSingerIDs = newValue } }
@@ -721,7 +723,10 @@ struct LibraryView: View {
.searchable(text:$search,placement:.toolbar,prompt:"Search Music")
.toolbar {
ToolbarItemGroup {
Button { importing = true } label: { Label("Import Music",systemImage:"square.and.arrow.down") }.help("Import music or video")
Menu {
Button("Import Files or Folder…") { importing = true }
Button("Import from SMB/NAS…") { importingServer = true }
} label: { Label("Import Music",systemImage:"square.and.arrow.down") }.help("Import from files, folders or a server drive")
Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.disabled(model.generatingWaveforms || model.scanningSource != nil).help("Prepare waveforms for a track, selected tracks or the whole library")
Menu {
Button("New Singer…") { model.beginSinger() }
@@ -769,6 +774,10 @@ struct LibraryView: View {
updateLibraryRows()
if source == "Singer Songs",presentedSinger != singer { showSinger(singer,section:nil) }
}
.sheet(isPresented:$importingServer,onDismiss:{
let urls = serverImportURLs;serverImportURLs = []
model.importFiles(urls)
}) { ServerImportSheet { serverImportURLs = $0 } }
.fileImporter(isPresented: $importing, allowedContentTypes: [.folder, .audio, .movie, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in
switch result {
case .success(let urls): model.importFiles(urls)
@@ -1394,6 +1403,58 @@ private struct MusicStoreEditor: View {
}
private func save() { do { try model.saveStore(.init(id:store?.id ?? 0,name:name,url:url,searchTemplate:search));dismiss() } catch { self.error = error.localizedDescription } }
}
/// Finder owns server authentication and stores credentials through macOS.
/// Only the user-selected mounted folder is passed to the ordinary copy importer.
enum SMBImportAddress {
static func url(_ address:String) throws -> URL {
let text = address.trimmingCharacters(in:.whitespacesAndNewlines)
guard let url = URL(string:text),url.scheme?.lowercased() == "smb",
let host = url.host,!host.isEmpty,url.user == nil,url.password == nil,
url.query == nil,url.fragment == nil else {
throw LibraryError.message("Enter an SMB address such as smb://nas/Music. Leave usernames and passwords out; macOS will ask you to sign in.")
}
return url
}
}
private struct ServerImportSheet: View {
let selected: ([URL]) -> Void
@Environment(\.dismiss) private var dismiss
@State private var address = "smb://"
@State private var choosingFolder = false
@State private var error: String?
var body: some View {
VStack(alignment:.leading,spacing:16) {
Text("Import from SMB/NAS").font(.title2.bold())
Text("Connect to your server, then choose a folder on its mounted drive. Supported files in that folder and its subfolders will be copied into your media library.").fixedSize(horizontal:false,vertical:true)
TextField("smb://nas/Music",text:$address).textFieldStyle(.roundedBorder)
Button("Connect in Finder…") {
do {
let url = try SMBImportAddress.url(address)
guard NSWorkspace.shared.open(url) else { throw LibraryError.message("Could not open the server connection in Finder.") }
error = nil
} catch { self.error = error.localizedDescription }
}
Text("Finish signing in through macOS. Then use Choose Server Folder to select the connected drive under Locations. You can also choose a drive that is already connected.").font(.callout).foregroundStyle(.secondary).fixedSize(horizontal:false,vertical:true)
if let error { Text(error).foregroundStyle(.red).fixedSize(horizontal:false,vertical:true) }
HStack {
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
Spacer()
Button("Choose Server Folder…") {
choosingFolder = true
}.keyboardShortcut(.defaultAction)
}
}.padding(24).frame(width:500)
.fileImporter(isPresented:$choosingFolder,allowedContentTypes:[.folder],allowsMultipleSelection:false) { result in
switch result {
case .success(let urls):
guard !urls.isEmpty else { return }
selected(urls)
dismiss()
case .failure(let error): self.error = error.localizedDescription
}
}
}
}
private struct SingerSongsSheet: View {
@ObservedObject var model: AppModel
let singer: PersonPlace
@@ -1401,6 +1462,8 @@ private struct SingerSongsSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var search = ""
@State private var importing = false
@State private var importingServer = false
@State private var serverImportURLs = [URL]()
var body: some View {
VStack(alignment:.leading,spacing:14) {
Text(tonight ? "Tonight for \(singer.name)" : "Songs for \(singer.name)").font(.title2.bold())
@@ -1421,10 +1484,14 @@ private struct SingerSongsSheet: View {
if model.songs.isEmpty { Text("Your library is empty. Import audio or MP3+G below.").foregroundStyle(.secondary) }
}.frame(maxWidth:.infinity,alignment:.leading)
}.frame(height:270)
HStack { Button("Import Songs…") { importing = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
HStack { Button("Import Songs…") { importing = true };Button("SMB/NAS…") { importingServer = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:520).background(StudioTheme.frame)
.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) } }
.sheet(isPresented:$importingServer,onDismiss:{
let urls = serverImportURLs;serverImportURLs = []
model.importFiles(urls)
}) { ServerImportSheet { serverImportURLs = $0 } }
.fileImporter(isPresented:$importing,allowedContentTypes:[.folder,.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 }
}
+8
View File
@@ -6,6 +6,14 @@ import UniformTypeIdentifiers
@testable import KJamsClone
final class KJamsCloneTests: XCTestCase {
func testSMBImportAddressValidation() throws {
XCTAssertEqual(try SMBImportAddress.url(" smb://nas/Music ").host,"nas")
XCTAssertEqual(try SMBImportAddress.url("smb://192.168.1.20/Karaoke/Nested").path,"/Karaoke/Nested")
for address in ["", "smb://", "https://nas/Music", "file:///Volumes/Music", "smb://user:secret@nas/Music", "smb://user@nas/Music", "smb://nas/Music?password=secret", "smb://nas/Music#secret"] {
XCTAssertThrowsError(try SMBImportAddress.url(address))
}
}
func testRecursiveFolderImportPairsDeduplicatesAndSkipsManagedMedia() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
+4
View File
@@ -146,3 +146,7 @@ In 0.6.3 the configured background Fade out also applies when starting/resuming
Click **Waveforms** in the toolbar or bottom status bar. Generate for the loaded track, one selected library track, all selected tracks (Command/Shift-select), or every track in the library, including tracks hidden by search. Right-click a track or selection for the same generation actions.
Generate reuses valid saved waveforms. Regenerate forces fresh analysis and replaces the saved waveform. Work runs on a background worker without starting/stopping music, importing media or changing singer lists. Progress shows completed/generated/reused counts and read issues; Cancel retains completed caches. Details lists up to 100 file issues. ShowScreen slides are skipped, and disconnected drives/read failures are reported while the remaining tracks continue. Reconnect unavailable drives before retrying. Existing location scans and batch waveform jobs run one at a time; the playing track’s display refreshes from its updated cache.
### Import from an SMB/NAS server
Open the Import Music toolbar menu → Import from SMB/NAS. Enter `smb://server/share`, click Connect in Finder, and complete macOS sign-in. Click Choose Server Folder and select the connected share under Locations. Imports copy supported files recursively into the configured managed media folder, using the normal duplicate checks, MP3/CDG pairing, progress and cancellation. Already connected shares can be selected without reconnecting. Keep the share connected until import finishes; connection or read errors stop the job and retain completed tracks. Reconnect and retry to skip completed imports. Credentials are handled by macOS, not saved by KJMix Pro.