diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index ae0625f..b46474f 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -92,76 +92,33 @@ final class BackupEngine: ObservableObject { try Task.checkCancellation() - // ── 3. Load manifest + heal orphaned files ───────────────────────── - // After any interrupted backup, files may sit on NAS without a manifest - // entry. We list the directory and merge any untracked files so the - // gallery and dedup logic reflect actual NAS contents. + // ── 3. Load manifest ─────────────────────────────────────────────── let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)" let spManifest = signposter.beginInterval("ManifestLoad") let manifestData = try? await transfer.downloadData(at: manifestPath) - let nasItems = (try? await transfer.listDirectory(at: connection.remotePath)) ?? [] - - // Decode + heal off main actor (CPU-bound work). - struct ManifestLoadResult { var index: ManifestIndex; var manifest: BackupManifest; var orphanCount: Int } - var loadResult: ManifestLoadResult = await Task.detached(priority: .userInitiated) { - var manifest: BackupManifest - if let data = manifestData, - let decoded = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) { - manifest = decoded - } else { - manifest = BackupManifest.buildFromNASListing(nasItems) - return ManifestLoadResult(index: ManifestIndex(manifest: manifest), manifest: manifest, orphanCount: 0) - } - - // Heal: find NAS files not tracked in the manifest (orphans from - // backups that were interrupted before the manifest write step). - let knownFilenames = Set(manifest.entries.map { $0.filename.lowercased() }) - let mediaExts: Set = ["jpg","jpeg","png","heic","heif","gif","webp","bmp", - "tiff","tif","raw","dng","cr2","nef","arw", - "mp4","mov","m4v","avi","mkv"] - let orphans: [ManifestEntry] = nasItems.compactMap { item in - guard !item.isDirectory, - item.name != BackupManifest.remoteFilename, - !item.name.hasPrefix("."), - mediaExts.contains((item.name as NSString).pathExtension.lowercased()), - !knownFilenames.contains(item.name.lowercased()) else { return nil } - return ManifestEntry( - localIdentifier: "", - filename: item.name, - creationDate: item.modifiedDate, - fileSize: item.size, - remotePath: item.path, - uploadedAt: item.modifiedDate ?? Date() - ) - } - if !orphans.isEmpty { manifest.merge(entries: orphans) } - return ManifestLoadResult(index: ManifestIndex(manifest: manifest), manifest: manifest, orphanCount: orphans.count) + let (baseIndex, baseManifest): (ManifestIndex, BackupManifest) = await Task.detached(priority: .userInitiated) { + guard let data = manifestData, + let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) + else { return (ManifestIndex(nasListing: []), BackupManifest()) } + return (ManifestIndex(manifest: manifest), manifest) }.value - signposter.endInterval("ManifestLoad", spManifest, - "\(loadResult.index.totalCount) entries, orphans=\(loadResult.orphanCount)") - logger.info("Manifest: \(loadResult.index.totalCount) entries (\(loadResult.orphanCount) orphans healed)") + signposter.endInterval("ManifestLoad", spManifest, "\(baseIndex.totalCount) entries") + logger.info("Manifest: \(baseIndex.totalCount) entries") - // If the manifest gained entries from healing, write it back to NAS now - // so the gallery reflects actual NAS contents even before uploads start. - if loadResult.orphanCount > 0, let encoded = try? JSONEncoder.kisani.encode(loadResult.manifest) { - try? await transfer.writeData(encoded, to: manifestPath) - } - - // Always seed local cache with the current (healed) state. - await NASManifestCache.shared.update(loadResult.manifest) + // Seed local cache so BackupStatusService fast-path sees current NAS state. + await NASManifestCache.shared.update(baseManifest) try Task.checkCancellation() // ── 4. Build pending queue off the main actor ────────────────────── let spFilter = signposter.beginInterval("BuildPendingQueue") - let currentIndex = loadResult.index let pendingAssets: [PhotoAsset] = await Task.detached(priority: .userInitiated) { assets.filter { asset in - !currentIndex.matches(localIdentifier: asset.localIdentifier) && - !(currentIndex.isFilenameOnly && currentIndex.matches(filename: asset.filename)) && - !currentIndex.matchesUnclaimed(filename: asset.filename) + !baseIndex.matches(localIdentifier: asset.localIdentifier) && + !(baseIndex.isFilenameOnly && baseIndex.matches(filename: asset.filename)) && + !baseIndex.matchesUnclaimed(filename: asset.filename) } }.value signposter.endInterval("BuildPendingQueue", spFilter, "\(pendingAssets.count) pending") @@ -222,6 +179,16 @@ final class BackupEngine: ObservableObject { queueItems[assetIdx].status = .skipped queueItems[assetIdx].completedAt = Date() } + // Record in manifest even though we didn't upload — the file is on NAS. + // Without this, skipped files stay "pending" forever and trigger backup loops. + manifestEntries.append(ManifestEntry( + localIdentifier: asset.localIdentifier, + filename: asset.filename, + creationDate: asset.creationDate, + fileSize: 0, + remotePath: remotePath, + uploadedAt: Date() + )) logger.debug("Skipped (NAS exists): \(asset.filename, privacy: .public)") continue } @@ -302,7 +269,7 @@ final class BackupEngine: ObservableObject { // Uses the same open connection so no extra connect/disconnect overhead. if uploaded - lastCheckpointAt >= checkpointInterval { lastCheckpointAt = uploaded - var checkpoint = loadResult.manifest + var checkpoint = baseManifest checkpoint.merge(entries: manifestEntries) if let encoded = try? JSONEncoder.kisani.encode(checkpoint) { try? await transfer.writeData(encoded, to: manifestPath)