The root cause: buildManifestIndex was discarding manifest localIdentifiers and switching to pure filename-only matching whenever dirCount > manifest.totalCount (which is always true when the NAS has files from other tools). If filenames didn't match exactly, Already Safe dropped to 0 on every stale-cache reconcile. Fix: introduce ManifestIndex(manifest:nasFilenames:totalCount:) — a hybrid that keeps Kisani's localIdentifiers as primary keys AND adds all NAS directory filenames for filename-fallback. Used in: - buildManifestIndex (full reconcile) - fast path (when dirCount > cached.entries.count, previously gated at <= 1) - BackupEngine step 3b (so "Back up again" only uploads genuinely missing files) Also simplify countSafe / pendingIDs / step-4 filter to always try filename fallback (removes the isFilenameOnly gate that blocked matches when the index had IDs). Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
168 lines
6.5 KiB
Swift
168 lines
6.5 KiB
Swift
import Foundation
|
||
import Photos
|
||
import os.log
|
||
|
||
private let log = Logger(subsystem: "com.albert.nasbackup", category: "LocalPhotoIndex")
|
||
|
||
/// On-device index of phone asset metadata — eliminates full PHFetchResult
|
||
/// enumeration for status counts after the initial build.
|
||
///
|
||
/// Build cost: O(N) on first launch, then zero on every subsequent open.
|
||
/// Update cost: O(changed) via PHPhotoLibraryChangeObserver.
|
||
/// Memory: ~100 bytes × asset count (≈1 MB for 10 k photos).
|
||
actor LocalPhotoIndex {
|
||
static let shared = LocalPhotoIndex()
|
||
|
||
struct Record: Codable, Sendable {
|
||
let localIdentifier: String
|
||
var filename: String
|
||
var creationDate: Date?
|
||
var mediaType: Int // PHAssetMediaType.rawValue
|
||
var isScreenshot: Bool
|
||
var isRAW: Bool
|
||
var isScreenRecording: Bool = false
|
||
}
|
||
|
||
private var records: [String: Record] = [:]
|
||
private let diskURL: URL
|
||
private var needsSave = false
|
||
|
||
private init() {
|
||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||
diskURL = caches.appendingPathComponent("kisani_local_index.json")
|
||
}
|
||
|
||
// MARK: — Lifecycle
|
||
|
||
/// Load from disk if available; otherwise build from PHFetchResult in background.
|
||
/// Safe to call multiple times — subsequent calls are no-ops if records are loaded.
|
||
func loadOrBuild() async {
|
||
guard records.isEmpty else { return }
|
||
if let loaded = await Self.loadFromDisk(url: diskURL) {
|
||
records = loaded
|
||
log.info("LocalPhotoIndex: loaded \(self.records.count) records from disk")
|
||
return
|
||
}
|
||
log.info("LocalPhotoIndex: building from scratch (first launch)")
|
||
await buildFromLibrary()
|
||
}
|
||
|
||
// MARK: — Queries
|
||
|
||
var totalCount: Int { records.count }
|
||
|
||
func count(filter: BackupFilter) -> Int {
|
||
records.values.filter { passes($0, filter: filter) }.count
|
||
}
|
||
|
||
func allRecords() -> [Record] { Array(records.values) }
|
||
|
||
func countSafe(against index: ManifestIndex, filter: BackupFilter) -> Int {
|
||
var safe = 0
|
||
for record in records.values {
|
||
guard passes(record, filter: filter) else { continue }
|
||
if index.matches(localIdentifier: record.localIdentifier) || index.matches(filename: record.filename) {
|
||
safe += 1
|
||
}
|
||
}
|
||
return safe
|
||
}
|
||
|
||
/// Returns localIdentifiers for assets not yet in the manifest (pending upload).
|
||
func pendingIDs(against index: ManifestIndex, filter: BackupFilter) -> [String] {
|
||
records.values.compactMap { record in
|
||
guard passes(record, filter: filter) else { return nil }
|
||
let alreadyBacked = index.matches(localIdentifier: record.localIdentifier) || index.matches(filename: record.filename)
|
||
return alreadyBacked ? nil : record.localIdentifier
|
||
}
|
||
}
|
||
|
||
// MARK: — Incremental updates (from PHPhotoLibraryChangeObserver)
|
||
|
||
func apply(inserted: [PHAsset], removed: [PHAsset], changed: [PHAsset]) {
|
||
for asset in removed { records.removeValue(forKey: asset.localIdentifier) }
|
||
for asset in inserted + changed {
|
||
records[asset.localIdentifier] = Self.makeRecord(asset)
|
||
}
|
||
needsSave = true
|
||
log.debug("LocalPhotoIndex: +\(inserted.count) -\(removed.count) ~\(changed.count)")
|
||
}
|
||
|
||
func flush() async {
|
||
guard needsSave else { return }
|
||
needsSave = false
|
||
let snapshot = records
|
||
let url = diskURL
|
||
await Task.detached(priority: .utility) {
|
||
guard let data = try? JSONEncoder.kisani.encode(snapshot) else { return }
|
||
try? data.write(to: url, options: .atomic)
|
||
}.value
|
||
log.debug("LocalPhotoIndex: flushed \(snapshot.count) records")
|
||
}
|
||
|
||
// MARK: — Private helpers
|
||
|
||
private func passes(_ record: Record, filter: BackupFilter) -> Bool {
|
||
let img = PHAssetMediaType.image.rawValue
|
||
let vid = PHAssetMediaType.video.rawValue
|
||
if !filter.includePhotos && record.mediaType == img { return false }
|
||
if !filter.includeVideos && record.mediaType == vid { return false }
|
||
if !filter.includeScreenshots && record.isScreenshot { return false }
|
||
if !filter.includeRAW && record.isRAW { return false }
|
||
if !filter.includeScreenRecordings && record.isScreenRecording { return false }
|
||
return true
|
||
}
|
||
|
||
private func buildFromLibrary() async {
|
||
let opts = PHFetchOptions()
|
||
opts.includeAssetSourceTypes = [.typeUserLibrary, .typeCloudShared, .typeiTunesSynced]
|
||
let built: [String: Record] = await Task.detached(priority: .userInitiated) {
|
||
let result = PHAsset.fetchAssets(with: opts)
|
||
var map = [String: Record](minimumCapacity: result.count)
|
||
let batchSize = 200
|
||
var i = 0
|
||
while i < result.count {
|
||
autoreleasepool {
|
||
let end = min(i + batchSize, result.count)
|
||
for j in i..<end {
|
||
let asset = result.object(at: j)
|
||
let rec = Self.makeRecord(asset)
|
||
map[rec.localIdentifier] = rec
|
||
}
|
||
i = end
|
||
}
|
||
}
|
||
return map
|
||
}.value
|
||
records = built
|
||
needsSave = true
|
||
await flush()
|
||
log.info("LocalPhotoIndex: built \(self.records.count) records")
|
||
}
|
||
|
||
private nonisolated static func loadFromDisk(url: URL) async -> [String: Record]? {
|
||
await Task.detached(priority: .utility) {
|
||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||
return try? JSONDecoder.kisani.decode([String: Record].self, from: data)
|
||
}.value
|
||
}
|
||
|
||
nonisolated static func makeRecord(_ asset: PHAsset) -> Record {
|
||
let resources = PHAssetResource.assetResources(for: asset)
|
||
let filename = resources.first?.originalFilename
|
||
?? "\(asset.localIdentifier.prefix(8)).jpg"
|
||
let isRAW = resources.contains { $0.type == .alternatePhoto }
|
||
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
|
||
let isScreenRecording = asset.mediaSubtypes.contains(.videoScreenRecording)
|
||
return Record(
|
||
localIdentifier: asset.localIdentifier,
|
||
filename: filename,
|
||
creationDate: asset.creationDate,
|
||
mediaType: asset.mediaType.rawValue,
|
||
isScreenshot: isScreenshot,
|
||
isRAW: isRAW,
|
||
isScreenRecording: isScreenRecording
|
||
)
|
||
}
|
||
}
|