fix: NAS Archive always shows real directory count, not manifest entry count

- NASManifestCache: add directoryCount field (Int?) stored independently from
  manifest.entries.count; add setDirectoryCount(_:) and addToDirectoryCount(_:);
  update(_:) now preserves existing directoryCount so manifest refreshes never
  reset the real folder count to zero
- BackupEngine step 3: switch from "NAS if non-empty, else cache" to
  max(NAS, cache) by entry count — a 1-entry NAS manifest no longer overwrites
  a 264-entry cache, preventing history loss and the Already Safe = 1 regression
- BackupEngine post-upload: call addToDirectoryCount(uploaded) so NAS Archive
  increments correctly (7422 → 7423) without requiring a full directory rescan
- BackupStatusService.buildManifestIndex: call setDirectoryCount(dirCount) in
  both the validity-check path (manifest ≤ 1 entry) and the bootstrap path so
  the real folder count survives across reconcile cycles
- BackupStatusService.writeManifest: same max(NAS, cache) base-selection logic
  to fix Already Safe = 1 after backup ends (merge base was 1-entry NAS instead
  of 264-entry cache)
- BackupStatusService.reconcile (fast + full path): use max(index.totalCount,
  directoryCount) for nasArchiveTotal so Gallery and stats strip reflect the
  real NAS folder count, not just Kisani manifest entries

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-18 18:03:56 +03:00
parent 00e4ecc506
commit 0acf111d32
3 changed files with 89 additions and 32 deletions

View File

@@ -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,