// // 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