Files
KMix_Pro/KJamsClone/Importer.swift
T
2026-11-03 12:53:05 +00:00

81 lines
5.9 KiB
Swift

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