importer chnges
This commit is contained in:
@@ -245,7 +245,7 @@
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = 39R6U3437D;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
@@ -255,7 +255,7 @@
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server.";
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 0.6.6;
|
||||
MARKETING_VERSION = 0.6.7;
|
||||
OTHER_LDFLAGS = "-lsqlite3";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app;
|
||||
PRODUCT_MODULE_NAME = KJamsClone;
|
||||
@@ -273,7 +273,7 @@
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = 39R6U3437D;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -282,7 +282,7 @@
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server.";
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 0.6.6;
|
||||
MARKETING_VERSION = 0.6.7;
|
||||
OTHER_LDFLAGS = "-lsqlite3";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app;
|
||||
PRODUCT_MODULE_NAME = KJamsClone;
|
||||
|
||||
@@ -318,6 +318,18 @@ struct StudioSettingsPane: View {
|
||||
}.padding(10)
|
||||
}
|
||||
GlobalMixerSettings(player:model.player)
|
||||
GroupBox("Imported Media Storage") {
|
||||
VStack(alignment:.leading,spacing:12) {
|
||||
Label(model.managedMediaDirectory.path,systemImage:"externaldrive").font(.callout).textSelection(.enabled).lineLimit(3)
|
||||
HStack {
|
||||
Button("Choose Import Folder…") { model.chooseMediaStorage() }
|
||||
Button("Move Existing Media…") { model.chooseMediaStorage(moveExisting:true) }
|
||||
Button("Show in Finder") { NSWorkspace.shared.open(model.managedMediaDirectory) }
|
||||
}.disabled(model.isImporting || model.movingMedia || model.scanningSource != nil || model.generatingWaveforms)
|
||||
Text("Choose changes where new imports are stored. Move copies and verifies existing imported audio and CDG files, updates library links, then removes the originals to free space. Keep the external drive connected while importing or playing.").font(.caption).foregroundStyle(.secondary)
|
||||
Text("The small library database, singer lists, settings and waveform cache stay on this Mac.").font(.caption).foregroundStyle(.secondary)
|
||||
}.padding(10)
|
||||
}
|
||||
GroupBox("Singer Web & Library") {
|
||||
VStack(alignment:.leading,spacing:12) {
|
||||
HStack { Text("Default server port");TextField("8080",text:$port).frame(width:90);Button("Apply Port") { if let value = Int(port),(1024...65535).contains(value) { model.studio.serverPort = value } else { model.error = "Use a port from 1024 to 65535" } } }
|
||||
|
||||
+197
-9
@@ -15,7 +15,7 @@ struct ImportProgress: Sendable {
|
||||
struct Importer {
|
||||
let media: URL
|
||||
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] {
|
||||
try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true)
|
||||
try ManagedMediaStorage.prepare(media)
|
||||
var state = ImportProgress()
|
||||
var lastReport = Date.distantPast
|
||||
func report(_ current: String,force: Bool = false) {
|
||||
@@ -72,10 +72,46 @@ struct Importer {
|
||||
// Validate names before extraction; reject path traversal and symlinks afterwards.
|
||||
let details = try process(["-Z", "-l", zip.path])
|
||||
guard !details.split(separator: "\n").contains(where: { $0.hasPrefix("l") }) else { throw LibraryError.message("ZIP symlinks are unsupported") }
|
||||
let list = try process(["-Z1", zip.path])
|
||||
let names = list.split(separator: "\n").map(String.init)
|
||||
let list = try processData(["-Z1", zip.path])
|
||||
let rawNames = list.split(separator:0x0A).map { Data($0) }
|
||||
let names = rawNames.map { String(decoding:$0,as:UTF8.self) }
|
||||
guard !names.isEmpty, names.allSatisfy({ !$0.hasPrefix("/") && !$0.split(separator: "/").contains("..") && !$0.contains("\\") }) else { throw LibraryError.message("Unsafe or empty ZIP") }
|
||||
_ = try process(["-q", zip.path, "-d", folder.path])
|
||||
// ditto maps legacy ZIP filename bytes to valid macOS names. unzip can
|
||||
// fail with exit 50 ("disk full") on those names even when CRCs pass.
|
||||
_ = try process(["-x", "-k", "--noqtn", zip.path, folder.path],executable:"/usr/bin/ditto",operation:"extract ZIP archive")
|
||||
// Unflagged older Windows archives commonly use CP1252. ditto makes
|
||||
// their names filesystem-safe using Mac Roman; rename those components
|
||||
// to Unicode CP1252 spellings before matching MP3/CDG companions.
|
||||
var renamedComponents = [String:String]()
|
||||
for rawName in try archiveNames(zip) {
|
||||
var original = [String]()
|
||||
for rawComponent in rawName.split(separator:0x2F) {
|
||||
let bytes = Data(rawComponent)
|
||||
if let unicode = String(data:bytes,encoding:.utf8) { original.append(unicode);continue }
|
||||
guard let native = String(data:bytes,encoding:.macOSRoman),
|
||||
let unicode = String(data:bytes,encoding:.windowsCP1252) ?? String(data:bytes,encoding:.isoLatin1) else { continue }
|
||||
original.append(native)
|
||||
renamedComponents[original.joined(separator:"/")] = unicode.precomposedStringWithCanonicalMapping
|
||||
}
|
||||
}
|
||||
if !renamedComponents.isEmpty {
|
||||
let rootPath = folder.resolvingSymlinksInPath().path
|
||||
let entries = FileManager.default.enumerator(at:folder,includingPropertiesForKeys:[.isSymbolicLinkKey])!
|
||||
var pending = [(URL,String)]()
|
||||
for case let entry as URL in entries {
|
||||
guard try entry.resourceValues(forKeys:[.isSymbolicLinkKey]).isSymbolicLink != true else { throw LibraryError.message("ZIP symlinks are unsupported") }
|
||||
let path = entry.resolvingSymlinksInPath().path
|
||||
guard path.hasPrefix(rootPath+"/") else { throw LibraryError.message("Unsafe ZIP path") }
|
||||
let relative = String(path.dropFirst(rootPath.count+1))
|
||||
if let corrected = renamedComponents[relative] { pending.append((entry,corrected)) }
|
||||
}
|
||||
// Children move before their parents, keeping nested paths valid.
|
||||
for (entry,corrected) in pending.sorted(by:{$0.0.pathComponents.count > $1.0.pathComponents.count}) {
|
||||
if cancelled() { throw CancellationError() }
|
||||
let target = entry.deletingLastPathComponent().appendingPathComponent(corrected)
|
||||
if entry.lastPathComponent != corrected { try FileManager.default.moveItem(at:entry,to:target) }
|
||||
}
|
||||
}
|
||||
let extracted = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: [.isSymbolicLinkKey,.isRegularFileKey])!
|
||||
for case let url as URL in extracted {
|
||||
let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey,.isRegularFileKey])
|
||||
@@ -154,13 +190,165 @@ struct Importer {
|
||||
guard let file = try? AVAudioFile(forReading:url) else { return 0 }
|
||||
return Double(file.length)/file.processingFormat.sampleRate
|
||||
}
|
||||
private func process(_ arguments: [String]) throws -> String {
|
||||
let task = Process(); task.executableURL = URL(fileURLWithPath: "/usr/bin/unzip"); task.arguments = arguments
|
||||
/// Read original central-directory bytes: zipinfo translates legacy names
|
||||
/// for the terminal, so its output cannot be used to rename extracted files.
|
||||
private func archiveNames(_ url:URL) throws -> [Data] {
|
||||
let file = try FileHandle(forReadingFrom:url);defer { try? file.close() }
|
||||
let size = try file.seekToEnd(),tailSize = min(size,65_557)
|
||||
try file.seek(toOffset:size-tailSize)
|
||||
let tail = [UInt8](try file.readToEnd() ?? Data())
|
||||
func integer(_ bytes:[UInt8],_ offset:Int,_ length:Int) -> UInt64 {
|
||||
guard offset >= 0,offset+length <= bytes.count else { return 0 }
|
||||
return (0..<length).reduce(0) { $0 | UInt64(bytes[offset+$1]) << (8*$1) }
|
||||
}
|
||||
guard tail.count >= 22,let end = stride(from:tail.count-22,through:0,by:-1).first(where:{integer(tail,$0,4) == 0x06054b50 && $0+22+Int(integer(tail,$0+20,2)) == tail.count}) else { throw LibraryError.message("Invalid ZIP directory") }
|
||||
var count = integer(tail,end+10,2),offset = integer(tail,end+16,4)
|
||||
if count == 0xFFFF || offset == 0xFFFFFFFF {
|
||||
guard end >= 20,integer(tail,end-20,4) == 0x07064b50 else { throw LibraryError.message("Invalid ZIP64 directory") }
|
||||
try file.seek(toOffset:integer(tail,end-12,8))
|
||||
let record = [UInt8](try file.read(upToCount:56) ?? Data())
|
||||
guard record.count == 56,integer(record,0,4) == 0x06064b50 else { throw LibraryError.message("Invalid ZIP64 directory") }
|
||||
count = integer(record,32,8);offset = integer(record,48,8)
|
||||
}
|
||||
guard offset <= size,count <= size/46 else { throw LibraryError.message("Invalid ZIP directory bounds") }
|
||||
var names = [Data]()
|
||||
for _ in 0..<count {
|
||||
try file.seek(toOffset:offset)
|
||||
let header = [UInt8](try file.read(upToCount:46) ?? Data())
|
||||
guard header.count == 46,integer(header,0,4) == 0x02014b50 else { throw LibraryError.message("Invalid ZIP entry") }
|
||||
let length = integer(header,28,2),extra = integer(header,30,2),comment = integer(header,32,2)
|
||||
guard offset+46+length+extra+comment <= size else { throw LibraryError.message("Invalid ZIP entry bounds") }
|
||||
let name = try file.read(upToCount:Int(length)) ?? Data()
|
||||
guard name.count == Int(length) else { throw LibraryError.message("Incomplete ZIP filename") }
|
||||
names.append(name);offset += 46+length+extra+comment
|
||||
}
|
||||
return names
|
||||
}
|
||||
private func process(_ arguments: [String],executable: String = "/usr/bin/unzip",operation: String = "read ZIP archive") throws -> String {
|
||||
String(decoding:try processData(arguments,executable:executable,operation:operation),as:UTF8.self)
|
||||
}
|
||||
private func processData(_ arguments: [String],executable: String = "/usr/bin/unzip",operation: String = "read ZIP archive") throws -> Data {
|
||||
let task = Process(); task.executableURL = URL(fileURLWithPath:executable); task.arguments = arguments
|
||||
let out = Pipe(); task.standardOutput = out
|
||||
let err = FileHandle.nullDevice; task.standardError = err
|
||||
task.standardError = out
|
||||
try task.run()
|
||||
let bytes = out.fileHandleForReading.readDataToEndOfFile(); task.waitUntilExit()
|
||||
guard task.terminationStatus == 0 else { throw LibraryError.message("Cannot read ZIP archive") }
|
||||
return String(decoding: bytes, as: UTF8.self)
|
||||
guard task.terminationStatus == 0 else {
|
||||
let detail = String(decoding:bytes,as:UTF8.self).trimmingCharacters(in:.whitespacesAndNewlines)
|
||||
throw LibraryError.message("Could not \(operation) (exit \(task.terminationStatus))."+(detail.isEmpty ? "" : " "+String(detail.prefix(600))))
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaStorageLocation: Codable, Sendable {
|
||||
var path: String
|
||||
var bookmark: Data?
|
||||
var roots: [String]
|
||||
static let key = "media.storage.v1"
|
||||
}
|
||||
|
||||
enum ManagedMediaStorage {
|
||||
static func prepare(_ directory:URL) throws {
|
||||
if directory.path.hasPrefix("/Volumes/") {
|
||||
let components = directory.pathComponents
|
||||
guard components.count >= 3 else { throw LibraryError.message("Choose a folder on the external drive") }
|
||||
let mount = URL(fileURLWithPath:"/Volumes/"+components[2]).resolvingSymlinksInPath()
|
||||
let mounted = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys:nil,options:[]) ?? []
|
||||
guard mounted.contains(where:{$0.resolvingSymlinksInPath().path == mount.path}) else { throw LibraryError.message("Reconnect the media drive at \(mount.path). Imports will wait for that drive.") }
|
||||
}
|
||||
try FileManager.default.createDirectory(at:directory,withIntermediateDirectories:true)
|
||||
guard FileManager.default.isWritableFile(atPath:directory.path) else { throw LibraryError.message("The media folder is not writable: \(directory.path)") }
|
||||
}
|
||||
static func location(_ directory:URL,roots:[String]) -> MediaStorageLocation {
|
||||
let bookmark = (try? directory.bookmarkData(options:.withSecurityScope,includingResourceValuesForKeys:nil,relativeTo:nil)) ?? (try? directory.bookmarkData(options:.minimalBookmark,includingResourceValuesForKeys:nil,relativeTo:nil))
|
||||
return .init(path:directory.path,bookmark:bookmark,roots:Array(Set(roots+[directory.path])).sorted())
|
||||
}
|
||||
static func save(_ location:MediaStorageLocation,to library:Library) throws {
|
||||
try library.setSetting(MediaStorageLocation.key,String(decoding:try JSONEncoder().encode(location),as:UTF8.self))
|
||||
}
|
||||
static func hash(_ url:URL,cancelled:()->Bool) throws -> SHA256.Digest {
|
||||
let file = try FileHandle(forReadingFrom:url);defer { try? file.close() }
|
||||
var result = SHA256()
|
||||
while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };result.update(data:data) }
|
||||
return result.finalize()
|
||||
}
|
||||
static func move(database:URL,roots:[URL],destination:URL,cancelled:()->Bool = {false},progress:(ImportProgress)->Void = {_ in}) throws -> ImportProgress {
|
||||
try prepare(destination)
|
||||
let targetRoot = destination.resolvingSymlinksInPath()
|
||||
let sources = Set(roots.map{$0.resolvingSymlinksInPath()}).filter{$0 != targetRoot}
|
||||
guard !sources.contains(where:{targetRoot.path.hasPrefix($0.path+"/") || $0.path.hasPrefix(targetRoot.path+"/")}) else { throw LibraryError.message("Choose a separate media folder, outside the existing media folders") }
|
||||
let db = try Library(url:database),songs = try db.songs()
|
||||
var files = Set<URL>()
|
||||
for song in songs {
|
||||
for path in [song.audio,song.cdg].compactMap({$0}) {
|
||||
let file = URL(fileURLWithPath:path).resolvingSymlinksInPath()
|
||||
if sources.contains(file.deletingLastPathComponent()) { files.insert(file) }
|
||||
}
|
||||
}
|
||||
// Include managed hash-named remnants of previous cancelled imports.
|
||||
for root in sources where FileManager.default.fileExists(atPath:root.path) {
|
||||
for file in try FileManager.default.contentsOfDirectory(at:root,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey]) {
|
||||
let stem = file.deletingPathExtension().lastPathComponent
|
||||
if [64,129].contains(stem.count),stem.allSatisfy({$0.isHexDigit || $0 == "-"}),["mp3","wav","m4a","mp4","m4v","mov","cdg"].contains(file.pathExtension.lowercased()) {
|
||||
let values = try file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey])
|
||||
if values.isRegularFile == true,values.isSymbolicLink != true { files.insert(file.resolvingSymlinksInPath()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
var state = ImportProgress(total:files.count),verified = [(URL,URL)](),lastReport = Date.distantPast
|
||||
defer { progress(state) }
|
||||
var sourceStamps = [URL:String]()
|
||||
func stamp(_ url:URL) throws -> String { let info = try FileManager.default.attributesOfItem(atPath:url.path);return "\(info[.size] ?? 0)|\(info[.modificationDate] ?? "")|\(info[.systemFileNumber] ?? 0)" }
|
||||
progress(state)
|
||||
for source in files.sorted(by:{$0.path < $1.path}) {
|
||||
if cancelled() { throw CancellationError() }
|
||||
state.current = "Copying "+source.lastPathComponent
|
||||
if Date().timeIntervalSince(lastReport) >= 0.1 { progress(state);lastReport = Date() }
|
||||
let target = targetRoot.appendingPathComponent(source.lastPathComponent)
|
||||
sourceStamps[source] = try stamp(source)
|
||||
let expected = try hash(source,cancelled:cancelled)
|
||||
if FileManager.default.fileExists(atPath:target.path) {
|
||||
guard try hash(target,cancelled:cancelled) == expected else { throw LibraryError.message("A different file already exists at \(target.path). Choose an empty folder or resolve the conflict first.") }
|
||||
state.skipped += 1
|
||||
} else {
|
||||
let temporary = targetRoot.appendingPathComponent(".kjmix-moving-"+UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:temporary) }
|
||||
try FileManager.default.copyItem(at:source,to:temporary)
|
||||
guard try hash(temporary,cancelled:cancelled) == expected else { throw LibraryError.message("Verification failed for \(source.lastPathComponent). The original has been kept.") }
|
||||
try FileManager.default.moveItem(at:temporary,to:target)
|
||||
}
|
||||
verified.append((source,target));state.completed += 1
|
||||
if Date().timeIntervalSince(lastReport) >= 0.1 || state.completed == 1 || state.completed == state.total { progress(state);lastReport = Date() }
|
||||
}
|
||||
if cancelled() { throw CancellationError() }
|
||||
state.current = "Updating library links…";progress(state)
|
||||
let targets = Dictionary(uniqueKeysWithValues:verified.map { ($0.0.path,$0.1.path) })
|
||||
let studioURL = database.deletingLastPathComponent().appendingPathComponent("StudioSettings.json")
|
||||
if let data = try? Data(contentsOf:studioURL),var settings = try JSONSerialization.jsonObject(with:data) as? [String:Any],let background = settings["backgroundPath"] as? String,let target = targets[URL(fileURLWithPath:background).resolvingSymlinksInPath().path] {
|
||||
settings["backgroundPath"] = target;settings.removeValue(forKey:"backgroundBookmark")
|
||||
try JSONSerialization.data(withJSONObject:settings,options:[.sortedKeys]).write(to:studioURL,options:.atomic)
|
||||
}
|
||||
try db.execute("BEGIN IMMEDIATE")
|
||||
do {
|
||||
for song in try db.songs() {
|
||||
if let target = targets[URL(fileURLWithPath:song.audio).resolvingSymlinksInPath().path] { try db.execute("UPDATE songs SET audio=? WHERE id=?",[target,String(song.id)]) }
|
||||
if let graphics = song.cdg,let target = targets[URL(fileURLWithPath:graphics).resolvingSymlinksInPath().path] { try db.execute("UPDATE songs SET cdg=? WHERE id=?",[target,String(song.id)]) }
|
||||
}
|
||||
try save(location(targetRoot,roots:roots.map(\.path)),to:db)
|
||||
try db.execute("COMMIT")
|
||||
} catch { try? db.execute("ROLLBACK");throw error }
|
||||
// Every destination is verified and database links are committed before
|
||||
// any original is removed. A failed/cancelled copy keeps originals intact.
|
||||
var retained = 0
|
||||
for (source,_) in verified {
|
||||
do {
|
||||
guard try stamp(source) == sourceStamps[source] else { retained += 1;continue }
|
||||
try FileManager.default.removeItem(at:source)
|
||||
} catch { retained += 1 }
|
||||
}
|
||||
state.finished = true;state.current = "Media moved to "+targetRoot.path
|
||||
if retained > 0 { state.failure = "Media links were updated, but \(retained) original files could not be removed. You can retry the move to clean them up." }
|
||||
progress(state);return state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -118,6 +118,130 @@ final class KJamsCloneTests: XCTestCase {
|
||||
let song = try XCTUnwrap(try db.song(ids[0]));XCTAssertEqual(URL(fileURLWithPath:song.audio).deletingLastPathComponent().standardizedFileURL,media.standardizedFileURL)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio));XCTAssertNotNil(song.cdg)
|
||||
}
|
||||
func testLegacyEncodedZipNamesImportMatchedAudioAndGraphics() 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 fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
|
||||
// A valid stored ZIP with an unflagged 0xE9 filename byte, matching older
|
||||
// Windows karaoke archives. No commercial media is part of this test.
|
||||
func number(_ value:UInt32,_ count:Int) -> Data { Data((0..<count).map { UInt8(truncatingIfNeeded:value >> (8*$0)) }) }
|
||||
func crc(_ data:Data) -> UInt32 {
|
||||
var value = UInt32.max
|
||||
for byte in data { value ^= UInt32(byte);for _ in 0..<8 { value = value & 1 == 0 ? value >> 1 : (value >> 1) ^ 0xEDB88320 } }
|
||||
return ~value
|
||||
}
|
||||
var zip = Data(),central = Data()
|
||||
for ext in ["mp3","cdg"] {
|
||||
let name = Data("Original - Caf".utf8)+Data([0xE9])+Data(("."+ext).utf8)
|
||||
let contents = try Data(contentsOf:fixtures.appendingPathComponent("Demo."+ext)),size = UInt32(contents.count),checksum = crc(contents),offset = UInt32(zip.count)
|
||||
zip += number(0x04034b50,4)+number(20,2)+number(0,2)+number(0,2)+number(0,2)+number(0,2)
|
||||
zip += number(checksum,4)+number(size,4)+number(size,4)+number(UInt32(name.count),2)+number(0,2)+name+contents
|
||||
central += number(0x02014b50,4)+number(20,2)+number(20,2)+number(0,2)+number(0,2)+number(0,2)+number(0,2)
|
||||
central += number(checksum,4)+number(size,4)+number(size,4)
|
||||
central += number(UInt32(name.count),2)+number(0,2)+number(0,2)
|
||||
central += number(0,2)+number(0,2)+number(0,4)+number(offset,4)
|
||||
central += name
|
||||
}
|
||||
let centralOffset = UInt32(zip.count);zip += central
|
||||
zip += number(0x06054b50,4)+number(0,2)+number(0,2)+number(2,2)+number(2,2)+number(UInt32(central.count),4)+number(centralOffset,4)+number(0,2)
|
||||
let archive = root.appendingPathComponent("Legacy.zip");try zip.write(to:archive)
|
||||
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:root.appendingPathComponent("Media"))
|
||||
let ids = try importer.run([archive],library:db);XCTAssertEqual(ids.count,1)
|
||||
let song = try XCTUnwrap(try db.song(ids[0]));let graphics = try XCTUnwrap(song.cdg)
|
||||
XCTAssertTrue(song.title.contains("Café"),"Legacy filenames must be renamed to Unicode before import")
|
||||
XCTAssertEqual(try Data(contentsOf:URL(fileURLWithPath:song.audio)),try Data(contentsOf:fixtures.appendingPathComponent("Demo.mp3")))
|
||||
XCTAssertEqual(try Data(contentsOf:URL(fileURLWithPath:graphics)),try Data(contentsOf:fixtures.appendingPathComponent("Demo.cdg")))
|
||||
var progress = ImportProgress();_ = try importer.run([archive],library:db,progress:{progress = $0})
|
||||
XCTAssertEqual(progress.skipped,1);XCTAssertEqual(try db.songs().count,1)
|
||||
}
|
||||
@MainActor func testMediaFolderChoicePersistsAndFutureImportsUseIt() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
let model = AppModel(storageURL:root.appendingPathComponent("Library")),external = root.appendingPathComponent("External Media")
|
||||
try model.setMediaDirectory(external)
|
||||
let reopened = AppModel(storageURL:root.appendingPathComponent("Library"))
|
||||
XCTAssertEqual(reopened.managedMediaDirectory.resolvingSymlinksInPath().path,external.resolvingSymlinksInPath().path)
|
||||
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.zip"))
|
||||
reopened.importFiles([fixture])
|
||||
for _ in 0..<300 { if !reopened.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
|
||||
XCTAssertFalse(reopened.isImporting);XCTAssertNil(reopened.importProgress?.failure)
|
||||
let song = try XCTUnwrap(reopened.songs.first)
|
||||
XCTAssertTrue(URL(fileURLWithPath:song.audio).resolvingSymlinksInPath().path.hasPrefix(external.resolvingSymlinksInPath().path+"/"));XCTAssertTrue(URL(fileURLWithPath:try XCTUnwrap(song.cdg)).resolvingSymlinksInPath().path.hasPrefix(external.resolvingSymlinksInPath().path+"/"))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath:root.appendingPathComponent("Library/Media").path))
|
||||
}
|
||||
func testVerifiedMoveUpdatesLinksPreservesSingerPreferencesAndFreesOriginals() 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 database = root.appendingPathComponent("Library.sqlite"),source = root.appendingPathComponent("Media"),target = root.appendingPathComponent("External")
|
||||
let db = try Library(url:database),fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.zip"))
|
||||
let ids = try Importer(media:source).run([fixture],library:db),id = try XCTUnwrap(ids.first)
|
||||
try db.addEntity("singers",name:"Alex");let singer = try XCTUnwrap(try db.entities("singers").first?.id);try db.assignSong(id,singer:singer)
|
||||
try db.savePreference(song:id,singer:singer,value:.init(pitch:3,tempo:1.1,rating:4));try db.rate(id,5)
|
||||
let before = try XCTUnwrap(try db.song(id)),audio = try Data(contentsOf:URL(fileURLWithPath:before.audio))
|
||||
var studio = StudioSettings();studio.backgroundPath = before.audio
|
||||
try JSONEncoder().encode(studio).write(to:root.appendingPathComponent("StudioSettings.json"))
|
||||
let result = try ManagedMediaStorage.move(database:database,roots:[source],destination:target)
|
||||
XCTAssertTrue(result.finished);XCTAssertNil(result.failure);XCTAssertEqual(result.completed,2)
|
||||
let after = try XCTUnwrap(try db.song(id));XCTAssertEqual(after.id,id);XCTAssertEqual(after.rating,5)
|
||||
XCTAssertTrue(after.audio.hasPrefix(target.resolvingSymlinksInPath().path+"/"))
|
||||
let movedSettings = try JSONDecoder().decode(StudioSettings.self,from:Data(contentsOf:root.appendingPathComponent("StudioSettings.json")))
|
||||
XCTAssertEqual(movedSettings.backgroundPath,after.audio)
|
||||
XCTAssertEqual(try Data(contentsOf:URL(fileURLWithPath:after.audio)),audio)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath:before.audio));XCTAssertFalse(FileManager.default.fileExists(atPath:try XCTUnwrap(before.cdg)))
|
||||
XCTAssertEqual(try db.preference(song:id,singer:singer).pitch,3)
|
||||
var progress = ImportProgress();_ = try Importer(media:target).run([fixture],library:db,progress:{progress = $0})
|
||||
XCTAssertEqual(progress.skipped,1)
|
||||
}
|
||||
func testCancelledMoveKeepsOriginalMediaAndDatabaseLinks() 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 database = root.appendingPathComponent("Library.sqlite"),source = root.appendingPathComponent("Media"),target = root.appendingPathComponent("External")
|
||||
let db = try Library(url:database),fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.zip"))
|
||||
_ = try Importer(media:source).run([fixture],library:db);let before = try XCTUnwrap(try db.songs().first)
|
||||
var cancel = false
|
||||
XCTAssertThrowsError(try ManagedMediaStorage.move(database:database,roots:[source],destination:target,cancelled:{cancel},progress:{if $0.completed == 1 { cancel = true }})) { XCTAssertTrue($0 is CancellationError) }
|
||||
XCTAssertEqual(try db.song(before.id)?.audio,before.audio);XCTAssertTrue(FileManager.default.fileExists(atPath:before.audio))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath:try XCTUnwrap(before.cdg)));XCTAssertNil(try db.setting(MediaStorageLocation.key))
|
||||
let retried = try ManagedMediaStorage.move(database:database,roots:[source],destination:target);XCTAssertNil(retried.failure)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath:before.audio))
|
||||
}
|
||||
func testMoveConflictDoesNotOverwriteDestinationOrDeleteOriginal() 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 database = root.appendingPathComponent("Library.sqlite"),source = root.appendingPathComponent("Media"),target = root.appendingPathComponent("External")
|
||||
let db = try Library(url:database),fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
|
||||
_ = try Importer(media:source).run([fixture],library:db);let song = try XCTUnwrap(try db.songs().first)
|
||||
try FileManager.default.createDirectory(at:target,withIntermediateDirectories:true)
|
||||
let conflict = target.appendingPathComponent(URL(fileURLWithPath:song.audio).lastPathComponent),different = Data("Other media".utf8);try different.write(to:conflict)
|
||||
XCTAssertThrowsError(try ManagedMediaStorage.move(database:database,roots:[source],destination:target))
|
||||
XCTAssertEqual(try Data(contentsOf:conflict),different);XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio));XCTAssertEqual(try db.song(song.id)?.audio,song.audio)
|
||||
}
|
||||
func testUnavailableExternalDriveDoesNotCreateALocalFallbackFolder() {
|
||||
let path = URL(fileURLWithPath:"/Volumes/KJMix-Offline-"+UUID().uuidString+"/Media")
|
||||
XCTAssertThrowsError(try ManagedMediaStorage.prepare(path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath:path.path))
|
||||
}
|
||||
@MainActor func testMediaMoveCollectsPreviousImportFolders() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
let model = AppModel(storageURL:root.appendingPathComponent("Library")),a = root.appendingPathComponent("External A"),b = root.appendingPathComponent("External B")
|
||||
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
|
||||
model.importFiles([fixtures.appendingPathComponent("Demo.mp3")])
|
||||
for _ in 0..<300 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
|
||||
model.dismissImport();try model.setMediaDirectory(a)
|
||||
model.importFiles([fixtures.appendingPathComponent("TestTrack.mp3")])
|
||||
for _ in 0..<300 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
|
||||
model.dismissImport();let ids = Set(model.songs.map(\.id));model.moveMedia(to:b)
|
||||
for _ in 0..<300 { if !model.movingMedia { break };try await Task.sleep(nanoseconds:10_000_000) }
|
||||
XCTAssertFalse(model.movingMedia);XCTAssertNil(model.storageProgress?.failure)
|
||||
XCTAssertEqual(Set(model.songs.map(\.id)),ids);XCTAssertEqual(model.songs.count,2)
|
||||
XCTAssertTrue(model.songs.allSatisfy{$0.audio.hasPrefix(b.resolvingSymlinksInPath().path+"/")})
|
||||
XCTAssertEqual(model.managedMediaDirectory.resolvingSymlinksInPath().path,b.resolvingSymlinksInPath().path)
|
||||
}
|
||||
func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] }
|
||||
func testPresetTileXORAndInvalidCoordinates() {
|
||||
var d = CDGDecoder()
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
# KJMix Pro 0.6.7 (build 18) — external media storage and legacy ZIP filenames
|
||||
|
||||
Verified on 4 November 2026.
|
||||
|
||||
- Settings → Imported Media Storage provides Choose Import Folder, Move Existing Media and Show in Finder. The selected folder and permission bookmark persist across launches. The database, singer lists, settings and waveform cache remain in the existing local Application Support folder.
|
||||
- New imports and ZIPs found by folder scans use the selected media folder. An unavailable external drive blocks imports with a reconnect message; no fallback media folder is created on the internal drive.
|
||||
- Background media moves include previous configured import folders. Each copy is checked with SHA-256 before transactional library-path updates. Original files are removed only after commit. Cancellation, conflicts and copy failures preserve originals; retries reuse verified copies. Song IDs, metadata, singer preferences and list relationships are retained. Managed background-track references are updated too.
|
||||
- The supplied SF285 ZIP passed its CRC test; macOS unzip failed with exit 50 on a legacy-encoded accented filename despite free disk space. Validated extraction now uses ditto and renames legacy filename components to Unicode before MP3/CDG matching. Raw central-directory names are read directly, including ZIP64 offsets, because zipinfo translates filename bytes. Original ZIPs are unchanged.
|
||||
- The supplied archive imported all 16 MP3/CDG pairs in an isolated temporary library. No commercial test media was added to the repository or installer. The synthetic legacy-name fixture uses original demo audio and graphics.
|
||||
- 87 XCTest cases passed, zero failures (`/tmp/kjmix-media-storage-final-tests.log`). Coverage includes saved destination/relaunch, media and CDG copies, migration and preference preservation, cancellation/retry, conflicts, missing drives, earlier media folders, background reference updates, corrected legacy filenames and existing import/playback/rotation regressions.
|
||||
- Release signing, Apple notarization, stapling and Gatekeeper results are stored under `build-release/release-verification/KJMixPro-0.6.7-macOS/`. Only the current DMG is retained in `dist`.
|
||||
|
||||
---
|
||||
|
||||
# KJMix Pro 0.6.6 (build 17) — responsive imports and import history
|
||||
|
||||
Verified on 4 November 2026.
|
||||
|
||||
+37
-1
@@ -1,4 +1,40 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
xcodebuild -project "KJMix Pro.xcodeproj" -scheme "KJMix Pro" -configuration Debug -destination 'platform=macOS' -derivedDataPath "${DERIVED_DATA:-$PWD/build}" CODE_SIGNING_ALLOWED=NO build "$@"
|
||||
|
||||
DERIVED_DATA="${DERIVED_DATA:-$PWD/build}"
|
||||
|
||||
echo "=== BUILDING KJMIX PRO ==="
|
||||
|
||||
xcodebuild \
|
||||
-project "KJMix Pro.xcodeproj" \
|
||||
-scheme "KJMix Pro" \
|
||||
-configuration Debug \
|
||||
-destination 'platform=macOS' \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build "$@"
|
||||
|
||||
APP_PATH="$DERIVED_DATA/Build/Products/Debug/KJMix Pro.app"
|
||||
|
||||
if [[ ! -d "$APP_PATH" ]]; then
|
||||
echo "ERROR: Application not found: $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXECUTABLE=$(/usr/libexec/PlistBuddy \
|
||||
-c "Print CFBundleExecutable" \
|
||||
"$APP_PATH/Contents/Info.plist")
|
||||
|
||||
EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/$EXECUTABLE"
|
||||
|
||||
if [[ ! -x "$EXECUTABLE_PATH" ]]; then
|
||||
echo "ERROR: Executable not found: $EXECUTABLE_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== BUILD SUCCESSFUL ==="
|
||||
echo "=== LAUNCHING KJMIX PRO ==="
|
||||
|
||||
exec "$EXECUTABLE_PATH"
|
||||
Reference in New Issue
Block a user