import Foundation import CryptoKit import AVFoundation struct Importer { let media: URL @discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false }) throws -> [Int64] { try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true) var files = urls var temporary = [URL]() defer { for url in temporary { try? FileManager.default.removeItem(at: url) } } var songIDs = Set() for zip in urls where zip.pathExtension.lowercased() == "zip" { if cancelled() { throw CancellationError() } let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) temporary.append(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) } } } for cdg in files.filter({ $0.pathExtension.lowercased() == "cdg" }) { let siblings = try FileManager.default.contentsOfDirectory(at:cdg.deletingLastPathComponent(),includingPropertiesForKeys:nil) if let audio = siblings.first(where: { ["mp3","wav","m4a","mp4","m4v","mov"].contains($0.pathExtension.lowercased()) && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(cdg.deletingPathExtension().lastPathComponent) == .orderedSame }) { files.append(audio) } } var imported = 0 for audio in Set(files) where ["mp3","wav","m4a","mp4","m4v","mov"].contains(audio.pathExtension.lowercased()) { if cancelled() { throw CancellationError() } let siblings = try FileManager.default.contentsOfDirectory(at: audio.deletingLastPathComponent(), includingPropertiesForKeys: nil) let cdg = siblings.first { $0.pathExtension.lowercased() == "cdg" && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(audio.deletingPathExtension().lastPathComponent) == .orderedSame } let hash = try audioFingerprint(audio,cancelled:cancelled) let copy = copyMedia || temporary.contains { audio.resolvingSymlinksInPath().path.hasPrefix($0.resolvingSymlinksInPath().path+"/") } 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+".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) } guard imported > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") } 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) } }