Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,340 @@
//
// 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