Files
yattee/Yattee/Services/Downloads/DownloadManager+Persistence.swift
Arkadiusz Fal a734bb47f7 Fix downloads never finishing due to duplicate AppEnvironment
SwiftUI evaluates a @State default-value autoclosure more than once,
keeping only the first instance but still running the side effects of
the discarded ones. Each AppEnvironment() built its own DownloadManager,
and each DownloadManager registered a background URLSession under the
same identifier. The download task started on the surviving instance's
session, but iOS delivered the completion delegate callback to the other
(leaked) instance, whose activeDownloads was empty - so the finished
file was dropped and the progress spinner spun forever at 0 KB.

- Make AppEnvironment a process-wide singleton (static let shared) and
  reference it from the App's @State, guaranteeing exactly one instance
  (and one background session, one DataManager/CloudKit stack).
- Make DownloadManager.setDownloadSettings idempotent: only create the
  background session when none exists, so a re-entrant call never
  invalidateAndCancels the live session and kills in-flight downloads.
  Cellular changes already route through refreshCellularAccessSetting().
2026-05-10 23:46:02 +02:00

341 lines
14 KiB
Swift

//
// DownloadManager+Persistence.swift
// Yattee
//
// JSON persistence and diagnostic helpers for DownloadManager.
//
import Foundation
#if !os(tvOS)
extension DownloadManager {
// MARK: - Persistence
/// Debounced save - waits 1 second before actually saving to reduce frequent encoding.
/// Multiple rapid calls will cancel previous pending saves.
func saveDownloads() {
// Cancel any pending save
saveTask?.cancel()
// Capture current state for saving
let activeData = activeDownloads
let completedData = completedDownloads
saveTask = Task {
// Wait 1 second before actually saving (debounce)
try? await Task.sleep(for: .seconds(1))
guard !Task.isCancelled else { return }
// Perform JSON encoding on background thread
await Task.detached {
let encoder = JSONEncoder()
do {
let active = try encoder.encode(activeData)
UserDefaults.standard.set(active, forKey: "activeDownloads")
} catch {
LoggingService.shared.logDownloadError("Failed to save active downloads", error: error)
}
do {
let completed = try encoder.encode(completedData)
UserDefaults.standard.set(completed, forKey: "completedDownloads")
} catch {
LoggingService.shared.logDownloadError("Failed to save completed downloads", error: error)
}
}.value
}
}
/// Immediate save without debouncing - use for critical state changes.
func saveDownloadsImmediately() {
saveTask?.cancel()
let encoder = JSONEncoder()
do {
let activeData = try encoder.encode(activeDownloads)
UserDefaults.standard.set(activeData, forKey: "activeDownloads")
} catch {
LoggingService.shared.logDownloadError("Failed to save active downloads", error: error)
}
do {
let completedData = try encoder.encode(completedDownloads)
UserDefaults.standard.set(completedData, forKey: "completedDownloads")
} catch {
LoggingService.shared.logDownloadError("Failed to save completed downloads", error: error)
}
}
func loadDownloads() {
let decoder = JSONDecoder()
// ==== ACTIVE DOWNLOADS ====
if let activeData = UserDefaults.standard.data(forKey: "activeDownloads") {
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] Loading active downloads",
details: "Size: \(activeData.count) bytes"
)
// Preview first 100 characters only
if let preview = String(data: activeData.prefix(100), encoding: .utf8) {
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] Data preview",
details: preview + "..."
)
}
do {
activeDownloads = try decoder.decode([Download].self, from: activeData)
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] ✅ Loaded active downloads: \(activeDownloads.count)"
)
} catch let decodingError as DecodingError {
let diagnostics = diagnoseDecodingError(decodingError, dataSize: activeData.count)
LoggingService.shared.logDownloadError(
"[DOWNLOADS DIAGNOSTIC] ❌ DecodingError in active downloads",
error: decodingError
)
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] Error details",
details: diagnostics
)
inspectRawJSON(activeData, key: "activeDownloads")
} catch {
LoggingService.shared.logDownloadError(
"[DOWNLOADS DIAGNOSTIC] ❌ Unexpected error in active downloads",
error: error
)
}
} else {
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] No active downloads in UserDefaults"
)
}
// ==== COMPLETED DOWNLOADS ====
if let completedData = UserDefaults.standard.data(forKey: "completedDownloads") {
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] Loading completed downloads",
details: "Size: \(completedData.count) bytes"
)
// Preview first 100 characters only
if let preview = String(data: completedData.prefix(100), encoding: .utf8) {
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] Data preview",
details: preview + "..."
)
}
do {
completedDownloads = try decoder.decode([Download].self, from: completedData)
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] ✅ Loaded completed downloads: \(completedDownloads.count)"
)
// Validate completed downloads have files on disk
let beforeCount = completedDownloads.count
completedDownloads.removeAll { download in
guard let fileURL = resolveLocalURL(for: download),
fileManager.fileExists(atPath: fileURL.path) else {
LoggingService.shared.warning(
"[Downloads] Removing orphaned record: \(download.videoID) — file missing at \(download.localVideoPath ?? "nil")",
category: .downloads
)
return true
}
return false
}
let removed = beforeCount - completedDownloads.count
if removed > 0 {
LoggingService.shared.warning("[Downloads] Removed \(removed) orphaned download record(s)", category: .downloads)
saveDownloadsImmediately()
}
} catch let decodingError as DecodingError {
let diagnostics = diagnoseDecodingError(decodingError, dataSize: completedData.count)
LoggingService.shared.logDownloadError(
"[DOWNLOADS DIAGNOSTIC] ❌ DecodingError in completed downloads",
error: decodingError
)
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] Error details",
details: diagnostics
)
inspectRawJSON(completedData, key: "completedDownloads")
} catch {
LoggingService.shared.logDownloadError(
"[DOWNLOADS DIAGNOSTIC] ❌ Unexpected error in completed downloads",
error: error
)
}
} else {
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] No completed downloads in UserDefaults"
)
}
// ==== POST-LOAD DIAGNOSTICS ====
Task {
await calculateStorageUsed()
await diagnoseOrphanedFiles()
}
// Rebuild cached Sets for O(1) lookup
downloadingVideoIDs = Set(activeDownloads.map { $0.videoID })
downloadedVideoIDs = Set(completedDownloads.map { $0.videoID })
// Initialize per-video progress dictionary for active downloads
for download in activeDownloads {
downloadProgressByVideo[download.videoID] = DownloadProgressInfo(
progress: download.progress,
isIndeterminate: download.hasIndeterminateProgress
)
}
}
// MARK: - Diagnostic Helpers
/// Diagnoses a decoding error and returns detailed diagnostic information.
func diagnoseDecodingError(_ error: DecodingError, dataSize: Int) -> String {
var diagnostics: [String] = []
switch error {
case .keyNotFound(let key, let context):
diagnostics.append("Missing key: '\(key.stringValue)'")
if !context.codingPath.isEmpty {
diagnostics.append("Coding path: \(context.codingPath.map { $0.stringValue }.joined(separator: ""))")
}
diagnostics.append("Description: \(context.debugDescription)")
case .typeMismatch(let type, let context):
diagnostics.append("Type mismatch: expected \(type)")
if !context.codingPath.isEmpty {
diagnostics.append("Coding path: \(context.codingPath.map { $0.stringValue }.joined(separator: ""))")
}
diagnostics.append("Description: \(context.debugDescription)")
case .valueNotFound(let type, let context):
diagnostics.append("Value not found: expected \(type)")
if !context.codingPath.isEmpty {
diagnostics.append("Coding path: \(context.codingPath.map { $0.stringValue }.joined(separator: ""))")
}
diagnostics.append("Description: \(context.debugDescription)")
case .dataCorrupted(let context):
diagnostics.append("Data corrupted")
if !context.codingPath.isEmpty {
diagnostics.append("Coding path: \(context.codingPath.map { $0.stringValue }.joined(separator: ""))")
}
diagnostics.append("Description: \(context.debugDescription)")
@unknown default:
diagnostics.append("Unknown decoding error: \(error)")
}
diagnostics.append("Data size: \(dataSize) bytes")
return diagnostics.joined(separator: "\n")
}
/// Inspects raw JSON data to identify missing fields and patterns.
func inspectRawJSON(_ data: Data, key: String) {
guard let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] Failed to parse \(key) as JSON array"
)
return
}
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] \(key) contains \(jsonArray.count) items"
)
// Analyze first item's fields
if let firstItem = jsonArray.first {
let fields = firstItem.keys.sorted()
// Only log first 10 fields to save space
let fieldsPreview = fields.prefix(10).joined(separator: ", ") +
(fields.count > 10 ? "... (total: \(fields.count) fields)" : "")
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] First item fields",
details: fieldsPreview
)
// Check for storyboard-related fields
let hasStoryboard = fields.contains("storyboard")
let hasStoryboardPath = fields.contains("localStoryboardPath")
let hasStoryboardProgress = fields.contains("storyboardProgress")
let hasStoryboardTotalBytes = fields.contains("storyboardTotalBytes")
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] Storyboard fields check",
details: """
storyboard: \(hasStoryboard)
localStoryboardPath: \(hasStoryboardPath)
storyboardProgress: \(hasStoryboardProgress)
storyboardTotalBytes: \(hasStoryboardTotalBytes)
"""
)
}
// Check if all items have same fields (pattern detection)
if jsonArray.count > 1 {
let allFieldSets = jsonArray.map { Set($0.keys) }
let allSame = allFieldSets.allSatisfy { $0 == allFieldSets[0] }
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] All \(jsonArray.count) items have identical field structure: \(allSame)"
)
}
}
/// Diagnoses orphaned files by comparing disk storage with loaded downloads.
func diagnoseOrphanedFiles() async {
do {
let downloadsDir = downloadsDirectory()
let contents = try fileManager.contentsOfDirectory(
at: downloadsDir,
includingPropertiesForKeys: [.isDirectoryKey]
)
// Count video files (*.mp4, *.mkv, etc.)
let videoFiles = contents.filter { url in
let ext = url.pathExtension.lowercased()
return ["mp4", "mkv", "webm", "mov", "m4v"].contains(ext)
}
// Count directories (might contain separate video/audio)
let directories = contents.filter { url in
(try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
}
let totalLoaded = activeDownloads.count + completedDownloads.count
LoggingService.shared.logDownload(
"[DOWNLOADS DIAGNOSTIC] Orphaned files analysis",
details: """
Video files on disk: \(videoFiles.count)
Directories on disk: \(directories.count)
Total downloads loaded: \(totalLoaded)
- Active: \(activeDownloads.count)
- Completed: \(completedDownloads.count)
Potential orphans: \(max(0, videoFiles.count + directories.count - totalLoaded))
Storage used: \(ByteCountFormatter.string(fromByteCount: storageUsed, countStyle: .file))
"""
)
} catch {
LoggingService.shared.logDownloadError(
"[DOWNLOADS DIAGNOSTIC] Failed to diagnose orphaned files",
error: error
)
}
}
}
#endif