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

@@ -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()
}