167 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|