Files
KMix_Pro/KJamsClone/Importer.swift
T
2026-11-04 16:10:32 +00:00

167 lines
11 KiB
Swift

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<Int64>()
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)
}
}