mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
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().
341 lines
14 KiB
Swift
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
|