import Foundation import CryptoKit import AVFoundation struct ImportProgress: Sendable { var completed = 0 var skipped = 0 var total = 0 var current = "Preparing import…" var finished = false var cancelled = false var failure: String? } 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) var state = ImportProgress() var lastReport = Date.distantPast func report(_ current: String,force: Bool = false) { state.current = current if force || Date().timeIntervalSince(lastReport) >= 0.1 { progress(state);lastReport = Date() } } report("Preparing import…",force:true) // Index each directory once: a large folder must not be searched for every song. var siblingCache = [URL:[String:[String:URL]]]() func companions(of url: URL) throws -> [String:URL] { let directory = url.deletingLastPathComponent() if siblingCache[directory] == nil { let entries = try FileManager.default.contentsOfDirectory(at:directory,includingPropertiesForKeys:nil) var index = [String:[String:URL]]() for entry in entries { if cancelled() { throw CancellationError() } index[entry.deletingPathExtension().lastPathComponent.lowercased(),default:[:]][entry.pathExtension.lowercased()] = entry } siblingCache[directory] = index } return siblingCache[directory]?[url.deletingPathExtension().lastPathComponent.lowercased()] ?? [:] } defer { state.total = max(state.total,state.completed);progress(state) } var files = urls var temporary = [URL]() defer { for url in temporary { try? FileManager.default.removeItem(at: url) } } var songIDs = Set() var archives = [(url:URL,signature:String,folder:URL)]() var archiveForFile = [String:URL]() var archiveSongIDs = [URL:[Int64]]() func importSignature(_ audio: URL,cdg: URL? = nil) throws -> String { func signature(_ url: URL) throws -> String { let attrs = try FileManager.default.attributesOfItem(atPath:url.path) return "\(url.standardizedFileURL.path)|\(attrs[.size] ?? 0)|\((attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0)|\((attrs[.creationDate] as? Date)?.timeIntervalSince1970 ?? 0)|\(attrs[.systemFileNumber] ?? 0)" } return try signature(audio)+"|"+(cdg.map { try signature($0) } ?? "no-cdg")+"|copy=\(copyMedia)" } func remember(_ audio:URL,_ signature:String,_ id:Int64) throws { 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" { if cancelled() { throw CancellationError() } let signature = try importSignature(zip) if let existing = try library.importedSongs(path:zip.standardizedFileURL.path,signature:signature) { songIDs.formUnion(existing);state.completed += existing.count;state.skipped += existing.count report("Already imported: "+zip.lastPathComponent,force:state.completed == existing.count);continue } report("Extracting "+zip.lastPathComponent,force:archives.isEmpty) let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) temporary.append(folder) archives.append((zip,signature,folder)) // 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) 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]) 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]) guard values.isSymbolicLink != true else { throw LibraryError.message("ZIP symlinks are unsupported") } if values.isRegularFile == true { files.append(url);archiveForFile[url.resolvingSymlinksInPath().path] = folder } } } for cdg in files.filter({ $0.pathExtension.lowercased() == "cdg" }) { if cancelled() { throw CancellationError() } let matching = try companions(of:cdg) 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 } state.total = audioFiles.count+state.completed report("Importing tracks…",force:true) for audio in audioFiles { if cancelled() { throw CancellationError() } report(audio.lastPathComponent) let cdg = try companions(of:audio)["cdg"] let archive = archiveForFile[audio.resolvingSymlinksInPath().path] let copy = copyMedia || archive != nil let signature = try importSignature(audio,cdg:cdg) if let existing = try library.importedSongs(path:audio.standardizedFileURL.path,signature:signature) { songIDs.formUnion(existing) if let archive { archiveSongIDs[archive,default:[]].append(contentsOf:existing) } state.completed += 1;state.skipped += 1 report("Already imported: "+audio.lastPathComponent,force:state.completed == 1);continue } let hash = try audioFingerprint(audio,cancelled:cancelled) let graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) } let duplicate = try library.songWithFingerprint(hash) var sameGraphics = cdg == nil if let graphicsHash,let stored = duplicate?.cdg,FileManager.default.fileExists(atPath:stored) { sameGraphics = try audioFingerprint(URL(fileURLWithPath:stored),cancelled:cancelled) == graphicsHash } if let existing = duplicate,sameGraphics,FileManager.default.fileExists(atPath:existing.audio), (!copy || URL(fileURLWithPath:existing.audio).deletingLastPathComponent().standardizedFileURL == media.standardizedFileURL) { try remember(audio,signature,existing.id);songIDs.insert(existing.id) state.completed += 1;state.skipped += 1 report("Already in library: "+audio.lastPathComponent,force:state.completed == 1);continue } let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL if copy && !FileManager.default.fileExists(atPath:dest.path) { try FileManager.default.copyItem(at:audio,to:dest) } var graphics: String? if let cdg { let target = copy ? media.appendingPathComponent(hash+"-"+(graphicsHash ?? "graphics")+".cdg") : cdg.standardizedFileURL if copy && !FileManager.default.fileExists(atPath:target.path) { try FileManager.default.copyItem(at:cdg,to:target) } graphics = target.path } imported += 1 let metadata = AVURLAsset(url:audio).commonMetadata func tag(_ key: AVMetadataKey) -> String { metadata.first { $0.commonKey == key }?.stringValue ?? "" } let taggedTitle = tag(.commonKeyTitle) let id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:audioDuration(audio),fingerprint:hash,preferIncomingFile:copy) songIDs.insert(id) try remember(audio,signature,id) state.completed += 1 report(audio.lastPathComponent,force:state.completed == 1) } for archive in archives { let ids = archiveSongIDs[archive.folder] ?? [] try library.rememberImport(path:archive.url.standardizedFileURL.path,signature:archive.signature,songs:ids) } guard imported > 0 || state.skipped > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") } state.finished = true;report("Import complete",force:true) return Array(songIDs) } private func audioFingerprint(_ url: URL,cancelled: () -> Bool) throws -> String { let file = try FileHandle(forReadingFrom:url);defer { try? file.close() } var hasher = SHA256() while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };hasher.update(data:data) } return hasher.finalize().map { String(format:"%02x",$0) }.joined() } private func audioDuration(_ url: URL) -> Double { 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 let out = Pipe(); task.standardOutput = out let err = FileHandle.nullDevice; task.standardError = err 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) } }