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,383 @@
//
// Download.swift
// Yattee
//
// Represents a video download.
//
import Foundation
/// Represents a video download.
struct Download: Identifiable, Codable, Sendable, Equatable {
let id: UUID
let videoID: VideoID
let title: String
let channelName: String
let channelID: String
let channelThumbnailURL: URL?
let channelSubscriberCount: Int?
/// Channel/author URL for external sources (e.g., bilibili channel page)
let channelURL: URL?
let thumbnailURL: URL?
let duration: TimeInterval
let description: String?
let viewCount: Int?
let likeCount: Int?
let dislikeCount: Int?
let publishedAt: Date?
let publishedText: String?
let streamURL: URL
/// Optional separate audio stream URL (for video-only streams)
let audioStreamURL: URL?
/// Optional caption URL for subtitle download
let captionURL: URL?
/// Language code for the audio track (e.g., "en", "ja")
let audioLanguage: String?
/// Language code for the caption (e.g., "en", "de")
let captionLanguage: String?
/// Custom HTTP headers required for downloading (cookies, referer, etc.)
let httpHeaders: [String: String]?
/// Storyboard metadata for offline seek preview
let storyboard: Storyboard?
var status: DownloadStatus
var progress: Double
var totalBytes: Int64
var downloadedBytes: Int64
var quality: String
var formatID: String
var localVideoPath: String?
var localAudioPath: String?
/// Path to downloaded caption file
var localCaptionPath: String?
/// Directory containing downloaded storyboard sprite sheets
var localStoryboardPath: String?
/// Path to downloaded video thumbnail for offline Now Playing artwork
var localThumbnailPath: String?
/// Path to downloaded channel thumbnail for offline display
var localChannelThumbnailPath: String?
var startedAt: Date?
var completedAt: Date?
var error: String?
var priority: DownloadPriority
var autoDelete: Bool
var resumeData: Data?
/// Resume data for audio download
var audioResumeData: Data?
var retryCount: Int
/// Non-fatal warnings during download (e.g., "Subtitles failed to download")
var warnings: [String]
/// Current phase of multi-file download
var downloadPhase: DownloadPhase
/// Progress for video file (0.0 to 1.0)
var videoProgress: Double
/// Progress for audio file (0.0 to 1.0)
var audioProgress: Double
/// Total bytes for video file
var videoTotalBytes: Int64
/// Total bytes for audio file
var audioTotalBytes: Int64
/// Video codec (e.g., "avc1", "vp9", "av01")
let videoCodec: String?
/// Audio codec (e.g., "mp4a", "opus")
let audioCodec: String?
/// Video bitrate in bits per second
let videoBitrate: Int?
/// Audio bitrate in bits per second
let audioBitrate: Int?
/// Current download speed in bytes per second (not persisted)
var downloadSpeed: Int64 = 0
/// Last time bytes were recorded for speed calculation (not persisted)
var lastSpeedUpdateTime: Date?
/// Last bytes count for speed calculation (not persisted)
var lastSpeedBytes: Int64 = 0
/// Per-stream speed tracking (not persisted)
var videoDownloadSpeed: Int64 = 0
var audioDownloadSpeed: Int64 = 0
var captionDownloadSpeed: Int64 = 0
/// Caption progress (0.0 to 1.0)
var captionProgress: Double = 0
/// Total bytes for caption file
var captionTotalBytes: Int64 = 0
/// Storyboard download speed (not persisted)
var storyboardDownloadSpeed: Int64 = 0
/// Storyboard progress (0.0 to 1.0)
var storyboardProgress: Double = 0
/// Total bytes for all storyboard files
var storyboardTotalBytes: Int64 = 0
/// Bytes downloaded for video (for indeterminate progress display, not persisted)
var videoDownloadedBytes: Int64 = 0
/// Bytes downloaded for audio (for indeterminate progress display, not persisted)
var audioDownloadedBytes: Int64 = 0
/// Bytes downloaded for caption (for indeterminate progress display, not persisted)
var captionDownloadedBytes: Int64 = 0
// MARK: - Size Unknown Detection
/// Whether video stream size is unknown (server didn't provide Content-Length)
var videoSizeUnknown: Bool {
videoTotalBytes <= 0 && videoProgress < 1.0
}
/// Whether audio stream size is unknown
var audioSizeUnknown: Bool {
audioStreamURL != nil && audioTotalBytes <= 0 && audioProgress < 1.0
}
/// Whether caption stream size is unknown
var captionSizeUnknown: Bool {
captionURL != nil && captionTotalBytes <= 0 && captionProgress < 1.0
}
/// Whether any active stream has unknown size (for overall indeterminate display)
var hasIndeterminateProgress: Bool {
let videoIndeterminate = videoProgress < 1.0 && videoTotalBytes <= 0
let audioIndeterminate = audioStreamURL != nil && audioProgress < 1.0 && audioTotalBytes <= 0
return videoIndeterminate || audioIndeterminate
}
// Don't persist speed-related fields
enum CodingKeys: String, CodingKey {
case id, videoID, title, channelName, channelID, channelThumbnailURL, channelSubscriberCount, channelURL
case thumbnailURL, duration, description, viewCount, likeCount, dislikeCount
case publishedAt, publishedText, streamURL, audioStreamURL, captionURL
case audioLanguage, captionLanguage, httpHeaders, storyboard, status, progress, totalBytes, downloadedBytes
case quality, formatID, localVideoPath, localAudioPath, localCaptionPath, localStoryboardPath, localThumbnailPath, localChannelThumbnailPath
case startedAt, completedAt, error, priority, autoDelete, resumeData, audioResumeData
case retryCount, warnings, downloadPhase, videoProgress, audioProgress, videoTotalBytes, audioTotalBytes
case videoCodec, audioCodec, videoBitrate, audioBitrate
case storyboardProgress, storyboardTotalBytes
}
// Custom decoder for backwards compatibility with downloads saved before 'warnings' was added
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
videoID = try container.decode(VideoID.self, forKey: .videoID)
title = try container.decode(String.self, forKey: .title)
channelName = try container.decode(String.self, forKey: .channelName)
channelID = try container.decode(String.self, forKey: .channelID)
channelThumbnailURL = try container.decodeIfPresent(URL.self, forKey: .channelThumbnailURL)
channelSubscriberCount = try container.decodeIfPresent(Int.self, forKey: .channelSubscriberCount)
channelURL = try container.decodeIfPresent(URL.self, forKey: .channelURL)
thumbnailURL = try container.decodeIfPresent(URL.self, forKey: .thumbnailURL)
duration = try container.decode(TimeInterval.self, forKey: .duration)
description = try container.decodeIfPresent(String.self, forKey: .description)
viewCount = try container.decodeIfPresent(Int.self, forKey: .viewCount)
likeCount = try container.decodeIfPresent(Int.self, forKey: .likeCount)
dislikeCount = try container.decodeIfPresent(Int.self, forKey: .dislikeCount)
publishedAt = try container.decodeIfPresent(Date.self, forKey: .publishedAt)
publishedText = try container.decodeIfPresent(String.self, forKey: .publishedText)
streamURL = try container.decode(URL.self, forKey: .streamURL)
audioStreamURL = try container.decodeIfPresent(URL.self, forKey: .audioStreamURL)
captionURL = try container.decodeIfPresent(URL.self, forKey: .captionURL)
audioLanguage = try container.decodeIfPresent(String.self, forKey: .audioLanguage)
captionLanguage = try container.decodeIfPresent(String.self, forKey: .captionLanguage)
httpHeaders = try container.decodeIfPresent([String: String].self, forKey: .httpHeaders)
storyboard = try container.decodeIfPresent(Storyboard.self, forKey: .storyboard)
status = try container.decode(DownloadStatus.self, forKey: .status)
progress = try container.decode(Double.self, forKey: .progress)
totalBytes = try container.decode(Int64.self, forKey: .totalBytes)
downloadedBytes = try container.decode(Int64.self, forKey: .downloadedBytes)
quality = try container.decode(String.self, forKey: .quality)
formatID = try container.decode(String.self, forKey: .formatID)
localVideoPath = try container.decodeIfPresent(String.self, forKey: .localVideoPath)
localAudioPath = try container.decodeIfPresent(String.self, forKey: .localAudioPath)
localCaptionPath = try container.decodeIfPresent(String.self, forKey: .localCaptionPath)
localStoryboardPath = try container.decodeIfPresent(String.self, forKey: .localStoryboardPath)
localThumbnailPath = try container.decodeIfPresent(String.self, forKey: .localThumbnailPath)
localChannelThumbnailPath = try container.decodeIfPresent(String.self, forKey: .localChannelThumbnailPath)
startedAt = try container.decodeIfPresent(Date.self, forKey: .startedAt)
completedAt = try container.decodeIfPresent(Date.self, forKey: .completedAt)
error = try container.decodeIfPresent(String.self, forKey: .error)
priority = try container.decode(DownloadPriority.self, forKey: .priority)
autoDelete = try container.decode(Bool.self, forKey: .autoDelete)
resumeData = try container.decodeIfPresent(Data.self, forKey: .resumeData)
audioResumeData = try container.decodeIfPresent(Data.self, forKey: .audioResumeData)
retryCount = try container.decode(Int.self, forKey: .retryCount)
// Backwards compatibility: 'warnings' was added later, default to empty array
warnings = try container.decodeIfPresent([String].self, forKey: .warnings) ?? []
downloadPhase = try container.decode(DownloadPhase.self, forKey: .downloadPhase)
videoProgress = try container.decode(Double.self, forKey: .videoProgress)
audioProgress = try container.decode(Double.self, forKey: .audioProgress)
videoTotalBytes = try container.decode(Int64.self, forKey: .videoTotalBytes)
audioTotalBytes = try container.decode(Int64.self, forKey: .audioTotalBytes)
videoCodec = try container.decodeIfPresent(String.self, forKey: .videoCodec)
audioCodec = try container.decodeIfPresent(String.self, forKey: .audioCodec)
videoBitrate = try container.decodeIfPresent(Int.self, forKey: .videoBitrate)
audioBitrate = try container.decodeIfPresent(Int.self, forKey: .audioBitrate)
// Backwards compatibility: storyboard progress fields were added later
storyboardProgress = try container.decodeIfPresent(Double.self, forKey: .storyboardProgress) ?? 0
storyboardTotalBytes = try container.decodeIfPresent(Int64.self, forKey: .storyboardTotalBytes) ?? 0
}
init(
video: Video,
quality: String,
formatID: String,
streamURL: URL,
audioStreamURL: URL? = nil,
captionURL: URL? = nil,
audioLanguage: String? = nil,
captionLanguage: String? = nil,
httpHeaders: [String: String]? = nil,
storyboard: Storyboard? = nil,
dislikeCount: Int? = nil,
priority: DownloadPriority = .normal,
autoDelete: Bool = false,
videoCodec: String? = nil,
audioCodec: String? = nil,
videoBitrate: Int? = nil,
audioBitrate: Int? = nil
) {
self.id = UUID()
self.videoID = video.id
self.title = video.title
self.channelName = video.author.name
self.channelID = video.author.id
self.channelThumbnailURL = video.author.thumbnailURL
self.channelSubscriberCount = video.author.subscriberCount
self.channelURL = video.author.url
self.thumbnailURL = video.bestThumbnail?.url
self.duration = video.duration
self.description = video.description
self.viewCount = video.viewCount
self.likeCount = video.likeCount
self.dislikeCount = dislikeCount
self.publishedAt = video.publishedAt
self.publishedText = video.publishedText
self.streamURL = streamURL
self.audioStreamURL = audioStreamURL
self.captionURL = captionURL
self.audioLanguage = audioLanguage
self.captionLanguage = captionLanguage
self.httpHeaders = httpHeaders
self.storyboard = storyboard
self.status = .queued
self.progress = 0
self.totalBytes = 0
self.downloadedBytes = 0
self.quality = quality
self.formatID = formatID
self.localVideoPath = nil
self.localAudioPath = nil
self.localCaptionPath = nil
self.localStoryboardPath = nil
self.localThumbnailPath = nil
self.localChannelThumbnailPath = nil
self.startedAt = nil
self.completedAt = nil
self.error = nil
self.priority = priority
self.autoDelete = autoDelete
self.resumeData = nil
self.audioResumeData = nil
self.retryCount = 0
self.warnings = []
self.downloadPhase = .video
self.videoProgress = 0
self.audioProgress = 0
self.videoTotalBytes = 0
self.audioTotalBytes = 0
self.videoCodec = videoCodec
self.audioCodec = audioCodec
self.videoBitrate = videoBitrate
self.audioBitrate = audioBitrate
}
/// Convert to a Video model for display purposes.
func toVideo() -> Video {
Video(
id: videoID,
title: title,
description: description,
author: Author(
id: channelID,
name: channelName,
thumbnailURL: channelThumbnailURL,
subscriberCount: channelSubscriberCount,
url: channelURL
),
duration: duration,
publishedAt: publishedAt,
publishedText: publishedText,
viewCount: viewCount,
likeCount: likeCount,
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
}
// MARK: - Preview Data
extension Download {
/// A sample completed download for SwiftUI previews.
static var preview: Download {
var download = Download(
video: .preview,
quality: "1080p",
formatID: "137",
streamURL: URL(string: "https://example.com/video.mp4")!,
audioStreamURL: URL(string: "https://example.com/audio.m4a")!,
captionURL: URL(string: "https://example.com/captions.vtt")!,
audioLanguage: "en",
captionLanguage: "en",
videoCodec: "avc1",
audioCodec: "mp4a",
videoBitrate: 5_000_000,
audioBitrate: 128_000
)
download.status = .completed
download.progress = 1.0
download.videoProgress = 1.0
download.audioProgress = 1.0
download.videoTotalBytes = 150_000_000
download.audioTotalBytes = 5_000_000
download.totalBytes = 155_000_000
download.downloadedBytes = 155_000_000
download.localVideoPath = "/Downloads/video.mp4"
download.localAudioPath = "/Downloads/audio.m4a"
download.localCaptionPath = "/Downloads/captions.vtt"
download.completedAt = Date()
return download
}
/// A sample muxed download (no separate audio) for SwiftUI previews.
static var muxedPreview: Download {
var download = Download(
video: .preview,
quality: "720p",
formatID: "22",
streamURL: URL(string: "https://example.com/muxed.mp4")!,
videoCodec: "avc1",
audioCodec: "mp4a",
videoBitrate: 2_500_000,
audioBitrate: 128_000
)
download.status = .completed
download.progress = 1.0
download.videoProgress = 1.0
download.videoTotalBytes = 80_000_000
download.totalBytes = 80_000_000
download.downloadedBytes = 80_000_000
download.localVideoPath = "/Downloads/muxed.mp4"
download.completedAt = Date()
return download
}
}

View File

@@ -0,0 +1,31 @@
//
// DownloadError.swift
// Yattee
//
// Error types for download operations.
//
import Foundation
enum DownloadError: LocalizedError {
case notSupported
case alreadyDownloading
case alreadyDownloaded
case noStreamAvailable
case downloadFailed(String)
var errorDescription: String? {
switch self {
case .notSupported:
return "Downloads are not supported on this platform."
case .alreadyDownloading:
return "This video is already downloading."
case .alreadyDownloaded:
return "This video has already been downloaded."
case .noStreamAvailable:
return "No downloadable stream available."
case .downloadFailed(let reason):
return "Download failed: \(reason)"
}
}
}

View File

@@ -0,0 +1,377 @@
//
// DownloadManager+Assets.swift
// Yattee
//
// Storyboard and thumbnail download operations for DownloadManager.
//
import Foundation
#if !os(tvOS)
extension DownloadManager {
// MARK: - Storyboard Download
/// Start downloading storyboard sprite sheets sequentially
func startStoryboardDownload(downloadID: UUID) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }),
let storyboard = activeDownloads[index].storyboard else {
return
}
// Cancel any existing storyboard task
storyboardTasks[downloadID]?.cancel()
let download = activeDownloads[index]
let videoID = download.videoID.videoID
.replacingOccurrences(of: ":", with: "_")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "\\", with: "_")
let task = Task {
// Create storyboard directory
let storyboardDirName = "\(videoID)_storyboards"
let storyboardDir = downloadsDirectory().appendingPathComponent(storyboardDirName, isDirectory: true)
do {
if !fileManager.fileExists(atPath: storyboardDir.path) {
try fileManager.createDirectory(at: storyboardDir, withIntermediateDirectories: true)
}
} catch {
LoggingService.shared.logDownloadError("Failed to create storyboard directory", error: error)
handleStoryboardCompletion(downloadID: downloadID, success: false)
return
}
// First, try to get VTT from proxy URL to extract actual image URLs
var imageURLs: [URL] = []
if let proxyUrl = storyboard.proxyUrl {
// Construct absolute VTT URL
let vttURL: URL?
if proxyUrl.hasPrefix("http://") || proxyUrl.hasPrefix("https://") {
// Already an absolute URL
vttURL = URL(string: proxyUrl)
} else if let baseURL = storyboard.instanceBaseURL {
// Relative URL - prepend base URL
var baseString = baseURL.absoluteString
if baseString.hasSuffix("/"), proxyUrl.hasPrefix("/") {
baseString = String(baseString.dropLast())
}
vttURL = URL(string: baseString + proxyUrl)
} else {
vttURL = nil
}
if let vttURL {
do {
let (vttData, _) = try await URLSession.shared.data(from: vttURL)
imageURLs = parseVTTForImageURLs(vttData, baseURL: vttURL)
} catch {
// VTT fetch failed, will fall back to direct URLs
}
}
}
// If VTT parsing failed, fall back to direct URLs (may not work if blocked)
if imageURLs.isEmpty {
for sheetIndex in 0..<storyboard.storyboardCount {
if let url = storyboard.directSheetURL(for: sheetIndex) {
imageURLs.append(url)
}
}
}
let totalSheets = imageURLs.count
var completedSheets = 0
// Download each sprite sheet sequentially
for (sheetIndex, sheetURL) in imageURLs.enumerated() {
guard !Task.isCancelled else { return }
let fileName = "sb_\(sheetIndex).jpg"
let fileURL = storyboardDir.appendingPathComponent(fileName)
// Skip if already downloaded
if fileManager.fileExists(atPath: fileURL.path) {
completedSheets += 1
updateStoryboardProgress(downloadID: downloadID, completed: completedSheets, total: totalSheets)
continue
}
do {
let (data, response) = try await URLSession.shared.data(from: sheetURL)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
continue
}
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "unknown"
// Verify it's actually an image
guard contentType.contains("image") || data.count > 50000 else {
continue
}
try data.write(to: fileURL)
completedSheets += 1
updateStoryboardProgress(downloadID: downloadID, completed: completedSheets, total: totalSheets)
} catch {
// Continue with next sheet - non-fatal
}
}
// Complete storyboard phase
let success = completedSheets > 0
finalizeStoryboardDownload(
downloadID: downloadID,
storyboardDirName: storyboardDirName,
success: success
)
}
storyboardTasks[downloadID] = task
}
/// Parse VTT data to extract unique image URLs
/// - Parameters:
/// - data: The VTT file data
/// - baseURL: Base URL for resolving relative image paths
func parseVTTForImageURLs(_ data: Data, baseURL: URL) -> [URL] {
guard let text = String(data: data, encoding: .utf8) else {
return []
}
var uniqueURLs: [URL] = []
var seenURLs: Set<String> = []
let lines = text.components(separatedBy: .newlines)
for line in lines {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
// Skip empty lines, WEBVTT header, and timestamp lines
guard !trimmedLine.isEmpty,
!trimmedLine.hasPrefix("WEBVTT"),
!trimmedLine.contains("-->") else {
continue
}
// Extract URL part (before #xywh fragment)
let urlString = trimmedLine.components(separatedBy: "#").first ?? trimmedLine
// Resolve the URL (handle both absolute and relative URLs)
let resolvedURL: URL?
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
// Already absolute
resolvedURL = URL(string: urlString)
} else if urlString.hasPrefix("/") {
// Relative to host root - extract scheme and host from baseURL
if let scheme = baseURL.scheme, let host = baseURL.host {
let port = baseURL.port.map { ":\($0)" } ?? ""
resolvedURL = URL(string: "\(scheme)://\(host)\(port)\(urlString)")
} else {
resolvedURL = nil
}
} else if !urlString.isEmpty {
// Relative to current path
resolvedURL = URL(string: urlString, relativeTo: baseURL)?.absoluteURL
} else {
resolvedURL = nil
}
// Only add unique URLs
if let url = resolvedURL, !seenURLs.contains(url.absoluteString) {
seenURLs.insert(url.absoluteString)
uniqueURLs.append(url)
}
}
return uniqueURLs
}
/// Update storyboard download progress
func updateStoryboardProgress(downloadID: UUID, completed: Int, total: Int) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
activeDownloads[index].storyboardProgress = Double(completed) / Double(total)
recalculateOverallProgress(for: index)
}
/// Finalize storyboard download phase
func finalizeStoryboardDownload(downloadID: UUID, storyboardDirName: String, success: Bool) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
if success {
activeDownloads[index].localStoryboardPath = storyboardDirName
activeDownloads[index].storyboardProgress = 1.0
// Calculate total storyboard size
let storyboardDir = downloadsDirectory().appendingPathComponent(storyboardDirName)
activeDownloads[index].storyboardTotalBytes = directorySize(at: storyboardDir)
LoggingService.shared.logDownload(
"Storyboard saved: \(activeDownloads[index].videoID.id)",
details: storyboardDirName
)
} else {
// Mark as complete even on failure (non-blocking)
activeDownloads[index].storyboardProgress = 1.0
LoggingService.shared.logDownload(
"Storyboard download failed (non-fatal): \(activeDownloads[index].videoID.id)"
)
}
storyboardTasks.removeValue(forKey: downloadID)
recalculateOverallProgress(for: index)
saveDownloads()
Task {
await checkAndCompleteDownload(downloadID: downloadID)
}
}
/// Handle storyboard completion for error cases
func handleStoryboardCompletion(downloadID: UUID, success: Bool) {
finalizeStoryboardDownload(downloadID: downloadID, storyboardDirName: "", success: success)
}
// MARK: - Thumbnail Download
/// Downloads video and channel thumbnails for offline Now Playing artwork.
/// This is a best-effort operation - failures do not affect download completion.
func startThumbnailDownload(downloadID: UUID) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
// Cancel any existing thumbnail task
thumbnailTasks[downloadID]?.cancel()
let download = activeDownloads[index]
activeDownloads[index].downloadPhase = .thumbnail
let task = Task {
let videoID = sanitizedVideoID(download.videoID)
var thumbnailPath: String?
var channelThumbnailPath: String?
// Download video thumbnail (best quality) - best-effort, ignore failures
if let thumbnailURL = download.thumbnailURL {
thumbnailPath = await downloadThumbnail(
from: thumbnailURL,
filename: "\(videoID)_thumbnail.jpg"
)
}
// Download channel thumbnail - best-effort, ignore failures
if let channelURL = download.channelThumbnailURL {
let channelID = download.channelID
.replacingOccurrences(of: ":", with: "_")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "\\", with: "_")
channelThumbnailPath = await downloadThumbnail(
from: channelURL,
filename: "\(channelID)_avatar.jpg"
)
}
// Always complete, regardless of thumbnail success/failure
finalizeThumbnailDownload(
downloadID: downloadID,
thumbnailPath: thumbnailPath,
channelThumbnailPath: channelThumbnailPath
)
}
thumbnailTasks[downloadID] = task
}
/// Downloads a single thumbnail image.
/// Returns the filename on success, nil on failure. Never throws.
func downloadThumbnail(from url: URL, filename: String) async -> String? {
let fileURL = downloadsDirectory().appendingPathComponent(filename)
// Skip if already downloaded
if fileManager.fileExists(atPath: fileURL.path) {
return filename
}
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
!data.isEmpty else {
LoggingService.shared.debug(
"[Downloads] Thumbnail download returned non-200 or empty: \(filename)",
category: .downloads
)
return nil
}
try data.write(to: fileURL, options: .atomic)
LoggingService.shared.debug(
"[Downloads] Thumbnail saved: \(filename)",
category: .downloads
)
return filename
} catch {
// Log but don't propagate - thumbnail failure is non-fatal
LoggingService.shared.debug(
"[Downloads] Thumbnail download failed (non-fatal): \(filename) - \(error.localizedDescription)",
category: .downloads
)
return nil
}
}
/// Finalizes thumbnail phase and completes the download.
/// Called regardless of whether thumbnails succeeded or failed.
func finalizeThumbnailDownload(
downloadID: UUID,
thumbnailPath: String?,
channelThumbnailPath: String?
) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
// Set paths (will be nil if download failed - that's fine)
activeDownloads[index].localThumbnailPath = thumbnailPath
activeDownloads[index].localChannelThumbnailPath = channelThumbnailPath
thumbnailTasks.removeValue(forKey: downloadID)
if thumbnailPath != nil || channelThumbnailPath != nil {
LoggingService.shared.logDownload(
"Thumbnails saved: \(activeDownloads[index].videoID.id)",
details: "video: \(thumbnailPath ?? "none"), channel: \(channelThumbnailPath ?? "none")"
)
}
saveDownloads()
// Thumbnail phase is complete - finalize the download
// (all other phases were already complete when thumbnail download started)
Task {
await completeMultiFileDownload(downloadID: downloadID)
}
}
// MARK: - Helpers
/// Helper to sanitize video ID for use in filenames
func sanitizedVideoID(_ videoID: VideoID) -> String {
videoID.videoID
.replacingOccurrences(of: ":", with: "_")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "\\", with: "_")
}
}
#endif

View File

@@ -0,0 +1,875 @@
//
// DownloadManager+Execution.swift
// Yattee
//
// Download execution, progress, completion, and error handling for DownloadManager.
//
import Foundation
import SwiftUI
#if !os(tvOS)
extension DownloadManager {
// MARK: - Download Execution
func startNextDownloadIfNeeded() async {
let currentlyDownloading = activeDownloads.filter { $0.status == .downloading }.count
guard currentlyDownloading < maxConcurrentDownloads else { return }
// Find next queued download
guard let nextIndex = activeDownloads.firstIndex(where: { $0.status == .queued }) else {
return
}
let downloadID = activeDownloads[nextIndex].id
activeDownloads[nextIndex].status = .downloading
activeDownloads[nextIndex].startedAt = Date()
saveDownloads()
await startDownload(for: downloadID)
}
func startDownload(for downloadID: UUID) async {
guard urlSession != nil else {
LoggingService.shared.logDownloadError("URLSession not initialized - call setDownloadSettings() first")
return
}
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
LoggingService.shared.logDownloadError("Download not found in activeDownloads: \(downloadID.uuidString)")
return
}
let download = activeDownloads[index]
LoggingService.shared.logDownload(
"[Downloads] startDownload called",
details: "videoID: \(download.videoID.id), videoProgress: \(download.videoProgress), existingTask: \(videoTasks[downloadID] != nil)"
)
// Start video download if not completed
if download.videoProgress < 1.0 {
startStreamDownload(
downloadID: downloadID,
url: download.streamURL,
phase: .video,
resumeData: download.resumeData,
httpHeaders: download.httpHeaders
)
LoggingService.shared.logDownload("Starting video: \(download.videoID.id)")
}
// Start audio download simultaneously if needed
if let audioURL = download.audioStreamURL, download.audioProgress < 1.0 {
startStreamDownload(
downloadID: downloadID,
url: audioURL,
phase: .audio,
resumeData: download.audioResumeData,
httpHeaders: download.httpHeaders
)
LoggingService.shared.logDownload("Starting audio: \(download.videoID.id)")
}
// Start caption download simultaneously if needed
if let captionURL = download.captionURL, download.captionProgress < 1.0 {
startStreamDownload(
downloadID: downloadID,
url: captionURL,
phase: .caption,
resumeData: nil,
httpHeaders: download.httpHeaders
)
LoggingService.shared.logDownload("Starting caption: \(download.videoID.id)")
}
// Start storyboard download if available and not complete
if download.storyboard != nil, download.storyboardProgress < 1.0 {
startStoryboardDownload(downloadID: downloadID)
}
}
/// Start a download task for a specific stream phase
func startStreamDownload(
downloadID: UUID,
url: URL,
phase: DownloadPhase,
resumeData: Data?,
httpHeaders: [String: String]? = nil
) {
let task: URLSessionDownloadTask
if let resumeData {
task = urlSession.downloadTask(withResumeData: resumeData)
} else {
// Starting fresh without resumeData - reset progress for this phase
// to avoid jumping when saved progress conflicts with new URLSession progress
if let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) {
switch phase {
case .video:
activeDownloads[index].videoProgress = 0
case .audio:
activeDownloads[index].audioProgress = 0
case .caption:
activeDownloads[index].captionProgress = 0
case .storyboard, .thumbnail, .complete:
break
}
recalculateOverallProgress(for: index)
}
var request = URLRequest(url: url)
request.setValue(SettingsManager.currentUserAgent(), forHTTPHeaderField: "User-Agent")
// Add server-provided headers (cookies, referer, etc.)
if let httpHeaders {
for (key, value) in httpHeaders {
request.setValue(value, forHTTPHeaderField: key)
}
}
task = urlSession.downloadTask(with: request)
}
task.taskDescription = "\(downloadID.uuidString):\(phase.rawValue)"
setTaskInfo(downloadID, phase: phase, forTask: task.taskIdentifier)
// Store task in appropriate dictionary
switch phase {
case .video:
videoTasks[downloadID] = task
case .audio:
audioTasks[downloadID] = task
case .caption:
captionTasks[downloadID] = task
case .storyboard, .thumbnail, .complete:
break
}
LoggingService.shared.logDownload(
"[Downloads] Task started (\(phase))",
details: "taskID: \(task.taskIdentifier), URL: \(url.host ?? "unknown")"
)
task.resume()
}
// MARK: - Progress Handling
func handleDownloadProgress(downloadID: UUID, phase: DownloadPhase, bytesWritten: Int64, totalBytes: Int64) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
let download = activeDownloads[index]
let previousProgress = download.progress
let now = Date()
// Update phase-specific progress and speed
switch phase {
case .video:
activeDownloads[index].videoTotalBytes = totalBytes
activeDownloads[index].videoDownloadedBytes = bytesWritten
if totalBytes > 0 {
activeDownloads[index].videoProgress = Double(bytesWritten) / Double(totalBytes)
}
// Speed calculation for video
if let lastTime = download.lastSpeedUpdateTime {
let timeDelta = now.timeIntervalSince(lastTime)
if timeDelta >= 0.5 {
let bytesDelta = bytesWritten - download.lastSpeedBytes
let speed = Int64(Double(bytesDelta) / timeDelta)
activeDownloads[index].videoDownloadSpeed = max(0, speed)
activeDownloads[index].downloadSpeed = max(0, speed)
activeDownloads[index].lastSpeedUpdateTime = now
activeDownloads[index].lastSpeedBytes = bytesWritten
}
} else {
activeDownloads[index].lastSpeedUpdateTime = now
activeDownloads[index].lastSpeedBytes = bytesWritten
}
case .audio:
activeDownloads[index].audioTotalBytes = totalBytes
activeDownloads[index].audioDownloadedBytes = bytesWritten
if totalBytes > 0 {
activeDownloads[index].audioProgress = Double(bytesWritten) / Double(totalBytes)
}
// Speed calculation for audio - use separate tracking
let speed = calculateSpeed(currentBytes: bytesWritten, phase: phase, download: download)
activeDownloads[index].audioDownloadSpeed = speed
case .caption:
activeDownloads[index].captionTotalBytes = totalBytes
activeDownloads[index].captionDownloadedBytes = bytesWritten
if totalBytes > 0 {
activeDownloads[index].captionProgress = Double(bytesWritten) / Double(totalBytes)
}
// Speed calculation for caption - use separate tracking
let speed = calculateSpeed(currentBytes: bytesWritten, phase: phase, download: download)
activeDownloads[index].captionDownloadSpeed = speed
case .storyboard, .thumbnail, .complete:
// Storyboard and thumbnail progress are handled separately
break
}
// Calculate combined overall progress
recalculateOverallProgress(for: index)
// Update per-video progress dictionary for efficient thumbnail observation.
// SwiftUI only re-renders views that access this specific video's progress.
let updatedDownload = activeDownloads[index]
downloadProgressByVideo[updatedDownload.videoID] = DownloadProgressInfo(
progress: updatedDownload.progress,
isIndeterminate: updatedDownload.hasIndeterminateProgress
)
// Detect progress reset (indicates download restarted)
let newProgress = activeDownloads[index].progress
if previousProgress > 0.5 && newProgress < 0.1 {
LoggingService.shared.logDownload(
"[Downloads] Progress reset: \(download.videoID.id)",
details: "Was: \(Int(previousProgress * 100))%, Now: \(Int(newProgress * 100))%, Phase: \(phase), bytesWritten: \(bytesWritten), totalBytes: \(totalBytes)"
)
}
}
/// Calculate speed for a stream phase (simplified, updates every call)
func calculateSpeed(currentBytes: Int64, phase: DownloadPhase, download: Download) -> Int64 {
// For audio/caption, we use a simplified speed calculation
// since they run in parallel with video
let previousBytes: Int64
switch phase {
case .audio:
previousBytes = download.audioTotalBytes > 0 ? Int64(download.audioProgress * Double(download.audioTotalBytes)) : 0
case .caption:
previousBytes = download.captionTotalBytes > 0 ? Int64(download.captionProgress * Double(download.captionTotalBytes)) : 0
default:
return 0
}
// Approximate speed based on progress difference (rough estimate)
let delta = currentBytes - previousBytes
return max(0, delta * 2) // Multiply by 2 since updates happen ~every 0.5s
}
/// Recalculate overall progress from all phases
func recalculateOverallProgress(for index: Int) {
let download = activeDownloads[index]
let hasAudio = download.audioStreamURL != nil
let hasCaption = download.captionURL != nil
let hasStoryboard = download.storyboard != nil
// Weights: video ~79%, audio ~19%, caption ~1%, storyboard ~1%
let storyboardWeight: Double = hasStoryboard ? 0.01 : 0.0
let captionWeight: Double = hasCaption ? 0.01 : 0.0
let audioWeight: Double = hasAudio ? 0.19 : 0.0
let videoWeight: Double = 1.0 - audioWeight - captionWeight - storyboardWeight
var overallProgress = download.videoProgress * videoWeight
if hasAudio {
overallProgress += download.audioProgress * audioWeight
}
if hasCaption {
overallProgress += download.captionProgress * captionWeight
}
if hasStoryboard {
overallProgress += download.storyboardProgress * storyboardWeight
}
activeDownloads[index].progress = min(overallProgress, 0.99) // Cap at 99% until truly complete
}
// MARK: - Completion Handling
/// Result of file operations performed on background thread
private struct FileOperationResult {
let success: Bool
let fileName: String?
let fileSize: Int64
let error: Error?
let errorMessage: String?
let contentPreview: String?
}
func handleDownloadCompletion(downloadID: UUID, phase: DownloadPhase, location: URL, expectedBytes: Int64 = 0) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
let download = activeDownloads[index]
let minSize = phase == .caption ? Int64(10) : minimumValidFileSize
// Get expected bytes - use header value if available, otherwise use tracked value from progress
let effectiveExpectedBytes: Int64
if expectedBytes > 0 {
effectiveExpectedBytes = expectedBytes
} else {
switch phase {
case .video: effectiveExpectedBytes = download.videoTotalBytes
case .audio: effectiveExpectedBytes = download.audioTotalBytes
case .caption: effectiveExpectedBytes = download.captionTotalBytes
default: effectiveExpectedBytes = 0
}
}
// Generate filename based on phase (pure computation, safe on main thread)
let sanitizedVideoID = download.videoID.videoID
.replacingOccurrences(of: ":", with: "_")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "\\", with: "_")
let fileName: String
switch phase {
case .video:
let fileExtension = download.formatID.isEmpty ? "mp4" : download.formatID
fileName = "\(sanitizedVideoID)_\(download.quality).\(fileExtension)"
case .audio:
let audioExt = download.audioStreamURL?.pathExtension.isEmpty == false
? download.audioStreamURL!.pathExtension
: "m4a"
let langSuffix = download.audioLanguage.map { "_\($0)" } ?? ""
fileName = "\(sanitizedVideoID)_audio\(langSuffix).\(audioExt)"
case .caption:
let langSuffix = download.captionLanguage ?? "unknown"
let captionExt = download.captionURL?.pathExtension.isEmpty == false
? download.captionURL!.pathExtension
: "vtt"
fileName = "\(sanitizedVideoID)_\(langSuffix).\(captionExt)"
case .storyboard, .thumbnail, .complete:
return
}
let destinationURL = downloadsDirectory().appendingPathComponent(fileName)
let videoID = download.videoID.id
// Build URL string for error logging
let urlString: String
switch phase {
case .video: urlString = download.streamURL.absoluteString
case .audio: urlString = download.audioStreamURL?.absoluteString ?? "none"
case .caption: urlString = download.captionURL?.absoluteString ?? "none"
case .storyboard, .thumbnail, .complete: urlString = "n/a"
}
// Move ALL file operations to background thread
Task.detached { [weak self] in
// Capture self at start of closure to satisfy Swift 6 concurrency
let manager = self
let fm = FileManager.default
var result: FileOperationResult
do {
// Check file size
let attrs = try fm.attributesOfItem(atPath: location.path)
let downloadedSize = attrs[.size] as? Int64 ?? 0
if downloadedSize < minSize {
// Read small preview for error logging (max 1KB, only for small error files)
var contentPreview: String?
if downloadedSize < 2048 {
if let data = try? Data(contentsOf: location, options: .mappedIfSafe),
let preview = String(data: data.prefix(300), encoding: .utf8) {
contentPreview = preview.replacingOccurrences(of: "\n", with: " ").prefix(200).description
}
}
try? fm.removeItem(at: location)
result = FileOperationResult(
success: false,
fileName: nil,
fileSize: 0,
error: DownloadError.downloadFailed("Downloaded file is empty or corrupted (\(downloadedSize) bytes)"),
errorMessage: "Got \(downloadedSize) bytes, expected >= \(minSize)",
contentPreview: contentPreview
)
} else if effectiveExpectedBytes > 0 {
// Validate downloaded size against expected size
// If we received less than 90% of expected bytes, the download is incomplete
let minimumAcceptableRatio: Double = 0.90
let actualRatio = Double(downloadedSize) / Double(effectiveExpectedBytes)
if actualRatio < minimumAcceptableRatio {
// Download is incomplete - treat as error for auto-retry
let percentReceived = Int(actualRatio * 100)
try? fm.removeItem(at: location)
result = FileOperationResult(
success: false,
fileName: nil,
fileSize: 0,
error: DownloadError.downloadFailed("Download incomplete: received \(percentReceived)% of expected data"),
errorMessage: "Got \(downloadedSize) bytes, expected \(effectiveExpectedBytes) bytes (\(percentReceived)%)",
contentPreview: nil
)
} else {
// Downloaded size is within acceptable range
// Remove existing file if present
if fm.fileExists(atPath: destinationURL.path) {
try fm.removeItem(at: destinationURL)
}
try fm.moveItem(at: location, to: destinationURL)
// Get final file size
let finalAttrs = try fm.attributesOfItem(atPath: destinationURL.path)
let finalSize = finalAttrs[.size] as? Int64 ?? 0
result = FileOperationResult(
success: true,
fileName: fileName,
fileSize: finalSize,
error: nil,
errorMessage: nil,
contentPreview: nil
)
}
} else {
// Remove existing file if present
if fm.fileExists(atPath: destinationURL.path) {
try fm.removeItem(at: destinationURL)
}
try fm.moveItem(at: location, to: destinationURL)
// Get final file size
let finalAttrs = try fm.attributesOfItem(atPath: destinationURL.path)
let finalSize = finalAttrs[.size] as? Int64 ?? 0
result = FileOperationResult(
success: true,
fileName: fileName,
fileSize: finalSize,
error: nil,
errorMessage: nil,
contentPreview: nil
)
}
} catch {
result = FileOperationResult(
success: false,
fileName: nil,
fileSize: 0,
error: error,
errorMessage: error.localizedDescription,
contentPreview: nil
)
}
// Capture result before entering MainActor to satisfy Swift 6 concurrency
let finalResult = result
// Update state on main thread - guard self before MainActor.run for Swift 6
guard let manager else { return }
await MainActor.run {
manager.handleFileOperationResult(
result: finalResult,
downloadID: downloadID,
phase: phase,
videoID: videoID,
fileName: fileName,
urlString: urlString
)
}
}
}
/// Handle the result of background file operations (runs on main thread)
private func handleFileOperationResult(
result: FileOperationResult,
downloadID: UUID,
phase: DownloadPhase,
videoID: String,
fileName: String,
urlString: String
) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
if result.success, let savedFileName = result.fileName {
// Update the appropriate path and progress based on phase
switch phase {
case .video:
activeDownloads[index].localVideoPath = savedFileName
activeDownloads[index].resumeData = nil
activeDownloads[index].videoProgress = 1.0
activeDownloads[index].videoTotalBytes = result.fileSize
videoTasks.removeValue(forKey: downloadID)
LoggingService.shared.logDownload("Video saved: \(videoID)", details: savedFileName)
case .audio:
activeDownloads[index].localAudioPath = savedFileName
activeDownloads[index].audioResumeData = nil
activeDownloads[index].audioProgress = 1.0
activeDownloads[index].audioTotalBytes = result.fileSize
audioTasks.removeValue(forKey: downloadID)
LoggingService.shared.logDownload("Audio saved: \(videoID)", details: savedFileName)
case .caption:
activeDownloads[index].localCaptionPath = savedFileName
activeDownloads[index].captionProgress = 1.0
captionTasks.removeValue(forKey: downloadID)
LoggingService.shared.logDownload("Caption saved: \(videoID)", details: savedFileName)
case .storyboard, .thumbnail, .complete:
break
}
recalculateOverallProgress(for: index)
saveDownloads()
Task {
await checkAndCompleteDownload(downloadID: downloadID)
}
} else {
// Handle error
if let errorMessage = result.errorMessage {
// Determine if this is an incomplete download or a corrupt/small file
let isIncompleteDownload = errorMessage.contains("expected") && errorMessage.contains("%")
let logCategory = isIncompleteDownload ? "Download incomplete" : "File too small"
LoggingService.shared.logDownloadError(
"[Downloads] \(logCategory) (\(phase)): \(videoID)",
error: DownloadError.downloadFailed(errorMessage)
)
}
LoggingService.shared.logDownload(
"[Downloads] Failed URL (\(phase))",
details: String(urlString.prefix(150))
)
if let preview = result.contentPreview {
LoggingService.shared.logDownloadError("[Downloads] Content preview: \(preview)")
}
handleDownloadError(
downloadID: downloadID,
phase: phase,
error: result.error ?? DownloadError.downloadFailed("Unknown error")
)
}
}
/// Check if all required phases are complete and finalize the download
func checkAndCompleteDownload(downloadID: UUID) async {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
let download = activeDownloads[index]
// Check if video is complete
let videoComplete = download.localVideoPath != nil
// Check if audio is complete (or not needed)
let audioComplete = download.audioStreamURL == nil || download.localAudioPath != nil
// Check if caption is complete (or not needed, or skipped due to error)
let captionComplete = download.captionURL == nil ||
download.localCaptionPath != nil ||
download.captionProgress >= 1.0
// Check if storyboard is complete (or not needed, or marked complete via progress)
let storyboardComplete = download.storyboard == nil || download.storyboardProgress >= 1.0
// If video, audio, caption, and storyboard are complete, start thumbnail download
// Thumbnail download is best-effort and won't block completion
if videoComplete && audioComplete && captionComplete && storyboardComplete {
// Check if thumbnail phase needs to be started
if download.downloadPhase != .thumbnail && download.downloadPhase != .complete {
// Start thumbnail download (will call checkAndCompleteDownload again when done)
startThumbnailDownload(downloadID: downloadID)
return
}
// Thumbnail phase is complete (either finished or was started and callback returned)
if download.downloadPhase == .thumbnail {
// Still downloading thumbnails, wait for finalizeThumbnailDownload to call us back
return
}
// All phases complete
await completeMultiFileDownload(downloadID: downloadID)
}
}
/// Complete a multi-file download after all phases are done
func completeMultiFileDownload(downloadID: UUID) async {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
var download = activeDownloads[index]
let baseDir = downloadsDirectory()
// Capture file paths for background calculation
let videoPath = download.localVideoPath
let audioPath = download.localAudioPath
let captionPath = download.localCaptionPath
let storyboardPath = download.localStoryboardPath
let thumbnailPath = download.localThumbnailPath
let channelThumbnailPath = download.localChannelThumbnailPath
// Calculate total bytes on background thread to avoid blocking UI
let totalBytes = await Task.detached {
let fm = FileManager.default
var total: Int64 = 0
if let videoPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(videoPath).path, fileManager: fm)
}
if let audioPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(audioPath).path, fileManager: fm)
}
if let captionPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(captionPath).path, fileManager: fm)
}
if let storyboardPath {
total += Self.directorySizeBackground(at: baseDir.appendingPathComponent(storyboardPath), fileManager: fm)
}
if let thumbnailPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(thumbnailPath).path, fileManager: fm)
}
if let channelThumbnailPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(channelThumbnailPath).path, fileManager: fm)
}
return total
}.value
// Check if this was a batch download and remove from tracking
let wasBatchDownload = batchDownloadIDs.contains(downloadID)
if wasBatchDownload {
batchDownloadIDs.remove(downloadID)
}
// Update download state on main thread
download.downloadPhase = .complete
download.status = .completed
download.completedAt = Date()
download.progress = 1.0
download.resumeData = nil
download.audioResumeData = nil
download.retryCount = 0
download.totalBytes = totalBytes
download.downloadedBytes = totalBytes
// Move to completed
activeDownloads.remove(at: index)
completedDownloads.insert(download, at: 0)
// Update cached Sets
downloadingVideoIDs.remove(download.videoID)
downloadedVideoIDs.insert(download.videoID)
downloadProgressByVideo.removeValue(forKey: download.videoID)
// Clean up any remaining task references (should already be removed)
videoTasks.removeValue(forKey: downloadID)
audioTasks.removeValue(forKey: downloadID)
captionTasks.removeValue(forKey: downloadID)
storyboardTasks.removeValue(forKey: downloadID)
thumbnailTasks.removeValue(forKey: downloadID)
var details = "Video: \(download.localVideoPath ?? "none")"
if download.localAudioPath != nil { details += ", Audio: \(download.localAudioPath!)" }
if download.localCaptionPath != nil { details += ", Caption: \(download.localCaptionPath!)" }
if download.localStoryboardPath != nil { details += ", Storyboard: \(download.localStoryboardPath!)" }
if download.localThumbnailPath != nil { details += ", Thumbnail: \(download.localThumbnailPath!)" }
LoggingService.shared.logDownload("Completed: \(download.videoID.id)", details: details)
// Only show individual toast for non-batch downloads
if !wasBatchDownload {
if download.warnings.isEmpty {
toastManager?.show(
category: .download,
title: String(localized: "toast.download.completed.title"),
subtitle: download.title,
icon: "checkmark.circle.fill",
iconColor: .green,
autoDismissDelay: 3.0
)
} else {
// Partial success toast with warning
toastManager?.show(
category: .download,
title: String(localized: "toast.download.completedWithWarnings.title"),
subtitle: download.title,
icon: "exclamationmark.triangle.fill",
iconColor: .orange,
autoDismissDelay: 4.0
)
}
}
saveDownloadsImmediately()
await calculateStorageUsed()
await startNextDownloadIfNeeded()
}
// MARK: - Error Handling
func handleDownloadError(downloadID: UUID, phase: DownloadPhase, error: Error) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
let download = activeDownloads[index]
// Remove task for this phase
switch phase {
case .video:
videoTasks.removeValue(forKey: downloadID)
case .audio:
audioTasks.removeValue(forKey: downloadID)
case .caption:
captionTasks.removeValue(forKey: downloadID)
case .storyboard, .thumbnail, .complete:
break
}
// Check if we should retry
if download.retryCount < maxRetryAttempts {
activeDownloads[index].retryCount += 1
// Clear resume data for the failed phase
switch phase {
case .video:
activeDownloads[index].resumeData = nil
case .audio:
activeDownloads[index].audioResumeData = nil
case .caption, .storyboard, .thumbnail, .complete:
break
}
// Build URL string for logging
let retryUrlString: String
switch phase {
case .video: retryUrlString = String(download.streamURL.absoluteString.prefix(100))
case .audio: retryUrlString = String((download.audioStreamURL?.absoluteString ?? "none").prefix(100))
case .caption: retryUrlString = String((download.captionURL?.absoluteString ?? "none").prefix(100))
case .storyboard, .thumbnail, .complete: retryUrlString = "n/a"
}
let delay = retryDelays[min(activeDownloads[index].retryCount - 1, retryDelays.count - 1)]
LoggingService.shared.logDownload(
"[Downloads] Retry \(activeDownloads[index].retryCount)/\(maxRetryAttempts + 1) (\(phase)): \(download.videoID.id)",
details: "Error: \(error.localizedDescription), retrying in \(Int(delay))s"
)
LoggingService.shared.logDownload(
"[Downloads] Retry URL",
details: retryUrlString
)
saveDownloads()
// Schedule retry with delay - only retry the failed phase
Task {
try? await Task.sleep(for: .seconds(delay))
// Verify download still exists and needs retry
guard let currentIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }),
activeDownloads[currentIndex].status == .downloading else {
return
}
let currentDownload = activeDownloads[currentIndex]
// Retry only the failed phase
switch phase {
case .video:
startStreamDownload(
downloadID: downloadID,
url: currentDownload.streamURL,
phase: .video,
resumeData: nil,
httpHeaders: currentDownload.httpHeaders
)
case .audio:
if let audioURL = currentDownload.audioStreamURL {
startStreamDownload(
downloadID: downloadID,
url: audioURL,
phase: .audio,
resumeData: nil,
httpHeaders: currentDownload.httpHeaders
)
}
case .caption:
if let captionURL = currentDownload.captionURL {
startStreamDownload(
downloadID: downloadID,
url: captionURL,
phase: .caption,
resumeData: nil,
httpHeaders: currentDownload.httpHeaders
)
}
case .storyboard:
// Storyboard retry - restart the storyboard download task
startStoryboardDownload(downloadID: downloadID)
case .thumbnail:
// Thumbnail retry - restart the thumbnail download task
startThumbnailDownload(downloadID: downloadID)
case .complete:
break
}
}
} else {
// Max retries exceeded
switch phase {
case .video, .audio:
// Critical phases - fail the entire download
// Remove from batch tracking if this was a batch download
batchDownloadIDs.remove(downloadID)
// Cancel other ongoing tasks for this download
if let task = videoTasks[downloadID] {
task.cancel()
videoTasks.removeValue(forKey: downloadID)
}
if let task = audioTasks[downloadID] {
task.cancel()
audioTasks.removeValue(forKey: downloadID)
}
if let task = captionTasks[downloadID] {
task.cancel()
captionTasks.removeValue(forKey: downloadID)
}
activeDownloads[index].status = .failed
activeDownloads[index].error = "\(phase.rawValue): \(error.localizedDescription)"
LoggingService.shared.logDownloadError(
"Failed after \(maxRetryAttempts) retries (\(phase)): \(download.videoID.id)",
error: error
)
saveDownloads()
Task {
await startNextDownloadIfNeeded()
}
case .caption:
// Non-critical phase - mark as skipped and continue
activeDownloads[index].captionProgress = 1.0 // Mark complete (skipped)
activeDownloads[index].warnings.append("Subtitles failed to download")
captionTasks.removeValue(forKey: downloadID)
LoggingService.shared.logDownload(
"Caption download failed (non-fatal): \(download.videoID.id)",
details: "Error: \(error.localizedDescription)"
)
saveDownloads()
Task {
await checkAndCompleteDownload(downloadID: downloadID)
}
case .storyboard, .thumbnail, .complete:
// Already handled gracefully elsewhere
break
}
}
}
}
#endif

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

View File

@@ -0,0 +1,279 @@
//
// DownloadManager+Storage.swift
// Yattee
//
// Storage management and orphan detection for DownloadManager.
//
import Foundation
#if !os(tvOS)
extension DownloadManager {
// MARK: - Storage Management
/// Calculate total storage used by downloads (file operations run on background thread).
@discardableResult
func calculateStorageUsed() async -> Int64 {
// Capture download paths on main thread
let downloads = completedDownloads
let baseDir = downloadsDirectory()
// Calculate on background thread to avoid blocking UI
let total = await Task.detached {
let fm = FileManager.default
var total: Int64 = 0
for download in downloads {
// Count video file
if let videoPath = download.localVideoPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(videoPath).path, fileManager: fm)
}
// Count audio file
if let audioPath = download.localAudioPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(audioPath).path, fileManager: fm)
}
// Count caption file
if let captionPath = download.localCaptionPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(captionPath).path, fileManager: fm)
}
// Count storyboard directory
if let storyboardPath = download.localStoryboardPath {
total += Self.directorySizeBackground(at: baseDir.appendingPathComponent(storyboardPath), fileManager: fm)
}
}
return total
}.value
// Update published property on main thread
storageUsed = total
return total
}
/// Background-safe file size calculation (nonisolated static method)
nonisolated static func fileSizeBackground(at path: String, fileManager: FileManager) -> Int64 {
do {
let attrs = try fileManager.attributesOfItem(atPath: path)
return attrs[.size] as? Int64 ?? 0
} catch {
return 0
}
}
/// Background-safe directory size calculation (nonisolated static method)
nonisolated static func directorySizeBackground(at url: URL, fileManager: FileManager) -> Int64 {
guard fileManager.fileExists(atPath: url.path) else { return 0 }
var size: Int64 = 0
if let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) {
for case let fileURL as URL in enumerator {
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
size += Int64(fileSize)
}
}
}
return size
}
/// Get available storage on device.
func getAvailableStorage() -> Int64 {
do {
let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let values = try documentsURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
return values.volumeAvailableCapacityForImportantUsage ?? 0
} catch {
return 0
}
}
/// Calculate total size of a directory
func directorySize(at url: URL) -> Int64 {
guard fileManager.fileExists(atPath: url.path) else { return 0 }
var size: Int64 = 0
if let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) {
for case let fileURL as URL in enumerator {
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
size += Int64(fileSize)
}
}
}
return size
}
// MARK: - Orphan Detection & Cleanup
/// Represents an orphaned file not tracked by any download record.
struct OrphanedFile: Identifiable {
let id = UUID()
let url: URL
let fileName: String
let size: Int64
}
/// Scan the downloads directory for orphaned files not tracked by any download record.
/// Returns a list of orphaned files with their sizes.
func findOrphanedFiles() -> (orphanedFiles: [OrphanedFile], totalOrphanedSize: Int64, trackedSize: Int64, actualDiskSize: Int64) {
let downloadsDir = downloadsDirectory()
// Build set of all tracked file paths
var trackedPaths = Set<String>()
var trackedSize: Int64 = 0
// From completed downloads
for download in completedDownloads {
if let videoPath = download.localVideoPath {
trackedPaths.insert(videoPath)
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(videoPath).path)
}
if let audioPath = download.localAudioPath {
trackedPaths.insert(audioPath)
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(audioPath).path)
}
if let captionPath = download.localCaptionPath {
trackedPaths.insert(captionPath)
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(captionPath).path)
}
if let storyboardPath = download.localStoryboardPath {
trackedPaths.insert(storyboardPath)
trackedSize += directorySize(at: downloadsDir.appendingPathComponent(storyboardPath))
}
if let thumbnailPath = download.localThumbnailPath {
trackedPaths.insert(thumbnailPath)
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(thumbnailPath).path)
}
if let channelThumbnailPath = download.localChannelThumbnailPath {
trackedPaths.insert(channelThumbnailPath)
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(channelThumbnailPath).path)
}
}
// From active downloads (in progress)
for download in activeDownloads {
if let videoPath = download.localVideoPath {
trackedPaths.insert(videoPath)
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(videoPath).path)
}
if let audioPath = download.localAudioPath {
trackedPaths.insert(audioPath)
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(audioPath).path)
}
if let captionPath = download.localCaptionPath {
trackedPaths.insert(captionPath)
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(captionPath).path)
}
if let storyboardPath = download.localStoryboardPath {
trackedPaths.insert(storyboardPath)
trackedSize += directorySize(at: downloadsDir.appendingPathComponent(storyboardPath))
}
if let thumbnailPath = download.localThumbnailPath {
trackedPaths.insert(thumbnailPath)
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(thumbnailPath).path)
}
if let channelThumbnailPath = download.localChannelThumbnailPath {
trackedPaths.insert(channelThumbnailPath)
trackedSize += fileSize(at: downloadsDir.appendingPathComponent(channelThumbnailPath).path)
}
}
// Scan directory for all files and directories
var orphanedFiles: [OrphanedFile] = []
var totalOrphanedSize: Int64 = 0
var actualDiskSize: Int64 = 0
do {
let contents = try fileManager.contentsOfDirectory(at: downloadsDir, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey])
for fileURL in contents {
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey])
let fileName = fileURL.lastPathComponent
let isDirectory = resourceValues.isDirectory == true
let size: Int64
if isDirectory {
size = directorySize(at: fileURL)
} else {
size = Int64(resourceValues.fileSize ?? 0)
}
actualDiskSize += size
if !trackedPaths.contains(fileName) {
orphanedFiles.append(OrphanedFile(url: fileURL, fileName: fileName, size: size))
totalOrphanedSize += size
}
}
} catch {
LoggingService.shared.logDownloadError("Failed to scan downloads directory for orphans", error: error)
}
return (orphanedFiles, totalOrphanedSize, trackedSize, actualDiskSize)
}
/// Log detailed diagnostic information about orphaned files.
/// Call this to debug storage discrepancy issues.
func logOrphanDiagnostics() {
let (orphanedFiles, totalOrphanedSize, trackedSize, actualDiskSize) = findOrphanedFiles()
LoggingService.shared.logDownload(
"=== DOWNLOAD STORAGE DIAGNOSTICS ===",
details: """
Tracked downloads: \(completedDownloads.count) completed, \(activeDownloads.count) active
Tracked file size: \(formatBytes(trackedSize))
Actual disk usage: \(formatBytes(actualDiskSize))
Orphaned files: \(orphanedFiles.count) (\(formatBytes(totalOrphanedSize)))
Discrepancy: \(formatBytes(actualDiskSize - trackedSize))
"""
)
if !orphanedFiles.isEmpty {
LoggingService.shared.logDownload("=== ORPHANED FILES ===")
for file in orphanedFiles.sorted(by: { $0.size > $1.size }) {
LoggingService.shared.logDownload(
"Orphan: \(file.fileName)",
details: "Size: \(formatBytes(file.size))"
)
}
}
}
/// Delete all orphaned files not tracked by any download record.
/// Returns the number of files deleted and total bytes freed.
@discardableResult
func deleteOrphanedFiles() async -> (deletedCount: Int, bytesFreed: Int64) {
let (orphanedFiles, _, _, _) = findOrphanedFiles()
var deletedCount = 0
var bytesFreed: Int64 = 0
for file in orphanedFiles {
do {
try fileManager.removeItem(at: file.url)
deletedCount += 1
bytesFreed += file.size
LoggingService.shared.logDownload("Deleted orphan: \(file.fileName)", details: "Freed: \(formatBytes(file.size))")
} catch {
LoggingService.shared.logDownloadError("Failed to delete orphan: \(file.fileName)", error: error)
}
}
if deletedCount > 0 {
LoggingService.shared.logDownload(
"Orphan cleanup complete",
details: "Deleted \(deletedCount) files, freed \(formatBytes(bytesFreed))"
)
await calculateStorageUsed()
}
return (deletedCount, bytesFreed)
}
func formatBytes(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
}
#endif

View File

@@ -0,0 +1,215 @@
//
// DownloadManager+URLSession.swift
// Yattee
//
// URLSessionDownloadDelegate conformance for DownloadManager.
//
import Foundation
#if !os(tvOS)
// MARK: - URLSessionDownloadDelegate
extension DownloadManager: URLSessionDownloadDelegate {
/// Parse task description to extract downloadID and phase
/// Format: "UUID:phase" e.g., "550e8400-e29b-41d4-a716-446655440000:video"
private nonisolated func parseTaskDescription(_ description: String?) -> (downloadID: UUID, phase: DownloadPhase)? {
guard let description else { return nil }
let parts = description.split(separator: ":")
guard parts.count == 2,
let uuid = UUID(uuidString: String(parts[0])),
let phase = DownloadPhase(rawValue: String(parts[1])) else {
// Fallback: try parsing as just UUID for backward compatibility
if let uuid = UUID(uuidString: description) {
return (uuid, .video)
}
return nil
}
return (uuid, phase)
}
nonisolated func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
// Try task ID storage first, then fallback to task description
let taskInfo = getTaskInfo(forTask: downloadTask.taskIdentifier) ??
parseTaskDescription(downloadTask.taskDescription)
guard let taskInfo else {
Task { @MainActor in
LoggingService.shared.logDownloadError("Download completed but no download ID found for task \(downloadTask.taskIdentifier)")
}
return
}
// Log file size at completion with expected size info
let fileSize = (try? FileManager.default.attributesOfItem(atPath: location.path)[.size] as? Int64) ?? 0
let expectedSizeFromCountBytes = downloadTask.countOfBytesExpectedToReceive
Task { @MainActor in
var details = "taskID: \(downloadTask.taskIdentifier), fileSize: \(fileSize) bytes"
if expectedSizeFromCountBytes > 0 && expectedSizeFromCountBytes != NSURLSessionTransferSizeUnknown {
let ratio = expectedSizeFromCountBytes > 0 ? Double(fileSize) / Double(expectedSizeFromCountBytes) * 100 : 0
details += ", expected: \(expectedSizeFromCountBytes) bytes (\(Int(ratio))%)"
}
LoggingService.shared.logDownload(
"[Downloads] didFinishDownloadingTo (\(taskInfo.phase))",
details: details
)
}
// Extract expected content length from response headers
// Check both Content-Length and X-Expected-Content-Length (used by Yattee Server)
var expectedBytes: Int64 = 0
if let httpResponse = downloadTask.response as? HTTPURLResponse {
let statusCode = httpResponse.statusCode
Task { @MainActor in
LoggingService.shared.logDownload(
"[Downloads] HTTP \(statusCode) (\(taskInfo.phase))",
details: "URL: \(httpResponse.url?.host ?? "unknown")"
)
}
// Check for non-success status codes
if statusCode < 200 || statusCode >= 300 {
Task { @MainActor in
LoggingService.shared.logDownloadError(
"[Downloads] Server error HTTP \(statusCode) (\(taskInfo.phase))"
)
self.handleDownloadError(
downloadID: taskInfo.downloadID,
phase: taskInfo.phase,
error: DownloadError.downloadFailed("Server returned HTTP \(statusCode)")
)
}
return
}
// Extract expected size from headers
// X-Expected-Content-Length is used by Yattee Server when Content-Length is unavailable
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
let size = Int64(contentLength), size > 0 {
expectedBytes = size
} else if let expectedSize = httpResponse.value(forHTTPHeaderField: "X-Expected-Content-Length"),
let size = Int64(expectedSize), size > 0 {
expectedBytes = size
}
}
// Copy file to temp location since the original will be deleted
let originalExtension = location.pathExtension.isEmpty ? "tmp" : location.pathExtension
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + "." + originalExtension)
do {
try FileManager.default.copyItem(at: location, to: tempURL)
let capturedExpectedBytes = expectedBytes
Task { @MainActor in
self.handleDownloadCompletion(downloadID: taskInfo.downloadID, phase: taskInfo.phase, location: tempURL, expectedBytes: capturedExpectedBytes)
}
} catch {
Task { @MainActor in
LoggingService.shared.logDownloadError("Failed to copy downloaded file", error: error)
self.handleDownloadError(downloadID: taskInfo.downloadID, phase: taskInfo.phase, error: error)
}
}
}
nonisolated func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
let taskInfo = getTaskInfo(forTask: downloadTask.taskIdentifier) ??
parseTaskDescription(downloadTask.taskDescription)
guard let taskInfo else { return }
// EARLY THROTTLE: Skip ALL processing if update is too soon (saves CPU)
// This check must be FIRST to avoid unnecessary work on 100s of callbacks/sec
let now = Date()
let lastUpdate = lastProgressUpdateStorage.read { $0[taskInfo.downloadID] } ?? .distantPast
guard now.timeIntervalSince(lastUpdate) >= 0.3 else { return }
lastProgressUpdateStorage.write { $0[taskInfo.downloadID] = now }
// Only process updates that pass the throttle check
let taskID = downloadTask.taskIdentifier
let prevBytes = previousBytesStorage.read { $0[taskID] } ?? 0
if prevBytes > totalBytesWritten + 100_000 {
// Progress went backwards significantly (reset detected)
Task { @MainActor in
LoggingService.shared.logDownload(
"[Downloads] URLSession progress reset detected",
details: "taskID: \(taskID), prev: \(prevBytes), now: \(totalBytesWritten)"
)
}
}
previousBytesStorage.write { $0[taskID] = totalBytesWritten }
// Determine total bytes - use X-Expected-Content-Length header if Content-Length is unknown
let effectiveTotalBytes: Int64
if totalBytesExpectedToWrite == NSURLSessionTransferSizeUnknown,
let response = downloadTask.response as? HTTPURLResponse,
let expectedSize = response.value(forHTTPHeaderField: "X-Expected-Content-Length"),
let size = Int64(expectedSize) {
effectiveTotalBytes = size
} else {
effectiveTotalBytes = totalBytesExpectedToWrite
}
Task { @MainActor in
self.handleDownloadProgress(
downloadID: taskInfo.downloadID,
phase: taskInfo.phase,
bytesWritten: totalBytesWritten,
totalBytes: effectiveTotalBytes
)
}
}
nonisolated func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
if let error = error {
let taskInfo = getTaskInfo(forTask: task.taskIdentifier) ??
parseTaskDescription(task.taskDescription)
guard let taskInfo else {
Task { @MainActor in
LoggingService.shared.logDownloadError("Download task failed with error but no download ID found")
}
return
}
// Check if this was a cancellation with resume data
let nsError = error as NSError
if nsError.code == NSURLErrorCancelled {
Task { @MainActor in
LoggingService.shared.logDownload(
"[Downloads] Task cancelled (\(taskInfo.phase))",
details: "taskID: \(task.taskIdentifier)"
)
}
return
}
Task { @MainActor in
LoggingService.shared.logDownloadError("Download task failed (\(taskInfo.phase))", error: error)
self.handleDownloadError(downloadID: taskInfo.downloadID, phase: taskInfo.phase, error: error)
}
}
}
nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
// Called when all background tasks are complete
// Could be used to update UI or show notification
}
}
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
//
// DownloadSettings.swift
// Yattee
//
// Local-only settings for downloads sorting and grouping.
// These settings are NOT synced to iCloud.
//
import Foundation
/// Sort options for completed downloads.
enum DownloadSortOption: String, CaseIterable, Codable {
case name
case downloadDate
case fileSize
var displayName: String {
switch self {
case .name:
return String(localized: "downloads.sort.name")
case .downloadDate:
return String(localized: "downloads.sort.downloadDate")
case .fileSize:
return String(localized: "downloads.sort.fileSize")
}
}
var systemImage: String {
switch self {
case .name:
return "textformat"
case .downloadDate:
return "calendar"
case .fileSize:
return "internaldrive"
}
}
}
/// Sort direction.
enum SortDirection: String, CaseIterable, Codable {
case ascending
case descending
var systemImage: String {
switch self {
case .ascending:
return "arrow.up"
case .descending:
return "arrow.down"
}
}
mutating func toggle() {
self = self == .ascending ? .descending : .ascending
}
}
/// Manages download view settings locally (not synced to iCloud).
@MainActor
@Observable
final class DownloadSettings {
// MARK: - Storage Keys
private enum Keys {
static let sortOption = "downloads.sortOption"
static let sortDirection = "downloads.sortDirection"
static let groupByChannel = "downloads.groupByChannel"
static let allowCellularDownloads = "downloads.allowCellularDownloads"
static let preferredQuality = "downloads.preferredQuality"
static let includeSubtitlesInAutoDownload = "downloads.includeSubtitlesInAutoDownload"
static let maxConcurrentDownloads = "downloads.maxConcurrentDownloads"
}
// MARK: - Storage
private let defaults = UserDefaults.standard
// MARK: - Cached Values
private var _sortOption: DownloadSortOption?
private var _sortDirection: SortDirection?
private var _groupByChannel: Bool?
private var _allowCellularDownloads: Bool?
private var _preferredDownloadQuality: DownloadQuality?
private var _includeSubtitlesInAutoDownload: Bool?
private var _maxConcurrentDownloads: Int?
// MARK: - Properties
/// The current sort option for completed downloads.
var sortOption: DownloadSortOption {
get {
if let cached = _sortOption { return cached }
guard let rawValue = defaults.string(forKey: Keys.sortOption),
let option = DownloadSortOption(rawValue: rawValue) else {
return .downloadDate
}
return option
}
set {
_sortOption = newValue
defaults.set(newValue.rawValue, forKey: Keys.sortOption)
}
}
/// The current sort direction.
var sortDirection: SortDirection {
get {
if let cached = _sortDirection { return cached }
guard let rawValue = defaults.string(forKey: Keys.sortDirection),
let direction = SortDirection(rawValue: rawValue) else {
return .descending
}
return direction
}
set {
_sortDirection = newValue
defaults.set(newValue.rawValue, forKey: Keys.sortDirection)
}
}
/// Whether to group downloads by channel.
var groupByChannel: Bool {
get {
if let cached = _groupByChannel { return cached }
return defaults.bool(forKey: Keys.groupByChannel)
}
set {
_groupByChannel = newValue
defaults.set(newValue, forKey: Keys.groupByChannel)
}
}
#if os(iOS)
/// Whether to allow downloads on cellular network. Default is false (WiFi only).
var allowCellularDownloads: Bool {
get {
if let cached = _allowCellularDownloads { return cached }
return defaults.bool(forKey: Keys.allowCellularDownloads)
}
set {
_allowCellularDownloads = newValue
defaults.set(newValue, forKey: Keys.allowCellularDownloads)
}
}
#endif
/// Preferred download quality. When set to anything other than .ask,
/// downloads will start automatically without showing the stream selection sheet.
var preferredDownloadQuality: DownloadQuality {
get {
if let cached = _preferredDownloadQuality { return cached }
guard let rawValue = defaults.string(forKey: Keys.preferredQuality),
let quality = DownloadQuality(rawValue: rawValue) else {
return .hd1080p
}
return quality
}
set {
_preferredDownloadQuality = newValue
defaults.set(newValue.rawValue, forKey: Keys.preferredQuality)
}
}
/// Whether to include subtitles when auto-downloading (non-Ask mode).
/// Uses the preferred subtitle language from playback settings.
var includeSubtitlesInAutoDownload: Bool {
get {
if let cached = _includeSubtitlesInAutoDownload { return cached }
return defaults.bool(forKey: Keys.includeSubtitlesInAutoDownload)
}
set {
_includeSubtitlesInAutoDownload = newValue
defaults.set(newValue, forKey: Keys.includeSubtitlesInAutoDownload)
}
}
/// Maximum number of concurrent downloads. Default is 2.
var maxConcurrentDownloads: Int {
get {
if let cached = _maxConcurrentDownloads { return cached }
let value = defaults.integer(forKey: Keys.maxConcurrentDownloads)
return value > 0 ? value : 2
}
set {
_maxConcurrentDownloads = newValue
defaults.set(newValue, forKey: Keys.maxConcurrentDownloads)
}
}
// MARK: - Sorting
/// Sorts an array of downloads based on current settings.
func sorted(_ downloads: [Download]) -> [Download] {
let sorted = downloads.sorted { first, second in
let comparison: Bool
switch sortOption {
case .name:
comparison = first.title.localizedCaseInsensitiveCompare(second.title) == .orderedAscending
case .downloadDate:
let firstDate = first.completedAt ?? first.startedAt ?? Date.distantPast
let secondDate = second.completedAt ?? second.startedAt ?? Date.distantPast
comparison = firstDate < secondDate
case .fileSize:
comparison = first.totalBytes < second.totalBytes
}
return sortDirection == .ascending ? comparison : !comparison
}
return sorted
}
/// Groups downloads by channel.
func groupedByChannel(_ downloads: [Download]) -> [(channel: String, channelID: String, downloads: [Download])] {
let grouped = Dictionary(grouping: downloads) { $0.channelID }
return grouped.map { (channelID, channelDownloads) in
let channelName = channelDownloads.first?.channelName ?? channelID
return (channel: channelName, channelID: channelID, downloads: sorted(channelDownloads))
}
.sorted { $0.channel.localizedCaseInsensitiveCompare($1.channel) == .orderedAscending }
}
}

View File

@@ -0,0 +1,34 @@
//
// DownloadTypes.swift
// Yattee
//
// Type definitions for downloads.
//
import Foundation
/// Download priority levels.
enum DownloadPriority: Int, Codable, Sendable {
case low = 0
case normal = 1
case high = 2
}
/// Download status.
enum DownloadStatus: String, Codable, Sendable {
case queued
case downloading
case paused
case completed
case failed
}
/// Download phase for multi-file downloads (video + audio + caption + storyboard + thumbnail).
enum DownloadPhase: String, Codable, Sendable {
case video // Downloading video file
case audio // Downloading audio file (for video-only streams)
case caption // Downloading caption file
case storyboard // Downloading storyboard sprite sheets
case thumbnail // Downloading video and channel thumbnails for offline artwork
case complete // All files downloaded
}

View File

@@ -0,0 +1,338 @@
//
// StorageDiagnostics.swift
// Yattee
//
// Storage diagnostics and utilities for downloads.
//
import Foundation
// MARK: - Storage Diagnostics
/// Represents storage usage for a specific directory or category.
struct StorageUsageItem: Identifiable {
let id = UUID()
let name: String
let path: String
let size: Int64
let fileCount: Int
}
/// Comprehensive storage diagnostics for debugging app storage usage.
struct StorageDiagnostics {
let items: [StorageUsageItem]
let totalSize: Int64
let documentsSize: Int64
let cachesSize: Int64
let appSupportSize: Int64
let tempSize: Int64
let otherSize: Int64
var formattedTotal: String { formatBytes(totalSize) }
var formattedDocuments: String { formatBytes(documentsSize) }
var formattedCaches: String { formatBytes(cachesSize) }
var formattedAppSupport: String { formatBytes(appSupportSize) }
var formattedTemp: String { formatBytes(tempSize) }
private func formatBytes(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
@MainActor
func logDiagnostics() {
LoggingService.shared.logDownload(
"=== COMPREHENSIVE STORAGE DIAGNOSTICS ===",
details: """
Total app storage: \(formattedTotal)
Documents: \(formattedDocuments)
Caches: \(formattedCaches)
Application Support: \(formattedAppSupport)
Temp: \(formattedTemp)
"""
)
LoggingService.shared.logDownload("=== STORAGE BREAKDOWN ===")
for item in items.sorted(by: { $0.size > $1.size }) {
let formatted = formatBytes(item.size)
LoggingService.shared.logDownload(
"\(item.name): \(formatted)",
details: "Files: \(item.fileCount), Path: \(item.path)"
)
}
}
}
/// Helper to format bytes (standalone function for use in scanAppStorage)
private func formatBytesStatic(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
/// Scans all app directories and returns comprehensive storage diagnostics.
@MainActor
func scanAppStorage() -> StorageDiagnostics {
let fileManager = FileManager.default
var items: [StorageUsageItem] = []
// Helper to calculate directory size (including hidden files)
func directorySize(at url: URL, includeHidden: Bool = false) -> (size: Int64, count: Int) {
var totalSize: Int64 = 0
var fileCount = 0
let options: FileManager.DirectoryEnumerationOptions = includeHidden ? [] : [.skipsHiddenFiles]
guard let enumerator = fileManager.enumerator(
at: url,
includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey, .totalFileAllocatedSizeKey],
options: options
) else {
return (0, 0)
}
for case let fileURL as URL in enumerator {
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey, .totalFileAllocatedSizeKey]),
resourceValues.isDirectory != true else {
continue
}
// Use allocated size if available (accounts for sparse files and actual disk usage)
let size = Int64(resourceValues.totalFileAllocatedSize ?? resourceValues.fileSize ?? 0)
totalSize += size
fileCount += 1
}
return (totalSize, fileCount)
}
// Documents directory
var documentsTotal: Int64 = 0
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
// Downloads subfolder
let downloadsURL = documentsURL.appendingPathComponent("Downloads")
if fileManager.fileExists(atPath: downloadsURL.path) {
let (size, count) = directorySize(at: downloadsURL)
items.append(StorageUsageItem(name: "Downloads", path: downloadsURL.path, size: size, fileCount: count))
documentsTotal += size
}
// Check for other items in Documents
if let contents = try? fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil) {
for item in contents where item.lastPathComponent != "Downloads" {
let (size, count) = directorySize(at: item)
if size > 0 {
items.append(StorageUsageItem(name: "Documents/\(item.lastPathComponent)", path: item.path, size: size, fileCount: count))
documentsTotal += size
}
}
}
}
// Caches directory
var cachesTotal: Int64 = 0
if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first {
// Image cache
let imageCacheURL = cachesURL.appendingPathComponent("ImageCache")
if fileManager.fileExists(atPath: imageCacheURL.path) {
let (size, count) = directorySize(at: imageCacheURL)
items.append(StorageUsageItem(name: "Image Cache", path: imageCacheURL.path, size: size, fileCount: count))
cachesTotal += size
}
// Feed cache
let feedCacheURL = cachesURL.appendingPathComponent("FeedCache")
if fileManager.fileExists(atPath: feedCacheURL.path) {
let (size, count) = directorySize(at: feedCacheURL)
items.append(StorageUsageItem(name: "Feed Cache", path: feedCacheURL.path, size: size, fileCount: count))
cachesTotal += size
}
// URLSession cache (com.apple.nsurlsessiond)
if let contents = try? fileManager.contentsOfDirectory(at: cachesURL, includingPropertiesForKeys: nil) {
for item in contents {
let name = item.lastPathComponent
if name == "ImageCache" || name == "FeedCache" { continue }
let (size, count) = directorySize(at: item)
if size > 1024 { // Only show if > 1KB
items.append(StorageUsageItem(name: "Caches/\(name)", path: item.path, size: size, fileCount: count))
cachesTotal += size
}
}
}
}
// Application Support directory
var appSupportTotal: Int64 = 0
if let appSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
if let contents = try? fileManager.contentsOfDirectory(at: appSupportURL, includingPropertiesForKeys: nil) {
for item in contents {
let (size, count) = directorySize(at: item)
if size > 1024 { // Only show if > 1KB
items.append(StorageUsageItem(name: "AppSupport/\(item.lastPathComponent)", path: item.path, size: size, fileCount: count))
appSupportTotal += size
}
}
}
}
// Temp directory
var tempTotal: Int64 = 0
let tempURL = fileManager.temporaryDirectory
if let contents = try? fileManager.contentsOfDirectory(at: tempURL, includingPropertiesForKeys: nil) {
for item in contents {
let (size, count) = directorySize(at: item)
if size > 1024 { // Only show if > 1KB
items.append(StorageUsageItem(name: "Temp/\(item.lastPathComponent)", path: item.path, size: size, fileCount: count))
tempTotal += size
}
}
}
// Library directory - scan ALL subdirectories to find hidden storage
var otherTotal: Int64 = 0
if let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first {
// Get all items in Library (including hidden)
if let contents = try? fileManager.contentsOfDirectory(at: libraryURL, includingPropertiesForKeys: nil, options: []) {
for item in contents {
let name = item.lastPathComponent
// Skip Caches (already scanned) and Application Support (already scanned)
if name == "Caches" || name == "Application Support" { continue }
let (size, count) = directorySize(at: item, includeHidden: true)
if size > 1024 { // Only show if > 1KB
items.append(StorageUsageItem(name: "Library/\(name)", path: item.path, size: size, fileCount: count))
otherTotal += size
}
}
}
}
// Check the app container root for anything we might have missed
// Go up from Documents to the container root
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
let containerURL = documentsURL.deletingLastPathComponent()
// Scan for any top-level directories we haven't covered
if let contents = try? fileManager.contentsOfDirectory(at: containerURL, includingPropertiesForKeys: nil, options: []) {
for item in contents {
let name = item.lastPathComponent
// Skip directories we've already scanned
if name == "Documents" || name == "Library" || name == "tmp" || name == "SystemData" { continue }
let (size, count) = directorySize(at: item, includeHidden: true)
if size > 1024 {
items.append(StorageUsageItem(name: "Container/\(name)", path: item.path, size: size, fileCount: count))
otherTotal += size
}
}
}
// Log the container path for reference
LoggingService.shared.logDownload("App Container", details: containerURL.path)
// Scan ENTIRE container recursively to find all storage
let (totalContainerSize, totalContainerFiles) = directorySize(at: containerURL, includeHidden: true)
LoggingService.shared.logDownload(
"TOTAL Data Container: \(formatBytesStatic(totalContainerSize))",
details: "\(totalContainerFiles) files at \(containerURL.path)"
)
// Scan ALL top-level directories in container to find where storage is hiding
LoggingService.shared.logDownload("=== CONTAINER BREAKDOWN ===")
if let allContents = try? fileManager.contentsOfDirectory(at: containerURL, includingPropertiesForKeys: nil, options: []) {
for item in allContents {
let (itemSize, itemCount) = directorySize(at: item, includeHidden: true)
LoggingService.shared.logDownload(
" \(item.lastPathComponent): \(formatBytesStatic(itemSize))",
details: "\(itemCount) files"
)
// If this is a large directory, scan its subdirectories too
if itemSize > 100 * 1024 * 1024 { // > 100 MB
if let subContents = try? fileManager.contentsOfDirectory(at: item, includingPropertiesForKeys: nil, options: []) {
for subItem in subContents {
let (subSize, subCount) = directorySize(at: subItem, includeHidden: true)
if subSize > 10 * 1024 * 1024 { // > 10 MB
LoggingService.shared.logDownload(
" \(subItem.lastPathComponent): \(formatBytesStatic(subSize))",
details: "\(subCount) files"
)
}
}
}
}
}
}
}
// Check the app bundle size (this is read-only, can't be cleared)
var bundleSize: Int64 = 0
if let bundleURL = Bundle.main.bundleURL as URL? {
let (size, count) = directorySize(at: bundleURL, includeHidden: true)
bundleSize = size
items.append(StorageUsageItem(name: "App Bundle (read-only)", path: bundleURL.path, size: size, fileCount: count))
LoggingService.shared.logDownload("App Bundle", details: bundleURL.path)
// Log contents of bundle for debugging
if let contents = try? fileManager.contentsOfDirectory(at: bundleURL, includingPropertiesForKeys: nil, options: []) {
for item in contents {
let (itemSize, itemCount) = directorySize(at: item, includeHidden: true)
if itemSize > 1024 * 1024 { // Only log items > 1MB
LoggingService.shared.logDownload(" Bundle/\(item.lastPathComponent): \(formatBytesStatic(itemSize))", details: "\(itemCount) files")
}
}
}
// Check parent of bundle for other app-related directories
let bundleParent = bundleURL.deletingLastPathComponent()
if let parentContents = try? fileManager.contentsOfDirectory(at: bundleParent, includingPropertiesForKeys: nil, options: []) {
for item in parentContents where item.lastPathComponent != bundleURL.lastPathComponent {
let (itemSize, itemCount) = directorySize(at: item, includeHidden: true)
if itemSize > 1024 * 1024 { // Only log items > 1MB
items.append(StorageUsageItem(name: "BundleContainer/\(item.lastPathComponent)", path: item.path, size: itemSize, fileCount: itemCount))
bundleSize += itemSize
LoggingService.shared.logDownload("BundleContainer/\(item.lastPathComponent): \(formatBytesStatic(itemSize))", details: "\(itemCount) files")
}
}
}
LoggingService.shared.logDownload("Bundle container", details: bundleParent.path)
}
let totalSize = documentsTotal + cachesTotal + appSupportTotal + tempTotal + otherTotal + bundleSize
return StorageDiagnostics(
items: items,
totalSize: totalSize,
documentsSize: documentsTotal,
cachesSize: cachesTotal,
appSupportSize: appSupportTotal,
tempSize: tempTotal,
otherSize: otherTotal
)
}
// MARK: - Thread-Safe Storage
/// A thread-safe wrapper for mutable values using NSLock.
/// Conforms to Sendable since all access is synchronized.
final class LockedStorage<Value>: @unchecked Sendable {
private var value: Value
private let lock = NSLock()
init(_ value: Value) {
self.value = value
}
func read<T>(_ block: (Value) -> T) -> T {
lock.lock()
defer { lock.unlock() }
return block(value)
}
func write(_ block: (inout Value) -> Void) {
lock.lock()
defer { lock.unlock() }
block(&value)
}
}