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.. [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 ) } }