import Foundation import Vision /// 1ファイル分のキャッシュ。ファイルサイズと更新日時が一致するときだけ再利用する。 struct FingerprintRecord: Codable, Sendable { var fileSize: Int64 var modificationDate: Date var basics: ImageBasics? var featurePrint: FeaturePrintObservation? func matches(_ file: ScannedFile) -> Bool { fileSize == file.fileSize && abs(modificationDate.timeIntervalSince(file.modificationDate)) < 1 } } /// dHash と feature print をディスクに保存し、同じフォルダの再スキャンを速くする。 /// /// feature print は1枚あたり数 KB あるので、JSON ではなくバイナリ plist で持つ。 actor FingerprintCache { private var records: [String: FingerprintRecord] = [:] private var isDirty = false private let fileURL: URL? init(fileURL: URL? = FingerprintCache.defaultFileURL()) { self.fileURL = fileURL self.records = Self.loadRecords(from: fileURL) } static func defaultFileURL() -> URL? { guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil } let directory = caches.appendingPathComponent("Photochk", isDirectory: true) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) return directory.appendingPathComponent("fingerprints.plist") } // MARK: - 読み出し func basics(for files: [ScannedFile]) -> [URL: ImageBasics] { var found: [URL: ImageBasics] = [:] for file in files { if let record = records[file.url.path], record.matches(file), let basics = record.basics { found[file.url] = basics } } return found } func featurePrints(for files: [ScannedFile]) -> [URL: FeaturePrintObservation] { var found: [URL: FeaturePrintObservation] = [:] for file in files { if let record = records[file.url.path], record.matches(file), let print = record.featurePrint { found[file.url] = print } } return found } // MARK: - 書き込み func store(basics: [URL: ImageBasics], for files: [ScannedFile]) { for file in files { guard let value = basics[file.url] else { continue } update(file) { $0.basics = value } } } func store(featurePrints: [URL: FeaturePrintObservation], for files: [ScannedFile]) { for file in files { guard let value = featurePrints[file.url] else { continue } update(file) { $0.featurePrint = value } } } private func update(_ file: ScannedFile, _ body: (inout FingerprintRecord) -> Void) { var record = records[file.url.path] ?? FingerprintRecord( fileSize: file.fileSize, modificationDate: file.modificationDate ) if !record.matches(file) { // ファイルが変わっていたら作り直す record = FingerprintRecord( fileSize: file.fileSize, modificationDate: file.modificationDate ) } body(&record) records[file.url.path] = record isDirty = true } /// 消えたファイルのエントリを捨てる。スキャンのたびに肥大化するのを防ぐ。 func prune(keeping files: [ScannedFile]) { let alive = Set(files.map(\.url.path)) let before = records.count records = records.filter { alive.contains($0.key) } if records.count != before { isDirty = true } } // MARK: - 永続化 func save() { guard isDirty, let fileURL else { return } let encoder = PropertyListEncoder() encoder.outputFormat = .binary guard let data = try? encoder.encode(records) else { return } try? data.write(to: fileURL, options: .atomic) isDirty = false } private static func loadRecords(from fileURL: URL?) -> [String: FingerprintRecord] { guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return [:] } // 形式が変わっていたら黙って捨てる。キャッシュなので作り直せばよい。 return (try? PropertyListDecoder().decode([String: FingerprintRecord].self, from: data)) ?? [:] } }