Exclude downloaded media from iCloud backups

Downloads lived in Documents/Downloads and were included in device
backups. Mark the directory as excluded on every launch, and run a
one-time migration flagging files downloaded by earlier versions.

Download records stay in UserDefaults and are restored with a backup
while the files are not, so active downloads now reset finished phases
whose files are missing instead of completing with a dangling file.
This commit is contained in:
Arkadiusz Fal
2026-10-03 22:45:07 +02:00
parent 86ff0d6a9e
commit 6025933135
2 changed files with 127 additions and 0 deletions

View File

@@ -92,6 +92,8 @@ extension DownloadManager {
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] ✅ Loaded active downloads: \(activeDownloads.count)"
)
resetActiveDownloadPhasesWithMissingFiles()
} catch let decodingError as DecodingError {
let diagnostics = diagnoseDecodingError(decodingError, dataSize: activeData.count)
LoggingService.shared.logDownloadError(
@@ -196,6 +198,84 @@ extension DownloadManager {
}
}
/// Resets already-finished parts of active downloads whose files are gone from disk,
/// so they are downloaded again instead of completing with a missing file. Happens after
/// restoring a backup: download records live in UserDefaults (backed up) while the files
/// are excluded from backup.
func resetActiveDownloadPhasesWithMissingFiles() {
let directory = downloadsDirectory()
func isMissing(_ path: String?) -> Bool {
guard let path else { return false }
return !fileManager.fileExists(atPath: directory.appendingPathComponent(path).path)
}
var resetCount = 0
for index in activeDownloads.indices {
var download = activeDownloads[index]
var resetPhases: [String] = []
if isMissing(download.localVideoPath) {
download.localVideoPath = nil
download.videoProgress = 0
download.resumeData = nil
resetPhases.append("video")
}
if isMissing(download.localAudioPath) {
download.localAudioPath = nil
download.audioProgress = 0
download.audioResumeData = nil
resetPhases.append("audio")
}
if isMissing(download.localCaptionPath) {
download.localCaptionPath = nil
download.captionProgress = 0
resetPhases.append("caption")
}
if isMissing(download.localStoryboardPath) {
download.localStoryboardPath = nil
download.storyboardProgress = 0
resetPhases.append("storyboard")
}
// Thumbnails are best-effort: drop dangling paths without re-running any phase.
var clearedThumbnails = false
if isMissing(download.localThumbnailPath) {
download.localThumbnailPath = nil
clearedThumbnails = true
}
if isMissing(download.localChannelThumbnailPath) {
download.localChannelThumbnailPath = nil
clearedThumbnails = true
}
guard !resetPhases.isEmpty else {
if clearedThumbnails {
activeDownloads[index] = download
resetCount += 1
}
continue
}
// Leave the thumbnail/complete phase so checkAndCompleteDownload runs the
// remaining phases again instead of waiting on a thumbnail callback.
if download.downloadPhase == .thumbnail || download.downloadPhase == .complete {
download.downloadPhase = .video
}
download.retryCount = 0
activeDownloads[index] = download
recalculateOverallProgress(for: index)
resetCount += 1
LoggingService.shared.warning(
"[Downloads] Reset missing files for active download \(download.videoID): \(resetPhases.joined(separator: ", "))",
category: .downloads
)
}
if resetCount > 0 {
saveDownloadsImmediately()
}
}
// MARK: - Diagnostic Helpers
/// Diagnoses a decoding error and returns detailed diagnostic information.

View File

@@ -105,6 +105,7 @@ final class DownloadManager: NSObject {
override init() {
super.init()
loadDownloads()
excludeExistingDownloadsFromBackupIfNeeded()
// Note: Session setup and resumeInterruptedDownloads are deferred to setDownloadSettings()
// to ensure we have the correct cellular access setting before creating the session.
}
@@ -1058,10 +1059,56 @@ final class DownloadManager: NSObject {
if !fileManager.fileExists(atPath: downloadsURL.path) {
try? fileManager.createDirectory(at: downloadsURL, withIntermediateDirectories: true)
}
// Downloaded media is re-downloadable and can be large; keep it out of iCloud/device backups.
// Excluding the directory excludes everything inside it. Applied on every launch so
// directories created by earlier versions get the flag too.
var excludedURL = downloadsURL
var values = URLResourceValues()
values.isExcludedFromBackup = true
try? excludedURL.setResourceValues(values)
Self._cachedDownloadsDirectory = downloadsURL
return downloadsURL
}
private static let backupExclusionMigrationKey = "downloadsBackupExclusionMigrated"
/// One-time migration: flags every file already in the downloads directory as excluded
/// from iCloud/device backups. Downloads made by earlier versions were backed up.
private func excludeExistingDownloadsFromBackupIfNeeded() {
guard !UserDefaults.standard.bool(forKey: Self.backupExclusionMigrationKey) else { return }
let directory = downloadsDirectory()
Task.detached(priority: .utility) {
let fm = FileManager.default
var flagged = 0
var failed = 0
if let enumerator = fm.enumerator(at: directory, includingPropertiesForKeys: [.isExcludedFromBackupKey]) {
for case var url as URL in enumerator {
if (try? url.resourceValues(forKeys: [.isExcludedFromBackupKey]))?.isExcludedFromBackup == true {
continue
}
var values = URLResourceValues()
values.isExcludedFromBackup = true
do {
try url.setResourceValues(values)
flagged += 1
} catch {
failed += 1
}
}
}
LoggingService.shared.logDownload(
"[Downloads] Backup exclusion migration",
details: "flagged: \(flagged), failed: \(failed)"
)
// Retry on next launch if anything failed.
if failed == 0 {
UserDefaults.standard.set(true, forKey: DownloadManager.backupExclusionMigrationKey)
}
}
}
// MARK: - Media Source Download Helpers
/// Creates a completed download record for a file already downloaded to local storage.