Files
photochk/Sources/Photochk/Core/FingerprintCache.swift
T
2026-07-27 15:39:22 +00:00

119 lines
4.3 KiB
Swift

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)) ?? [:]
}
}