fix: pending queue uses NAS directory filenames when manifest is sparse

BackupEngine was building pendingAssets from ManifestIndex(manifest:) which
only knows about files Kisani previously uploaded. When most NAS files were
uploaded by other tools, nearly all phone photos passed the pending filter
and "Back up again" would queue and attempt to re-upload everything.

Fix: step 3b builds a filterIndex from the cached NAS directory filenames
(or a fresh listDirectory scan when cache is unavailable) when the directory
has more files than the manifest. The filename-based filterIndex correctly
identifies only photos whose filenames are absent from the NAS, so "Back up
again" queues exactly the delta — not a full library re-upload.

The baseManifest/baseIndex are unchanged and still drive the manifest write.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-18 23:24:46 +03:00
parent 6c38893374
commit fb6ecc10be

View File

@@ -130,15 +130,47 @@ final class BackupEngine: ObservableObject {
signposter.endInterval("ManifestLoad", spManifest, "\(baseIndex.totalCount) entries")
logger.info("Manifest base: \(baseIndex.totalCount) entries, isFilenameOnly=\(baseIndex.isFilenameOnly)")
// 3b. Build filter index
// When the NAS directory has more files than Kisani's manifest (e.g. files
// uploaded by other tools), a manifest-only index will misidentify those files
// as "not backed up" and re-upload them on every "Back up again" tap.
// Use the broader NAS directory listing as the filter when the manifest is sparse.
let filterIndex: ManifestIndex
let cachedDirCount = await NASManifestCache.shared.directoryCount ?? 0
if baseIndex.totalCount < cachedDirCount {
if let cachedFilenames = await NASManifestCache.shared.directoryFilenames,
cachedFilenames.count > baseIndex.totalCount {
filterIndex = await Task.detached(priority: .utility) {
ManifestIndex(nasFilenames: cachedFilenames)
}.value
logger.info("Filter index: \(cachedFilenames.count) cached NAS filenames (manifest=\(baseIndex.totalCount))")
} else {
// No cached filenames do a fresh listing (connection is already open)
let nasItems = (try? await transfer.listDirectory(at: connection.remotePath)) ?? []
let names = nasItems.compactMap { item -> String? in
guard !item.isDirectory, !item.name.hasPrefix("."),
item.name != BackupManifest.remoteFilename else { return nil }
return item.name
}
await NASManifestCache.shared.setDirectoryFilenames(names)
filterIndex = await Task.detached(priority: .utility) {
ManifestIndex(nasFilenames: names)
}.value
logger.info("Filter index: \(names.count) fresh NAS filenames (manifest=\(baseIndex.totalCount))")
}
} else {
filterIndex = baseIndex
}
try Task.checkCancellation()
// 4. Build pending queue off the main actor
let spFilter = signposter.beginInterval("BuildPendingQueue")
let pendingAssets: [PhotoAsset] = await Task.detached(priority: .userInitiated) {
assets.filter { asset in
!baseIndex.matches(localIdentifier: asset.localIdentifier) &&
!(baseIndex.isFilenameOnly && baseIndex.matches(filename: asset.filename)) &&
!baseIndex.matchesUnclaimed(filename: asset.filename)
!filterIndex.matches(localIdentifier: asset.localIdentifier) &&
!(filterIndex.isFilenameOnly && filterIndex.matches(filename: asset.filename)) &&
!filterIndex.matchesUnclaimed(filename: asset.filename)
}
}.value
signposter.endInterval("BuildPendingQueue", spFilter, "\(pendingAssets.count) pending")