Sora/Sora/Utils/DownloadUtils/DownloadModels.swift
2025-05-31 22:16:41 +02:00

709 lines
25 KiB
Swift

//
// DownloadModels.swift
// Sora
//
// Created by Francesco on 30/04/25.
//
import Foundation
// MARK: - Quality Preference Constants
enum DownloadQualityPreference: String, CaseIterable {
case best = "Best"
case high = "High"
case medium = "Medium"
case low = "Low"
static var defaultPreference: DownloadQualityPreference {
return .best
}
static var userDefaultsKey: String {
return "downloadQuality"
}
/// Returns the current user preference for download quality
static var current: DownloadQualityPreference {
let storedValue = UserDefaults.standard.string(forKey: userDefaultsKey) ?? defaultPreference.rawValue
return DownloadQualityPreference(rawValue: storedValue) ?? defaultPreference
}
/// Description of what each quality preference means
var description: String {
switch self {
case .best:
return "Highest available quality (largest file size)"
case .high:
return "High quality (720p or higher)"
case .medium:
return "Medium quality (480p-720p)"
case .low:
return "Lowest available quality (smallest file size)"
}
}
}
// MARK: - Download Types
enum DownloadType: String, Codable {
case movie
case episode
var description: String {
switch self {
case .movie:
return "Movie"
case .episode:
return "Episode"
}
}
}
// MARK: - Downloaded Asset Model
struct DownloadedAsset: Identifiable, Codable, Equatable {
let id: UUID
var name: String
let downloadDate: Date
let originalURL: URL
let localURL: URL
let type: DownloadType
let metadata: AssetMetadata?
// New fields for subtitle support
let subtitleURL: URL?
let localSubtitleURL: URL?
// For caching purposes, but not stored as part of the codable object
private var _cachedFileSize: Int64? = nil
// Implement Equatable
static func == (lhs: DownloadedAsset, rhs: DownloadedAsset) -> Bool {
return lhs.id == rhs.id
}
/// Returns the combined file size of the video file and subtitle file (if exists)
var fileSize: Int64 {
// This implementation calculates file size without caching it in the struct property
// Instead we'll use a static cache dictionary
let subtitlePathString = localSubtitleURL?.path ?? ""
let cacheKey = localURL.path + ":" + subtitlePathString
// Check the static cache first
if let size = DownloadedAsset.fileSizeCache[cacheKey] {
return size
}
// Check if this asset is currently being downloaded (avoid expensive calculations during active downloads)
if isCurrentlyBeingDownloaded() {
// Return cached size if available, otherwise return 0 and schedule background calculation
if let lastKnownSize = DownloadedAsset.lastKnownSizes[cacheKey] {
// Schedule a background update for when download completes
scheduleBackgroundSizeCalculation(cacheKey: cacheKey)
return lastKnownSize
} else {
// Return 0 for actively downloading files that we haven't calculated yet
return 0
}
}
// For non-active downloads, calculate the size normally
let calculatedSize = calculateFileSizeInternal()
// Store in both caches
DownloadedAsset.fileSizeCache[cacheKey] = calculatedSize
DownloadedAsset.lastKnownSizes[cacheKey] = calculatedSize
return calculatedSize
}
/// Check if this asset is currently being downloaded
public func isCurrentlyBeingDownloaded() -> Bool {
// Access JSController to check active downloads
let activeDownloads = JSController.shared.activeDownloads
// Check if any active download matches this asset's path
for download in activeDownloads {
// Compare based on the file name or title
if let downloadTitle = download.title, downloadTitle == name {
return true
}
// Also compare based on URL path if titles don't match
if download.originalURL.lastPathComponent.contains(name) ||
name.contains(download.originalURL.lastPathComponent) {
return true
}
}
return false
}
/// Schedule a background calculation for when the download completes
private func scheduleBackgroundSizeCalculation(cacheKey: String) {
DispatchQueue.global(qos: .background).async {
// Check if download is still active before calculating
if !self.isCurrentlyBeingDownloaded() {
let size = self.calculateFileSizeInternal()
DispatchQueue.main.async {
// Update caches on main thread
DownloadedAsset.fileSizeCache[cacheKey] = size
DownloadedAsset.lastKnownSizes[cacheKey] = size
// Post a notification that file size has been updated
NotificationCenter.default.post(
name: NSNotification.Name("fileSizeUpdated"),
object: nil,
userInfo: ["assetId": self.id.uuidString]
)
}
}
}
}
/// Internal method to calculate file size (separated for reuse)
public func calculateFileSizeInternal() -> Int64 {
var totalSize: Int64 = 0
let fileManager = FileManager.default
// Get video file or directory size
if fileManager.fileExists(atPath: localURL.path) {
// Check if it's a .movpkg directory or a regular file
var isDirectory: ObjCBool = false
fileManager.fileExists(atPath: localURL.path, isDirectory: &isDirectory)
if isDirectory.boolValue {
// If it's a directory (like .movpkg), calculate size of all contained files
totalSize += calculateDirectorySize(localURL)
Logger.shared.log("Calculated directory size for .movpkg: \(totalSize) bytes", type: "Info")
} else {
// If it's a single file, get its size
do {
let attributes = try fileManager.attributesOfItem(atPath: localURL.path)
if let size = attributes[.size] as? Int64 {
totalSize += size
} else if let size = attributes[.size] as? Int {
totalSize += Int64(size)
} else if let size = attributes[.size] as? NSNumber {
totalSize += size.int64Value
} else {
Logger.shared.log("Could not get file size as Int64 for: \(localURL.path)", type: "Warning")
}
} catch {
Logger.shared.log("Error getting file size: \(error.localizedDescription) for \(localURL.path)", type: "Error")
}
}
} else {
Logger.shared.log("Video file does not exist at path: \(localURL.path)", type: "Warning")
}
// Add subtitle file size if it exists
if let subtitlePath = localSubtitleURL?.path, fileManager.fileExists(atPath: subtitlePath) {
do {
let attributes = try fileManager.attributesOfItem(atPath: subtitlePath)
if let size = attributes[.size] as? Int64 {
totalSize += size
} else if let size = attributes[.size] as? Int {
totalSize += Int64(size)
} else if let size = attributes[.size] as? NSNumber {
totalSize += size.int64Value
}
} catch {
Logger.shared.log("Error getting subtitle file size: \(error.localizedDescription)", type: "Warning")
}
}
return totalSize
}
/// Calculates the size of all files in a directory recursively
private func calculateDirectorySize(_ directoryURL: URL) -> Int64 {
let fileManager = FileManager.default
var totalSize: Int64 = 0
do {
// Get all content URLs
let contents = try fileManager.contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey], options: [])
// Calculate size for each item
for url in contents {
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey])
if let isDirectory = resourceValues.isDirectory, isDirectory {
// If it's a directory, recursively calculate its size
totalSize += calculateDirectorySize(url)
} else {
// If it's a file, add its size
if let fileSize = resourceValues.fileSize {
totalSize += Int64(fileSize)
}
}
}
} catch {
Logger.shared.log("Error calculating directory size: \(error.localizedDescription)", type: "Error")
}
return totalSize
}
/// Global file size cache for performance
private static var fileSizeCache: [String: Int64] = [:]
/// Global last known sizes cache for performance
private static var lastKnownSizes: [String: Int64] = [:]
/// Clears the global file size cache
static func clearFileSizeCache() {
fileSizeCache.removeAll()
lastKnownSizes.removeAll()
}
/// Returns true if the main video file exists
var fileExists: Bool {
return FileManager.default.fileExists(atPath: localURL.path)
}
// MARK: - New Grouping Properties
/// Returns the anime title to use for grouping (show title for episodes, name for movies)
var groupTitle: String {
if type == .episode, let showTitle = metadata?.showTitle, !showTitle.isEmpty {
return showTitle
}
// For movies or episodes without show title, use the asset name
return name
}
/// Returns a display name suitable for showing in a list of episodes
var episodeDisplayName: String {
guard type == .episode else { return name }
// Return the name directly since titles typically already contain episode information
return name
}
/// Returns order priority for episodes within a show (by season and episode)
var episodeOrderPriority: Int {
guard type == .episode else { return 0 }
// Calculate priority: Season number * 1000 + episode number
let seasonValue = metadata?.season ?? 0
let episodeValue = metadata?.episode ?? 0
return (seasonValue * 1000) + episodeValue
}
// Add coding keys to ensure backward compatibility
enum CodingKeys: String, CodingKey {
case id, name, downloadDate, originalURL, localURL, type, metadata
case subtitleURL, localSubtitleURL
}
// Custom decoding to handle optional new fields
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Decode required fields
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
downloadDate = try container.decode(Date.self, forKey: .downloadDate)
originalURL = try container.decode(URL.self, forKey: .originalURL)
localURL = try container.decode(URL.self, forKey: .localURL)
type = try container.decode(DownloadType.self, forKey: .type)
metadata = try container.decodeIfPresent(AssetMetadata.self, forKey: .metadata)
// Decode new optional fields
subtitleURL = try container.decodeIfPresent(URL.self, forKey: .subtitleURL)
localSubtitleURL = try container.decodeIfPresent(URL.self, forKey: .localSubtitleURL)
// Initialize cache
_cachedFileSize = nil
}
init(
id: UUID = UUID(),
name: String,
downloadDate: Date,
originalURL: URL,
localURL: URL,
type: DownloadType = .movie,
metadata: AssetMetadata? = nil,
subtitleURL: URL? = nil,
localSubtitleURL: URL? = nil
) {
self.id = id
self.name = name
self.downloadDate = downloadDate
self.originalURL = originalURL
self.localURL = localURL
self.type = type
self.metadata = metadata
self.subtitleURL = subtitleURL
self.localSubtitleURL = localSubtitleURL
}
}
// MARK: - Active Download Model
struct ActiveDownload: Identifiable, Equatable {
let id: UUID
let originalURL: URL
var progress: Double
let task: URLSessionTask
let type: DownloadType
let metadata: AssetMetadata?
// Implement Equatable
static func == (lhs: ActiveDownload, rhs: ActiveDownload) -> Bool {
return lhs.id == rhs.id
}
// Add the same grouping properties as DownloadedAsset for consistency
var groupTitle: String {
if type == .episode,
let showTitle = metadata?.showTitle,
!showTitle.isEmpty {
return showTitle
}
return metadata?.title ?? originalURL.lastPathComponent
}
var episodeDisplayName: String {
guard type == .episode else {
return metadata?.title ?? originalURL.lastPathComponent
}
// Extract base episode number from metadata or default to 1
let episodeNumber = metadata?.episode ?? 1
let base = "Episode \(episodeNumber)"
// Check if we have a valid title that's different from the base
if let title = metadata?.title, !title.isEmpty, title != base {
return "\(base): \(title)"
} else {
return base
}
}
init(
id: UUID = UUID(),
originalURL: URL,
progress: Double = 0,
task: URLSessionTask,
type: DownloadType = .movie,
metadata: AssetMetadata? = nil
) {
self.id = id
self.originalURL = originalURL
self.progress = progress
self.task = task
self.type = type
self.metadata = metadata
}
}
// MARK: - JS Active Download Model
struct JSActiveDownload: Identifiable, Equatable {
let id: UUID
let originalURL: URL
var progress: Double
let task: AVAssetDownloadTask? // For HLS downloads
let mp4Task: URLSessionDownloadTask? // For MP4 downloads
let type: DownloadType
var metadata: AssetMetadata?
var title: String?
var imageURL: URL?
var subtitleURL: URL?
var queueStatus: DownloadQueueStatus
var asset: AVURLAsset?
var headers: [String: String]
var module: ScrapingModule?
static func == (lhs: JSActiveDownload, rhs: JSActiveDownload) -> Bool {
return lhs.id == rhs.id &&
lhs.originalURL == rhs.originalURL &&
lhs.progress == rhs.progress &&
lhs.type == rhs.type &&
lhs.title == rhs.title &&
lhs.imageURL == rhs.imageURL &&
lhs.subtitleURL == rhs.subtitleURL &&
lhs.queueStatus == rhs.queueStatus
}
init(
id: UUID = UUID(),
originalURL: URL,
progress: Double = 0,
task: AVAssetDownloadTask? = nil,
mp4Task: URLSessionDownloadTask? = nil,
queueStatus: DownloadQueueStatus = .queued,
type: DownloadType = .movie,
metadata: AssetMetadata? = nil,
title: String? = nil,
imageURL: URL? = nil,
subtitleURL: URL? = nil,
asset: AVURLAsset? = nil,
headers: [String: String] = [:],
module: ScrapingModule? = nil
) {
self.id = id
self.originalURL = originalURL
self.progress = progress
self.task = task
self.mp4Task = mp4Task
self.type = type
self.metadata = metadata
self.title = title
self.imageURL = imageURL
self.subtitleURL = subtitleURL
self.queueStatus = queueStatus
self.asset = asset
self.headers = headers
self.module = module
}
}
// MARK: - Asset Metadata
struct AssetMetadata: Codable {
let title: String
let overview: String?
let posterURL: URL?
let backdropURL: URL?
let releaseDate: String?
// Additional fields for episodes
let showTitle: String?
let season: Int?
let episode: Int?
let showPosterURL: URL? // Main show poster URL (distinct from episode-specific images)
init(
title: String,
overview: String? = nil,
posterURL: URL? = nil,
backdropURL: URL? = nil,
releaseDate: String? = nil,
showTitle: String? = nil,
season: Int? = nil,
episode: Int? = nil,
showPosterURL: URL? = nil
) {
self.title = title
self.overview = overview
self.posterURL = posterURL
self.backdropURL = backdropURL
self.releaseDate = releaseDate
self.showTitle = showTitle
self.season = season
self.episode = episode
self.showPosterURL = showPosterURL
}
}
// MARK: - New Group Model
/// Represents a group of downloads (anime/show or movies)
struct DownloadGroup: Identifiable {
var id = UUID()
let title: String // Anime title for shows
let type: DownloadType
var assets: [DownloadedAsset]
var posterURL: URL?
// Cache key for this group
private var cacheKey: String {
return "\(id)-\(title)-\(assets.count)"
}
// Static file size cache
private static var fileSizeCache: [String: Int64] = [:]
// Static last known group sizes cache for performance during active downloads
private static var lastKnownGroupSizes: [String: Int64] = [:]
var assetCount: Int {
return assets.count
}
var isShow: Bool {
return type == .episode
}
var isAnime: Bool {
return isShow
}
/// Returns the total file size of all assets in the group
var totalFileSize: Int64 {
// Check if we have a cached size for this group
let key = cacheKey
if let cachedSize = DownloadGroup.fileSizeCache[key] {
return cachedSize
}
// Check if any assets in this group are currently being downloaded
let hasActiveDownloads = assets.contains { asset in
return asset.isCurrentlyBeingDownloaded()
}
if hasActiveDownloads {
// If any downloads are active, return last known size or schedule background calculation
if let lastKnownSize = DownloadGroup.lastKnownGroupSizes[key] {
// Schedule a background update for when downloads complete
scheduleBackgroundGroupSizeCalculation(cacheKey: key)
return lastKnownSize
} else {
// Return 0 for groups with active downloads that we haven't calculated yet
return 0
}
}
// For groups without active downloads, calculate the size normally
let total = assets.reduce(0) { runningTotal, asset in
return runningTotal + asset.fileSize
}
// Store in both caches
DownloadGroup.fileSizeCache[key] = total
DownloadGroup.lastKnownGroupSizes[key] = total
return total
}
/// Schedule a background calculation for when downloads complete
private func scheduleBackgroundGroupSizeCalculation(cacheKey: String) {
DispatchQueue.global(qos: .background).async {
// Check if any assets are still being downloaded
let stillHasActiveDownloads = self.assets.contains { asset in
return asset.isCurrentlyBeingDownloaded()
}
if !stillHasActiveDownloads {
// Calculate total size
let total = self.assets.reduce(0) { runningTotal, asset in
return runningTotal + asset.calculateFileSizeInternal()
}
DispatchQueue.main.async {
// Update caches on main thread
DownloadGroup.fileSizeCache[cacheKey] = total
DownloadGroup.lastKnownGroupSizes[cacheKey] = total
// Post a notification that group size has been updated
NotificationCenter.default.post(
name: NSNotification.Name("groupSizeUpdated"),
object: nil,
userInfo: ["groupId": self.id.uuidString]
)
}
}
}
}
/// Returns the count of assets that actually exist on disk
var existingAssetsCount: Int {
return assets.filter { $0.fileExists }.count
}
/// Returns true if all assets in this group exist
var allAssetsExist: Bool {
return existingAssetsCount == assets.count
}
/// Clear the file size cache for all groups
static func clearFileSizeCache() {
fileSizeCache.removeAll()
lastKnownGroupSizes.removeAll()
}
// For anime/TV shows, organize episodes by season then episode number
func organizedEpisodes() -> [DownloadedAsset] {
guard isShow else { return assets }
return assets.sorted { $0.episodeOrderPriority < $1.episodeOrderPriority }
}
/// Refresh the calculated size for this group
mutating func refreshFileSize() {
DownloadGroup.fileSizeCache.removeValue(forKey: cacheKey)
_ = totalFileSize
}
init(title: String, type: DownloadType, assets: [DownloadedAsset], posterURL: URL? = nil) {
self.title = title
self.type = type
self.assets = assets
self.posterURL = posterURL
}
}
// MARK: - Grouping Extensions
extension Array where Element == DownloadedAsset {
/// Groups assets by anime title or movie
func groupedByTitle() -> [DownloadGroup] {
// First group by the anime title (show title for episodes, name for movies)
let groupedDict = Dictionary(grouping: self) { asset in
// For episodes, prioritize the showTitle from metadata
if asset.type == .episode, let showTitle = asset.metadata?.showTitle, !showTitle.isEmpty {
return showTitle
}
// For movies or episodes without proper metadata, use the asset name
return asset.name
}
// Convert to array of DownloadGroup objects
return groupedDict.map { (title, assets) in
// Determine group type (if any asset is an episode, it's a show)
let isShow = assets.contains { $0.type == .episode }
let type: DownloadType = isShow ? .episode : .movie
// Find poster URL - prioritize show-level posters over episode-specific ones
let posterURL: URL? = {
// First priority: Use dedicated showPosterURL if available
if let showPosterURL = assets.compactMap({ $0.metadata?.showPosterURL }).first {
return showPosterURL
}
// Second priority: For anime/TV shows, look for consistent poster URLs that appear across multiple episodes
// These are more likely to be show posters rather than episode-specific images
if isShow && assets.count > 1 {
let posterURLs = assets.compactMap { $0.metadata?.posterURL }
let urlCounts = Dictionary(grouping: posterURLs, by: { $0 })
// Find the most common poster URL (likely the show poster)
if let mostCommonPoster = urlCounts.max(by: { $0.value.count < $1.value.count })?.key {
return mostCommonPoster
}
}
// Fallback to first available poster
return assets.compactMap { $0.metadata?.posterURL }.first
}()
return DownloadGroup(
title: title,
type: type,
assets: assets,
posterURL: posterURL
)
}.sorted { $0.title < $1.title }
}
/// Sorts assets in a way suitable for flat list display
func sortedForDisplay(by sortOption: DownloadView.SortOption) -> [DownloadedAsset] {
switch sortOption {
case .newest:
return sorted { $0.downloadDate > $1.downloadDate }
case .oldest:
return sorted { $0.downloadDate < $1.downloadDate }
case .title:
return sorted { $0.name < $1.name }
}
}
}
// MARK: - Active Downloads Grouping
extension Array where Element == ActiveDownload {
/// Groups active downloads by show title
func groupedByTitle() -> [String: [ActiveDownload]] {
let grouped = Dictionary(grouping: self) { download in
return download.groupTitle
}
return grouped
}
}