mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user