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>
170 lines
6.7 KiB
Swift
170 lines
6.7 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
|
||
}
|
||
|
||
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
|
||
)
|
||
}
|
||
}
|