mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
383
Yattee/Services/Downloads/Download.swift
Normal file
383
Yattee/Services/Downloads/Download.swift
Normal 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
|
||||
}
|
||||
}
|
||||
31
Yattee/Services/Downloads/DownloadError.swift
Normal file
31
Yattee/Services/Downloads/DownloadError.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
377
Yattee/Services/Downloads/DownloadManager+Assets.swift
Normal file
377
Yattee/Services/Downloads/DownloadManager+Assets.swift
Normal 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
|
||||
875
Yattee/Services/Downloads/DownloadManager+Execution.swift
Normal file
875
Yattee/Services/Downloads/DownloadManager+Execution.swift
Normal 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
|
||||
340
Yattee/Services/Downloads/DownloadManager+Persistence.swift
Normal file
340
Yattee/Services/Downloads/DownloadManager+Persistence.swift
Normal 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
|
||||
279
Yattee/Services/Downloads/DownloadManager+Storage.swift
Normal file
279
Yattee/Services/Downloads/DownloadManager+Storage.swift
Normal 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
|
||||
215
Yattee/Services/Downloads/DownloadManager+URLSession.swift
Normal file
215
Yattee/Services/Downloads/DownloadManager+URLSession.swift
Normal 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
|
||||
1210
Yattee/Services/Downloads/DownloadManager.swift
Normal file
1210
Yattee/Services/Downloads/DownloadManager.swift
Normal file
File diff suppressed because it is too large
Load Diff
224
Yattee/Services/Downloads/DownloadSettings.swift
Normal file
224
Yattee/Services/Downloads/DownloadSettings.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
34
Yattee/Services/Downloads/DownloadTypes.swift
Normal file
34
Yattee/Services/Downloads/DownloadTypes.swift
Normal 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
|
||||
}
|
||||
338
Yattee/Services/Downloads/StorageDiagnostics.swift
Normal file
338
Yattee/Services/Downloads/StorageDiagnostics.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user