81 lines
5.9 KiB
Swift
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)
|
|
}
|
|
}
|