diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index b5f6445..7e5f03c 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -104,19 +104,22 @@ final class BackupEngine: ObservableObject { return try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) }.value - // Authoritative base precedence: NAS → local cache → empty. - // CRITICAL: never overwrite a populated cache with an empty/missing NAS result. - // If the download fails or the manifest file doesn't exist yet, writing - // BackupManifest() to NASManifestCache destroys accumulated history and - // causes NAS Archive to drop to 0, making every asset appear as needing backup. + // Use the manifest with more entries as the authoritative base. + // CRITICAL: if the NAS manifest has fewer entries than the local cache (e.g. 1 vs 264), + // the cache preserves accumulated history and must win. Overwriting the cache with a + // smaller NAS manifest destroys history and causes every asset to re-upload. + let cachedManifest = await NASManifestCache.shared.manifest + let cacheCount = cachedManifest?.entries.count ?? 0 + let nasCount = nasManifest?.entries.count ?? 0 + let baseManifest: BackupManifest - if let nas = nasManifest, !nas.entries.isEmpty { - baseManifest = nas - await NASManifestCache.shared.update(nas) - logger.info("Manifest loaded from NAS — \(nas.entries.count) entries") - } else if let cached = await NASManifestCache.shared.manifest, !cached.entries.isEmpty { - baseManifest = cached - logger.info("Manifest from cache — \(cached.entries.count) entries (NAS: \(nasManifest == nil ? "download failed" : "file empty"))") + if nasCount > 0, nasCount >= cacheCount { + baseManifest = nasManifest! + await NASManifestCache.shared.update(nasManifest!) + logger.info("Manifest from NAS — \(nasCount) entries") + } else if cacheCount > 0 { + baseManifest = cachedManifest! + logger.info("Manifest from cache — \(cacheCount) entries (NAS had \(nasCount))") } else { // Genuinely first backup — no manifest anywhere. baseManifest = nasManifest ?? BackupManifest() @@ -320,6 +323,11 @@ final class BackupEngine: ObservableObject { signposter.endInterval("UploadLoop", spUpload, "\(uploaded) uploaded, \(failed) failed") + // Keep directoryCount current without a full rescan. + if uploaded > 0 { + await NASManifestCache.shared.addToDirectoryCount(uploaded) + } + let duration = Date().timeIntervalSince(startDate) let result = BackupResult( uploadedCount: uploaded, diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index 002a622..965a5d7 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -153,15 +153,17 @@ final class BackupStatusService: NSObject, ObservableObject { }.value let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter) let phoneTotal = await LocalPhotoIndex.shared.count(filter: filter) + let dirCount = await NASManifestCache.shared.directoryCount ?? 0 + let nasTotal = max(index.totalCount, dirCount) var updated = BackupStatusSnapshot() updated.phoneTotal = phoneTotal updated.alreadySafe = min(safe, phoneTotal) - updated.nasArchiveTotal = index.totalCount + updated.nasArchiveTotal = nasTotal updated.connectionState = .connected updated.lastCheckedAt = Date() snapshot = updated saveSnapshot() - log.info("Reconcile (cached): phone=\(phoneTotal) safe=\(safe) NAS=\(index.totalCount) needBackup=\(phoneTotal - min(safe, phoneTotal))") + log.info("Reconcile (cached): phone=\(phoneTotal) safe=\(safe) NAS=\(nasTotal) (manifest=\(index.totalCount) dir=\(dirCount))") return } @@ -186,13 +188,12 @@ final class BackupStatusService: NSObject, ObservableObject { log.warning("NAS connect failed: \(reason, privacy: .public) — retaining cached counts") refreshError = reason - // Restore last-known NAS archive count from manifest cache so the dashboard + // Restore last-known NAS archive count so the dashboard // doesn't show 0 when the NAS is temporarily offline. - if let cached = await NASManifestCache.shared.manifest { - let cachedCount = ManifestIndex(manifest: cached).totalCount - snapshot.nasArchiveTotal = max(snapshot.nasArchiveTotal, cachedCount) - log.info("NAS offline — restored nasArchiveTotal=\(self.snapshot.nasArchiveTotal) from cache") - } + let cachedCount = (await NASManifestCache.shared.manifest).map { ManifestIndex(manifest: $0).totalCount } ?? 0 + let cachedDirCount = await NASManifestCache.shared.directoryCount ?? 0 + snapshot.nasArchiveTotal = max(snapshot.nasArchiveTotal, cachedCount, cachedDirCount) + log.info("NAS offline — restored nasArchiveTotal=\(self.snapshot.nasArchiveTotal) (manifest=\(cachedCount) dir=\(cachedDirCount))") snapshot.phoneTotal = phoneTotal snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal) snapshot.connectionState = .offline @@ -232,14 +233,17 @@ final class BackupStatusService: NSObject, ObservableObject { log.info("Comparison complete — \(safe)/\(phoneTotal) already safe") // ── Step 5: Commit ──────────────────────────────────────────────── + let dirCount = await NASManifestCache.shared.directoryCount ?? 0 + let nasTotal = max(index.totalCount, dirCount) var updated = BackupStatusSnapshot() updated.phoneTotal = phoneTotal updated.alreadySafe = min(safe, phoneTotal) - updated.nasArchiveTotal = index.totalCount + updated.nasArchiveTotal = nasTotal updated.connectionState = .connected updated.lastCheckedAt = Date() snapshot = updated saveSnapshot() + log.info("Reconcile (full): phone=\(phoneTotal) safe=\(safe) NAS=\(nasTotal) (manifest=\(index.totalCount) dir=\(dirCount))") // Invariant check — alreadySafe + needBackup must always equal phoneTotal let inv = updated.alreadySafe + updated.needBackup @@ -281,6 +285,7 @@ final class BackupStatusService: NSObject, ObservableObject { }.count log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — using max") if dirCount > index.totalCount { + await NASManifestCache.shared.setDirectoryCount(dirCount) return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount) } } @@ -294,9 +299,11 @@ final class BackupStatusService: NSObject, ObservableObject { // Bootstrap: list NAS directory and build filename-only index in background. let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] log.info("buildManifestIndex: bootstrap from \(items.count) items in \(conn.remotePath, privacy: .public)") - return await Task.detached(priority: .utility) { + let bootstrapIndex = await Task.detached(priority: .utility) { ManifestIndex(nasListing: items) }.value + await NASManifestCache.shared.setDirectoryCount(bootstrapIndex.totalCount) + return bootstrapIndex } /// Enumerates a PHFetchResult in batches using autoreleasepool. @@ -384,13 +391,18 @@ final class BackupStatusService: NSObject, ObservableObject { let existingData = try? await transfer.downloadData(at: manifestPath) // Merge + JSON encode off main actor — can be slow for large manifests. + // Use the manifest with more entries as the merge base so history is never lost + // if the NAS manifest has fewer entries than the local cache (e.g. 1 vs 264). let result: (data: Data, manifest: BackupManifest)? = await Task.detached(priority: .utility) { + let nasManifest = existingData.flatMap { try? JSONDecoder.kisani.decode(BackupManifest.self, from: $0) } + let nasCount = nasManifest?.entries.count ?? 0 + let cacheCount = cacheBase?.entries.count ?? 0 + var manifest: BackupManifest - if let data = existingData, - let existing = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) { - manifest = existing // NAS is authoritative when readable - } else if let base = cacheBase { - manifest = base // NAS download failed — preserve history from cache + if nasCount > 0, nasCount >= cacheCount { + manifest = nasManifest! // NAS at least as current — authoritative + } else if cacheCount > 0 { + manifest = cacheBase! // Cache has more history — preserve it } else { manifest = BackupManifest() } diff --git a/Services/NASManifestCache.swift b/Services/NASManifestCache.swift index 96542f6..65efbea 100644 --- a/Services/NASManifestCache.swift +++ b/Services/NASManifestCache.swift @@ -19,33 +19,70 @@ actor NASManifestCache { private struct CachedEntry: Codable { var manifest: BackupManifest var savedAt: Date + /// Real NAS destination folder file count from the last listDirectory scan. + /// Stored independently from manifest.entries.count — the folder may contain + /// files uploaded by other tools that are not in Kisani's manifest. + var directoryCount: Int? } private init() { let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] diskURL = caches.appendingPathComponent("kisani_manifest_cache.json") - // Load from disk immediately so the first `manifest` access is instant. if let data = try? Data(contentsOf: diskURL), let entry = try? JSONDecoder.kisani.decode(CachedEntry.self, from: data) { cached = entry - log.info("ManifestCache: loaded \(entry.manifest.entries.count) entries from disk") + log.info("ManifestCache: loaded \(entry.manifest.entries.count) entries from disk, dirCount=\(entry.directoryCount.map(String.init) ?? "nil")") } } /// Returns the cached manifest without any I/O. Never nil after first reconcile. var manifest: BackupManifest? { cached?.manifest } + /// Real NAS folder file count from the last listDirectory scan. + /// nil until the first full reconcile completes. + var directoryCount: Int? { cached?.directoryCount } + /// True when the cache is absent or older than the stale threshold. var isStale: Bool { guard let c = cached else { return true } return Date().timeIntervalSince(c.savedAt) > NASManifestCache.staleInterval } - /// Persist a freshly-downloaded manifest. Call after every successful NAS download. + /// Persist a freshly-downloaded manifest. Preserves any existing directoryCount. func update(_ manifest: BackupManifest) { - let entry = CachedEntry(manifest: manifest, savedAt: Date()) - cached = entry + cached = CachedEntry(manifest: manifest, savedAt: Date(), directoryCount: cached?.directoryCount) log.info("ManifestCache: updated — \(manifest.entries.count) entries") + saveToDisk() + } + + /// Store the real NAS destination folder file count (from listDirectory scan). + func setDirectoryCount(_ count: Int) { + if var entry = cached { + entry.directoryCount = count + cached = entry + } else { + cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast, directoryCount: count) + } + log.info("ManifestCache: directoryCount=\(count)") + saveToDisk() + } + + /// Increment the stored directory count after a successful upload batch. + func addToDirectoryCount(_ delta: Int) { + guard delta > 0 else { return } + let next = (cached?.directoryCount ?? 0) + delta + if var entry = cached { + entry.directoryCount = next + cached = entry + } else { + cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast, directoryCount: next) + } + log.info("ManifestCache: directoryCount += \(delta) → \(next)") + saveToDisk() + } + + private func saveToDisk() { + guard let entry = cached else { return } Task.detached(priority: .utility) { [entry, url = diskURL] in guard let data = try? JSONEncoder.kisani.encode(entry) else { return } try? data.write(to: url, options: .atomic)