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