From f64f4cea36f14c3e9d7aa1b2f483a58a122b249d Mon Sep 17 00:00:00 2001 From: yoshikazu Date: Mon, 27 Jul 2026 15:39:22 +0000 Subject: [PATCH] Add fingerprint cache --- Sources/Photochk/Core/FingerprintCache.swift | 118 +++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 Sources/Photochk/Core/FingerprintCache.swift diff --git a/Sources/Photochk/Core/FingerprintCache.swift b/Sources/Photochk/Core/FingerprintCache.swift new file mode 100644 index 0000000..25b96d2 --- /dev/null +++ b/Sources/Photochk/Core/FingerprintCache.swift @@ -0,0 +1,118 @@ +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)) ?? [:] + } +}