Files
Kisani/Services/LocalPhotoIndex.swift
Robin Kutesa 2b2d869ae7 fix: backup loops because isFilenameOnly gate disables filename fallback for mixed manifests
Root cause: ManifestIndex.isFilenameOnly is false as soon as any single entry has a
localIdentifier. All filename-only entries (orphans uploaded in prior sessions without
a saved localIdentifier) are never matched, so their assets appear pending on every
reconcile and the backup re-starts from scratch on every app open.

Fix: add unclaimedFilenames set — filenames of entries with localIdentifier: "".
Use this as a targeted fallback in countSafe / pendingIDs / BackupEngine filter:
an asset not matched by localIdentifier is still considered safe if its filename
matches an unclaimed entry. This is safe because unclaimed entries are specifically
files we know are on NAS but whose identity we can only track by name.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 00:17:16 +03:00

170 lines
6.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
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 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) {
safe += 1
} else if index.isFilenameOnly && index.matches(filename: record.filename) {
// Pure bootstrap mode: every entry is filename-only, match by name.
safe += 1
} else if index.matchesUnclaimed(filename: record.filename) {
// Mixed mode: this specific entry was uploaded without a localIdentifier
// (interrupted session / pre-ID backup). Filename match prevents re-upload.
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.isFilenameOnly && index.matches(filename: record.filename)) ||
index.matchesUnclaimed(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 }
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)
return Record(
localIdentifier: asset.localIdentifier,
filename: filename,
creationDate: asset.creationDate,
mediaType: asset.mediaType.rawValue,
isScreenshot: isScreenshot,
isRAW: isRAW
)
}
}