From 6025933135652ef17310e9234ad3857000b33389 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 3 Oct 2026 22:45:07 +0200 Subject: [PATCH] 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. --- .../DownloadManager+Persistence.swift | 80 +++++++++++++++++++ .../Services/Downloads/DownloadManager.swift | 47 +++++++++++ 2 files changed, 127 insertions(+) diff --git a/Yattee/Services/Downloads/DownloadManager+Persistence.swift b/Yattee/Services/Downloads/DownloadManager+Persistence.swift index 764ff525..0dacd437 100644 --- a/Yattee/Services/Downloads/DownloadManager+Persistence.swift +++ b/Yattee/Services/Downloads/DownloadManager+Persistence.swift @@ -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. diff --git a/Yattee/Services/Downloads/DownloadManager.swift b/Yattee/Services/Downloads/DownloadManager.swift index 20eefc37..d6ace4c3 100644 --- a/Yattee/Services/Downloads/DownloadManager.swift +++ b/Yattee/Services/Downloads/DownloadManager.swift @@ -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.