importer chnges
This commit is contained in:
@@ -39,7 +39,35 @@ struct Importer {
|
||||
return siblingCache[directory]?[url.deletingPathExtension().lastPathComponent.lowercased()] ?? [:]
|
||||
}
|
||||
defer { state.total = max(state.total,state.completed);progress(state) }
|
||||
var files = urls
|
||||
// Discover before copying: overlapping selections must not import twice,
|
||||
// and a destination inside the selected tree must not feed itself back in.
|
||||
let keys: Set<URLResourceKey> = [.isDirectoryKey,.isRegularFileKey,.isSymbolicLinkKey,.isPackageKey]
|
||||
let supported = Set(["mp3","wav","m4a","mp4","m4v","mov","cdg","zip"])
|
||||
let managedRoot = media.resolvingSymlinksInPath().standardizedFileURL
|
||||
var visited = Set<URL>(),discovered = Set<URL>()
|
||||
func discover(_ input:URL) throws {
|
||||
if cancelled() { throw CancellationError() }
|
||||
let url = input.standardizedFileURL
|
||||
guard visited.insert(url).inserted else { return }
|
||||
do {
|
||||
let values = try url.resourceValues(forKeys:keys)
|
||||
// Do not follow directory symlinks (cycles or files outside the selection).
|
||||
guard values.isSymbolicLink != true else { return }
|
||||
if values.isDirectory == true {
|
||||
guard values.isPackage != true,url.resolvingSymlinksInPath().standardizedFileURL.path != managedRoot.path else { return }
|
||||
report("Scanning "+url.path)
|
||||
for child in try FileManager.default.contentsOfDirectory(at:url,includingPropertiesForKeys:Array(keys),options:[.skipsHiddenFiles]) {
|
||||
try discover(child)
|
||||
}
|
||||
} else if values.isRegularFile == true,supported.contains(url.pathExtension.lowercased()) {
|
||||
discovered.insert(url)
|
||||
}
|
||||
} catch is CancellationError { throw CancellationError() }
|
||||
catch { throw LibraryError.message("Could not scan \(url.path): \(error.localizedDescription)") }
|
||||
}
|
||||
report("Scanning selected files and folders…",force:true)
|
||||
for url in urls { try discover(url) }
|
||||
var files = discovered.sorted { $0.path < $1.path }
|
||||
var temporary = [URL]()
|
||||
defer { for url in temporary { try? FileManager.default.removeItem(at: url) } }
|
||||
var songIDs = Set<Int64>()
|
||||
@@ -57,7 +85,7 @@ struct Importer {
|
||||
if let archive = archiveForFile[audio.resolvingSymlinksInPath().path] { archiveSongIDs[archive,default:[]].append(id) }
|
||||
else { try library.rememberImport(path:audio.standardizedFileURL.path,signature:signature,songs:[id]) }
|
||||
}
|
||||
for zip in urls where zip.pathExtension.lowercased() == "zip" {
|
||||
for zip in files where zip.pathExtension.lowercased() == "zip" {
|
||||
if cancelled() { throw CancellationError() }
|
||||
let signature = try importSignature(zip)
|
||||
if let existing = try library.importedSongs(path:zip.standardizedFileURL.path,signature:signature) {
|
||||
@@ -125,7 +153,7 @@ struct Importer {
|
||||
if let audio = ["mp3","wav","m4a","mp4","m4v","mov"].compactMap({ matching[$0] }).first { files.append(audio) }
|
||||
}
|
||||
var imported = 0
|
||||
let audioFiles = Set(files).filter { ["mp3","wav","m4a","mp4","m4v","mov"].contains($0.pathExtension.lowercased()) }.sorted { $0.path < $1.path }
|
||||
let audioFiles = Set(files.map { $0.standardizedFileURL }).filter { ["mp3","wav","m4a","mp4","m4v","mov"].contains($0.pathExtension.lowercased()) }.sorted { $0.path < $1.path }
|
||||
state.total = audioFiles.count+state.completed
|
||||
report("Importing tracks…",force:true)
|
||||
for audio in audioFiles {
|
||||
|
||||
@@ -769,7 +769,7 @@ struct LibraryView: View {
|
||||
updateLibraryRows()
|
||||
if source == "Singer Songs",presentedSinger != singer { showSinger(singer,section:nil) }
|
||||
}
|
||||
.fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .movie, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in
|
||||
.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
|
||||
@@ -1425,7 +1425,7 @@ private struct SingerSongsSheet: View {
|
||||
}.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) } }
|
||||
.fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.movie,.zip,UTType(filenameExtension:"cdg") ?? .data],allowsMultipleSelection:true) { result in
|
||||
.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,42 @@ import UniformTypeIdentifiers
|
||||
@testable import KJamsClone
|
||||
|
||||
final class KJamsCloneTests: XCTestCase {
|
||||
func testRecursiveFolderImportPairsDeduplicatesAndSkipsManagedMedia() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
let nested = root.appendingPathComponent("Input/Artist/Album")
|
||||
try FileManager.default.createDirectory(at:nested,withIntermediateDirectories:true)
|
||||
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
|
||||
let audio = nested.appendingPathComponent("Song.MP3")
|
||||
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.mp3"),to:audio)
|
||||
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.cdg"),to:nested.appendingPathComponent("song.CDG"))
|
||||
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.zip"),to:root.appendingPathComponent("Input/Archive.zip"))
|
||||
try Data("ignored".utf8).write(to:nested.appendingPathComponent("Notes.txt"))
|
||||
try FileManager.default.createSymbolicLink(at:nested.appendingPathComponent("Loop"),withDestinationURL:root)
|
||||
let media = root.appendingPathComponent("Input/Managed")
|
||||
try FileManager.default.createDirectory(at:media,withIntermediateDirectories:true)
|
||||
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("TestTrack.mp3"),to:media.appendingPathComponent("Existing.mp3"))
|
||||
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:media)
|
||||
var updates = [ImportProgress]()
|
||||
let ids = try importer.run([root.appendingPathComponent("Input"),nested,audio],library:db,progress:{updates.append($0)})
|
||||
XCTAssertEqual(ids.count,1);XCTAssertEqual(try db.songs().count,1)
|
||||
XCTAssertNotNil(try db.songs().first?.cdg)
|
||||
XCTAssertEqual(updates.last?.completed,2);XCTAssertEqual(updates.last?.total,2)
|
||||
XCTAssertTrue(updates.contains{$0.current.hasPrefix("Scanning ")})
|
||||
var latest = ImportProgress()
|
||||
XCTAssertEqual(try importer.run([root.appendingPathComponent("Input")],library:db,progress:{latest = $0}),ids)
|
||||
XCTAssertEqual(latest.skipped,2);XCTAssertTrue(latest.finished)
|
||||
}
|
||||
func testFolderDiscoveryCancellationEmptyFolderAndMissingInput() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:root.appendingPathComponent("Media"))
|
||||
XCTAssertThrowsError(try importer.run([root],library:db,cancelled:{true})) { XCTAssertTrue($0 is CancellationError) }
|
||||
XCTAssertThrowsError(try importer.run([root],library:db)) { XCTAssertTrue($0.localizedDescription.contains("No audio found")) }
|
||||
XCTAssertThrowsError(try importer.run([root.appendingPathComponent("Missing")],library:db)) { XCTAssertTrue($0.localizedDescription.contains("Missing")) }
|
||||
XCTAssertTrue(try db.songs().isEmpty)
|
||||
}
|
||||
@MainActor func testLargeImportReturnsImmediatelyAndReportsCompletion() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
|
||||
@@ -87,7 +87,7 @@ Catalogue… supports local JSON import/reload, saved catalogue browsing, search
|
||||
|
||||
## Local playback, waveform and external drives
|
||||
|
||||
Import MP3/WAV/M4A, same-basename CDG pairs, MP3+G ZIPs or MP4/M4V/MOV with macOS-decodable audio/video. Keep MP3 and CDG together. Direct imports copy managed media; registered locations reference regular files in place. Exact content of the same extension is deduplicated. ZIP entries are validated against traversal/symlinks and extracted media is copied for playback.
|
||||
Import MP3/WAV/M4A, same-basename CDG pairs, MP3+G ZIPs or MP4/M4V/MOV with macOS-decodable audio/video. Choose files or a top-level folder in either Import picker; folders are scanned recursively, including nested ZIPs. Keep MP3 and CDG together in the same folder (matching is case-insensitive). Overlapping folder/file selections are checked once. Hidden items, packages, symlinks and the active managed media folder are skipped during folder scans. Scanning and importing run in the background with progress and cancellation. An unreadable input or invalid archive stops the job with an error; completed imports remain saved for a retry. Direct imports copy managed media; registered locations reference regular files in place. Exact content of the same extension is deduplicated. ZIP entries are validated against traversal/symlinks and extracted media is copied for playback.
|
||||
|
||||
Library Locations recursively scans mounted drives/folders, indexes supported media and prepares cached 4096-bin RMS/frequency waveforms in the background. Cancel keeps completed work. Rescan reuses unchanged entries/caches; Rebuild Waveforms forces analysis. Offline sources retain their catalogue; removing a location keeps songs/media. Reconnect the drive to play referenced files. Results refresh at completion; there is no filesystem watcher/scheduled rescan.
|
||||
|
||||
@@ -115,7 +115,7 @@ Original test media: TestTrack.zip/MP3/CDG (20-second synthetic melody and origi
|
||||
|
||||
No full commercial-store APIs/DRM, CD ripping/burning/disc lookup, vocal-channel routing/removal, crossfade, automatic BPM/key analysis, CDG parity correction/channel selection, or complete kJams compatibility. ShowScreens are static text slides. The server is local HTTP and lacks public Internet account hosting. Native mouse/touch/keyboard flows, hardware display latency, broad commercial-media conformance, large-library/live-show stress, older macOS and Intel execution are not exhaustively validated.
|
||||
|
||||
Direct imports still run on the main thread; large/backward CDG seeks use cancellable background decoding and sparse checkpoints; there is no archive expansion quota. A multi-file direct import is not one filesystem/database transaction and can leave orphan copied media on failure. File tag support is limited as described above. Some AVFoundation metadata/export accessors compile with deprecation warnings. The app is not sandboxed. Design follows native macOS conventions but is not Apple-certified.
|
||||
Direct imports run on a background worker; large/backward CDG seeks use cancellable background decoding and sparse checkpoints; there is no archive expansion quota. A multi-file direct import is not one filesystem/database transaction and can leave orphan copied media on failure. File tag support is limited as described above. Some AVFoundation metadata/export accessors compile with deprecation warnings. The app is not sandboxed. Design follows native macOS conventions but is not Apple-certified.
|
||||
|
||||
References: [Rotation](https://karaoke.kjams.com/wiki/Rotation), [Manual rotation](https://karaoke.kjams.com/wiki/Rotation/Manual), [Singer server](https://karaoke.kjams.com/wiki/Server), [Music stores](https://karaoke.kjams.com/wiki/Music_Store), [Apple Search API](https://performance-partners.apple.com/search-api), [CD+G Revealed](https://jbum.com/cdg_revealed.html). Implementation and bundled test media are original.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user