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:
@@ -104,19 +104,22 @@ final class BackupEngine: ObservableObject {
|
|||||||
return try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
|
return try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
// Authoritative base precedence: NAS → local cache → empty.
|
// Use the manifest with more entries as the authoritative base.
|
||||||
// CRITICAL: never overwrite a populated cache with an empty/missing NAS result.
|
// CRITICAL: if the NAS manifest has fewer entries than the local cache (e.g. 1 vs 264),
|
||||||
// If the download fails or the manifest file doesn't exist yet, writing
|
// the cache preserves accumulated history and must win. Overwriting the cache with a
|
||||||
// BackupManifest() to NASManifestCache destroys accumulated history and
|
// smaller NAS manifest destroys history and causes every asset to re-upload.
|
||||||
// causes NAS Archive to drop to 0, making every asset appear as needing backup.
|
let cachedManifest = await NASManifestCache.shared.manifest
|
||||||
|
let cacheCount = cachedManifest?.entries.count ?? 0
|
||||||
|
let nasCount = nasManifest?.entries.count ?? 0
|
||||||
|
|
||||||
let baseManifest: BackupManifest
|
let baseManifest: BackupManifest
|
||||||
if let nas = nasManifest, !nas.entries.isEmpty {
|
if nasCount > 0, nasCount >= cacheCount {
|
||||||
baseManifest = nas
|
baseManifest = nasManifest!
|
||||||
await NASManifestCache.shared.update(nas)
|
await NASManifestCache.shared.update(nasManifest!)
|
||||||
logger.info("Manifest loaded from NAS — \(nas.entries.count) entries")
|
logger.info("Manifest from NAS — \(nasCount) entries")
|
||||||
} else if let cached = await NASManifestCache.shared.manifest, !cached.entries.isEmpty {
|
} else if cacheCount > 0 {
|
||||||
baseManifest = cached
|
baseManifest = cachedManifest!
|
||||||
logger.info("Manifest from cache — \(cached.entries.count) entries (NAS: \(nasManifest == nil ? "download failed" : "file empty"))")
|
logger.info("Manifest from cache — \(cacheCount) entries (NAS had \(nasCount))")
|
||||||
} else {
|
} else {
|
||||||
// Genuinely first backup — no manifest anywhere.
|
// Genuinely first backup — no manifest anywhere.
|
||||||
baseManifest = nasManifest ?? BackupManifest()
|
baseManifest = nasManifest ?? BackupManifest()
|
||||||
@@ -320,6 +323,11 @@ final class BackupEngine: ObservableObject {
|
|||||||
|
|
||||||
signposter.endInterval("UploadLoop", spUpload, "\(uploaded) uploaded, \(failed) failed")
|
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 duration = Date().timeIntervalSince(startDate)
|
||||||
let result = BackupResult(
|
let result = BackupResult(
|
||||||
uploadedCount: uploaded,
|
uploadedCount: uploaded,
|
||||||
|
|||||||
@@ -153,15 +153,17 @@ final class BackupStatusService: NSObject, ObservableObject {
|
|||||||
}.value
|
}.value
|
||||||
let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter)
|
let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter)
|
||||||
let phoneTotal = await LocalPhotoIndex.shared.count(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()
|
var updated = BackupStatusSnapshot()
|
||||||
updated.phoneTotal = phoneTotal
|
updated.phoneTotal = phoneTotal
|
||||||
updated.alreadySafe = min(safe, phoneTotal)
|
updated.alreadySafe = min(safe, phoneTotal)
|
||||||
updated.nasArchiveTotal = index.totalCount
|
updated.nasArchiveTotal = nasTotal
|
||||||
updated.connectionState = .connected
|
updated.connectionState = .connected
|
||||||
updated.lastCheckedAt = Date()
|
updated.lastCheckedAt = Date()
|
||||||
snapshot = updated
|
snapshot = updated
|
||||||
saveSnapshot()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,13 +188,12 @@ final class BackupStatusService: NSObject, ObservableObject {
|
|||||||
log.warning("NAS connect failed: \(reason, privacy: .public) — retaining cached counts")
|
log.warning("NAS connect failed: \(reason, privacy: .public) — retaining cached counts")
|
||||||
refreshError = reason
|
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.
|
// doesn't show 0 when the NAS is temporarily offline.
|
||||||
if let cached = await NASManifestCache.shared.manifest {
|
let cachedCount = (await NASManifestCache.shared.manifest).map { ManifestIndex(manifest: $0).totalCount } ?? 0
|
||||||
let cachedCount = ManifestIndex(manifest: cached).totalCount
|
let cachedDirCount = await NASManifestCache.shared.directoryCount ?? 0
|
||||||
snapshot.nasArchiveTotal = max(snapshot.nasArchiveTotal, cachedCount)
|
snapshot.nasArchiveTotal = max(snapshot.nasArchiveTotal, cachedCount, cachedDirCount)
|
||||||
log.info("NAS offline — restored nasArchiveTotal=\(self.snapshot.nasArchiveTotal) from cache")
|
log.info("NAS offline — restored nasArchiveTotal=\(self.snapshot.nasArchiveTotal) (manifest=\(cachedCount) dir=\(cachedDirCount))")
|
||||||
}
|
|
||||||
snapshot.phoneTotal = phoneTotal
|
snapshot.phoneTotal = phoneTotal
|
||||||
snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal)
|
snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal)
|
||||||
snapshot.connectionState = .offline
|
snapshot.connectionState = .offline
|
||||||
@@ -232,14 +233,17 @@ final class BackupStatusService: NSObject, ObservableObject {
|
|||||||
log.info("Comparison complete — \(safe)/\(phoneTotal) already safe")
|
log.info("Comparison complete — \(safe)/\(phoneTotal) already safe")
|
||||||
|
|
||||||
// ── Step 5: Commit ────────────────────────────────────────────────
|
// ── Step 5: Commit ────────────────────────────────────────────────
|
||||||
|
let dirCount = await NASManifestCache.shared.directoryCount ?? 0
|
||||||
|
let nasTotal = max(index.totalCount, dirCount)
|
||||||
var updated = BackupStatusSnapshot()
|
var updated = BackupStatusSnapshot()
|
||||||
updated.phoneTotal = phoneTotal
|
updated.phoneTotal = phoneTotal
|
||||||
updated.alreadySafe = min(safe, phoneTotal)
|
updated.alreadySafe = min(safe, phoneTotal)
|
||||||
updated.nasArchiveTotal = index.totalCount
|
updated.nasArchiveTotal = nasTotal
|
||||||
updated.connectionState = .connected
|
updated.connectionState = .connected
|
||||||
updated.lastCheckedAt = Date()
|
updated.lastCheckedAt = Date()
|
||||||
snapshot = updated
|
snapshot = updated
|
||||||
saveSnapshot()
|
saveSnapshot()
|
||||||
|
log.info("Reconcile (full): phone=\(phoneTotal) safe=\(safe) NAS=\(nasTotal) (manifest=\(index.totalCount) dir=\(dirCount))")
|
||||||
|
|
||||||
// Invariant check — alreadySafe + needBackup must always equal phoneTotal
|
// Invariant check — alreadySafe + needBackup must always equal phoneTotal
|
||||||
let inv = updated.alreadySafe + updated.needBackup
|
let inv = updated.alreadySafe + updated.needBackup
|
||||||
@@ -281,6 +285,7 @@ final class BackupStatusService: NSObject, ObservableObject {
|
|||||||
}.count
|
}.count
|
||||||
log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — using max")
|
log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — using max")
|
||||||
if dirCount > index.totalCount {
|
if dirCount > index.totalCount {
|
||||||
|
await NASManifestCache.shared.setDirectoryCount(dirCount)
|
||||||
return ManifestIndex(manifest: manifest, overrideTotalCount: 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.
|
// Bootstrap: list NAS directory and build filename-only index in background.
|
||||||
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
|
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
|
||||||
log.info("buildManifestIndex: bootstrap from \(items.count) items in \(conn.remotePath, privacy: .public)")
|
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)
|
ManifestIndex(nasListing: items)
|
||||||
}.value
|
}.value
|
||||||
|
await NASManifestCache.shared.setDirectoryCount(bootstrapIndex.totalCount)
|
||||||
|
return bootstrapIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enumerates a PHFetchResult in batches using autoreleasepool.
|
/// Enumerates a PHFetchResult in batches using autoreleasepool.
|
||||||
@@ -384,13 +391,18 @@ final class BackupStatusService: NSObject, ObservableObject {
|
|||||||
let existingData = try? await transfer.downloadData(at: manifestPath)
|
let existingData = try? await transfer.downloadData(at: manifestPath)
|
||||||
|
|
||||||
// Merge + JSON encode off main actor — can be slow for large manifests.
|
// 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 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
|
var manifest: BackupManifest
|
||||||
if let data = existingData,
|
if nasCount > 0, nasCount >= cacheCount {
|
||||||
let existing = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) {
|
manifest = nasManifest! // NAS at least as current — authoritative
|
||||||
manifest = existing // NAS is authoritative when readable
|
} else if cacheCount > 0 {
|
||||||
} else if let base = cacheBase {
|
manifest = cacheBase! // Cache has more history — preserve it
|
||||||
manifest = base // NAS download failed — preserve history from cache
|
|
||||||
} else {
|
} else {
|
||||||
manifest = BackupManifest()
|
manifest = BackupManifest()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,33 +19,70 @@ actor NASManifestCache {
|
|||||||
private struct CachedEntry: Codable {
|
private struct CachedEntry: Codable {
|
||||||
var manifest: BackupManifest
|
var manifest: BackupManifest
|
||||||
var savedAt: Date
|
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() {
|
private init() {
|
||||||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||||
diskURL = caches.appendingPathComponent("kisani_manifest_cache.json")
|
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),
|
if let data = try? Data(contentsOf: diskURL),
|
||||||
let entry = try? JSONDecoder.kisani.decode(CachedEntry.self, from: data) {
|
let entry = try? JSONDecoder.kisani.decode(CachedEntry.self, from: data) {
|
||||||
cached = entry
|
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.
|
/// Returns the cached manifest without any I/O. Never nil after first reconcile.
|
||||||
var manifest: BackupManifest? { cached?.manifest }
|
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.
|
/// True when the cache is absent or older than the stale threshold.
|
||||||
var isStale: Bool {
|
var isStale: Bool {
|
||||||
guard let c = cached else { return true }
|
guard let c = cached else { return true }
|
||||||
return Date().timeIntervalSince(c.savedAt) > NASManifestCache.staleInterval
|
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) {
|
func update(_ manifest: BackupManifest) {
|
||||||
let entry = CachedEntry(manifest: manifest, savedAt: Date())
|
cached = CachedEntry(manifest: manifest, savedAt: Date(), directoryCount: cached?.directoryCount)
|
||||||
cached = entry
|
|
||||||
log.info("ManifestCache: updated — \(manifest.entries.count) entries")
|
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
|
Task.detached(priority: .utility) { [entry, url = diskURL] in
|
||||||
guard let data = try? JSONEncoder.kisani.encode(entry) else { return }
|
guard let data = try? JSONEncoder.kisani.encode(entry) else { return }
|
||||||
try? data.write(to: url, options: .atomic)
|
try? data.write(to: url, options: .atomic)
|
||||||
|
|||||||
Reference in New Issue
Block a user