diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index 1c0b335..f47e165 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -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 { 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 } } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index f52ac50..9cff0ba 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -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) } diff --git a/README.md b/README.md index 57965fe..caaec22 100644 --- a/README.md +++ b/README.md @@ -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.