diff --git a/Sora/Managers/EpisodeMetadataManager.swift b/Sora/Managers/EpisodeMetadataManager.swift deleted file mode 100644 index f7e79ca..0000000 --- a/Sora/Managers/EpisodeMetadataManager.swift +++ /dev/null @@ -1,614 +0,0 @@ -// -// EpisodeMetadataManager.swift -// Sora -// -// Created by doomsboygaming on 5/22/25 -// - -import Foundation -import Combine - -/// A model representing episode metadata -struct EpisodeMetadataInfo: Codable, Equatable { - let title: [String: String] - let imageUrl: String - let anilistId: Int - let episodeNumber: Int - - var cacheKey: String { - return "anilist_\(anilistId)_episode_\(episodeNumber)" - } -} - -/// Status of a metadata fetch request -enum MetadataFetchStatus { - case notRequested - case fetching - case fetched(EpisodeMetadataInfo) - case failed(Error) -} - -/// Central manager for fetching, caching, and prefetching episode metadata -class EpisodeMetadataManager: ObservableObject { - static let shared = EpisodeMetadataManager() - - private init() { - // Initialize any resources here - Logger.shared.log("EpisodeMetadataManager initialized", type: "Info") - } - - // Published properties that trigger UI updates - @Published private var metadataCache: [String: MetadataFetchStatus] = [:] - - // In-flight requests to prevent duplicate API calls - private var activeRequests: [String: AnyCancellable] = [:] - - // Queue for managing concurrent requests - private let fetchQueue = DispatchQueue(label: "com.sora.metadataFetch", qos: .userInitiated, attributes: .concurrent) - - // Add retry configuration properties - private let maxRetryAttempts = 3 - private let initialBackoffDelay: TimeInterval = 1.0 // in seconds - private var currentRetryAttempts: [String: Int] = [:] // Track retry attempts by cache key - - // MARK: - Public Interface - - /// Fetch metadata for a single episode - /// - Parameters: - /// - anilistId: The Anilist ID of the anime - /// - episodeNumber: The episode number to fetch - /// - completion: Callback with the result - func fetchMetadata(anilistId: Int, episodeNumber: Int, completion: @escaping (Result) -> Void) { - let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)" - - // Check if we already have this metadata - if let existingStatus = metadataCache[cacheKey] { - switch existingStatus { - case .fetched(let metadata): - // Return cached data immediately - completion(.success(metadata)) - return - - case .fetching: - // Already fetching, will be notified via publisher - // Set up a listener for when this request completes - waitForRequest(cacheKey: cacheKey, completion: completion) - return - - case .failed: - // Previous attempt failed, try again - break - - case .notRequested: - // Should not happen but continue to fetch - break - } - } - - // Check persistent cache - if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey), - let metadata = EpisodeMetadata.fromData(cachedData) { - - let metadataInfo = EpisodeMetadataInfo( - title: metadata.title, - imageUrl: metadata.imageUrl, - anilistId: anilistId, - episodeNumber: episodeNumber - ) - - // Update memory cache - DispatchQueue.main.async { - self.metadataCache[cacheKey] = .fetched(metadataInfo) - } - - completion(.success(metadataInfo)) - return - } - - // Need to fetch from network - DispatchQueue.main.async { - self.metadataCache[cacheKey] = .fetching - } - - performFetch(anilistId: anilistId, episodeNumber: episodeNumber, cacheKey: cacheKey, completion: completion) - } - - /// Fetch metadata for multiple episodes in batch - /// - Parameters: - /// - anilistId: The Anilist ID of the anime - /// - episodeNumbers: Array of episode numbers to fetch - func batchFetchMetadata(anilistId: Int, episodeNumbers: [Int]) { - // First check which episodes we need to fetch - let episodesToFetch = episodeNumbers.filter { episodeNumber in - let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)" - if let status = metadataCache[cacheKey] { - switch status { - case .fetched, .fetching: - return false - default: - return true - } - } - return true - } - - guard !episodesToFetch.isEmpty else { - Logger.shared.log("No new episodes to fetch in batch", type: "Debug") - return - } - - // Mark all as fetching - for episodeNumber in episodesToFetch { - let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)" - DispatchQueue.main.async { - self.metadataCache[cacheKey] = .fetching - } - } - - // Perform batch fetch - fetchBatchFromNetwork(anilistId: anilistId, episodeNumbers: episodesToFetch) - } - - /// Prefetch metadata for a range of episodes - /// - Parameters: - /// - anilistId: The Anilist ID of the anime - /// - startEpisode: The starting episode number - /// - count: How many episodes to prefetch - func prefetchMetadata(anilistId: Int, startEpisode: Int, count: Int = 5) { - let episodeNumbers = Array(startEpisode..<(startEpisode + count)) - batchFetchMetadata(anilistId: anilistId, episodeNumbers: episodeNumbers) - } - - /// Get metadata for an episode (non-blocking, returns immediately from cache) - /// - Parameters: - /// - anilistId: The Anilist ID of the anime - /// - episodeNumber: The episode number - /// - Returns: The metadata fetch status - func getMetadataStatus(anilistId: Int, episodeNumber: Int) -> MetadataFetchStatus { - let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)" - return metadataCache[cacheKey] ?? .notRequested - } - - // MARK: - Private Methods - - private func performFetch(anilistId: Int, episodeNumber: Int, cacheKey: String, completion: @escaping (Result) -> Void) { - // Check if there's already an active request for this metadata - if activeRequests[cacheKey] != nil { - // Already fetching, wait for it to complete - waitForRequest(cacheKey: cacheKey, completion: completion) - return - } - - // Reset retry attempts if this is a new fetch - if currentRetryAttempts[cacheKey] == nil { - currentRetryAttempts[cacheKey] = 0 - } - - // Create API request - guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(anilistId)") else { - let error = NSError(domain: "com.sora.metadata", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]) - DispatchQueue.main.async { - self.metadataCache[cacheKey] = .failed(error) - } - completion(.failure(error)) - return - } - - Logger.shared.log("Fetching metadata for episode \(episodeNumber) from network", type: "Debug") - - // Create publisher for the request - let publisher = URLSession.custom.dataTaskPublisher(for: url) - .subscribe(on: fetchQueue) - .tryMap { [weak self] data, response -> EpisodeMetadataInfo in - guard let self = self else { - throw NSError(domain: "com.sora.metadata", code: 4, - userInfo: [NSLocalizedDescriptionKey: "Manager instance released"]) - } - - // Validate response - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw NSError(domain: "com.sora.metadata", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Invalid response"]) - } - - // Parse JSON - let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) - guard let json = jsonObject as? [String: Any] else { - throw NSError(domain: "com.sora.metadata", code: 3, - userInfo: [NSLocalizedDescriptionKey: "Invalid data format"]) - } - - // Check for episodes object - guard let episodes = json["episodes"] as? [String: Any] else { - Logger.shared.log("Missing 'episodes' object in response for anilistId: \(anilistId)", type: "Error") - throw NSError(domain: "com.sora.metadata", code: 3, - userInfo: [NSLocalizedDescriptionKey: "Missing episodes object in response"]) - } - - // Check if episode exists in response - let episodeKey = "\(episodeNumber)" - guard let episodeDetails = episodes[episodeKey] as? [String: Any] else { - Logger.shared.log("Episode \(episodeNumber) not found in response for anilistId: \(anilistId)", type: "Error") - throw NSError(domain: "com.sora.metadata", code: 5, - userInfo: [NSLocalizedDescriptionKey: "Episode \(episodeNumber) not found in response"]) - } - - // Extract available fields, log if they're missing - var title: [String: String] = [:] - var image: String = "" - var missingFields: [String] = [] - - // Try to get title - if let titleData = episodeDetails["title"] as? [String: String] { - title = titleData - - // Check if we have valid title values - if title.isEmpty || title.values.allSatisfy({ $0.isEmpty }) { - missingFields.append("title (all values empty)") - } - } else { - missingFields.append("title") - // Create default empty title dictionary - title = ["en": "Episode \(episodeNumber)"] - } - - // Try to get image - if let imageUrl = episodeDetails["image"] as? String { - image = imageUrl - - if imageUrl.isEmpty { - missingFields.append("image (empty string)") - } - } else { - missingFields.append("image") - // Use a default placeholder image - image = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" - } - - // Log missing fields but continue processing - if !missingFields.isEmpty { - Logger.shared.log("Episode \(episodeNumber) for anilistId \(anilistId) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning") - } - - // Create metadata object with whatever we have - let metadataInfo = EpisodeMetadataInfo( - title: title, - imageUrl: image, - anilistId: anilistId, - episodeNumber: episodeNumber - ) - - // Cache the metadata - - // Reset retry count on success (even with missing fields) - self.currentRetryAttempts.removeValue(forKey: cacheKey) - - return metadataInfo - } - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] result in - // Handle completion - guard let self = self else { return } - - switch result { - case .finished: - break - case .failure(let error): - // Handle retry logic - var shouldRetry = false - let currentAttempt = self.currentRetryAttempts[cacheKey] ?? 0 - - // Check if we should retry based on the error and attempt count - if currentAttempt < self.maxRetryAttempts { - // Increment attempt counter - let nextAttempt = currentAttempt + 1 - self.currentRetryAttempts[cacheKey] = nextAttempt - - // Calculate backoff delay using exponential backoff - let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(currentAttempt)) - - Logger.shared.log("Metadata fetch failed, retrying (attempt \(nextAttempt)/\(self.maxRetryAttempts)) in \(backoffDelay) seconds", type: "Debug") - - // Schedule retry after backoff delay - DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) { - // Remove the current request before retrying - self.activeRequests.removeValue(forKey: cacheKey) - self.performFetch(anilistId: anilistId, episodeNumber: episodeNumber, cacheKey: cacheKey, completion: completion) - } - shouldRetry = true - } else { - // Max retries reached - Logger.shared.log("Metadata fetch failed after \(self.maxRetryAttempts) attempts: \(error.localizedDescription)", type: "Error") - self.currentRetryAttempts.removeValue(forKey: cacheKey) - } - - if !shouldRetry { - // Update cache with error - self.metadataCache[cacheKey] = .failed(error) - completion(.failure(error)) - // Remove from active requests - self.activeRequests.removeValue(forKey: cacheKey) - } - } - }, receiveValue: { [weak self] metadataInfo in - // Update cache with result - self?.metadataCache[cacheKey] = .fetched(metadataInfo) - completion(.success(metadataInfo)) - - // Remove from active requests - self?.activeRequests.removeValue(forKey: cacheKey) - }) - - // Store publisher in active requests - activeRequests[cacheKey] = publisher - } - - private func fetchBatchFromNetwork(anilistId: Int, episodeNumbers: [Int]) { - // This API returns all episodes for a show in one call, so we only need one request - guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(anilistId)") else { - Logger.shared.log("Invalid URL for batch fetch", type: "Error") - return - } - - Logger.shared.log("Batch fetching \(episodeNumbers.count) episodes from network", type: "Debug") - - let batchCacheKey = "batch_\(anilistId)_\(episodeNumbers.map { String($0) }.joined(separator: "_"))" - - // Reset retry attempts if this is a new fetch - if currentRetryAttempts[batchCacheKey] == nil { - currentRetryAttempts[batchCacheKey] = 0 - } - - // Create publisher for the request - let publisher = URLSession.custom.dataTaskPublisher(for: url) - .subscribe(on: fetchQueue) - .tryMap { [weak self] data, response -> [Int: EpisodeMetadataInfo] in - guard let self = self else { - throw NSError(domain: "com.sora.metadata", code: 4, - userInfo: [NSLocalizedDescriptionKey: "Manager instance released"]) - } - - // Validate response - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw NSError(domain: "com.sora.metadata", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Invalid response"]) - } - - // Parse JSON - let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) - guard let json = jsonObject as? [String: Any] else { - throw NSError(domain: "com.sora.metadata", code: 3, - userInfo: [NSLocalizedDescriptionKey: "Invalid data format"]) - } - - guard let episodes = json["episodes"] as? [String: Any] else { - Logger.shared.log("Missing 'episodes' object in response for anilistId: \(anilistId)", type: "Error") - throw NSError(domain: "com.sora.metadata", code: 3, - userInfo: [NSLocalizedDescriptionKey: "Missing episodes object in response"]) - } - - // Check if we have at least one requested episode - let hasAnyRequestedEpisode = episodeNumbers.contains { episodeNumber in - return episodes["\(episodeNumber)"] != nil - } - - if !hasAnyRequestedEpisode { - Logger.shared.log("None of the requested episodes were found for anilistId: \(anilistId)", type: "Error") - throw NSError(domain: "com.sora.metadata", code: 5, - userInfo: [NSLocalizedDescriptionKey: "None of the requested episodes were found"]) - } - - // Process each requested episode - var results: [Int: EpisodeMetadataInfo] = [:] - var missingEpisodes: [Int] = [] - var episodesWithMissingFields: [String] = [] - - for episodeNumber in episodeNumbers { - let episodeKey = "\(episodeNumber)" - - // Check if this episode exists in the response - if let episodeDetails = episodes[episodeKey] as? [String: Any] { - var title: [String: String] = [:] - var image: String = "" - var missingFields: [String] = [] - - // Try to get title - if let titleData = episodeDetails["title"] as? [String: String] { - title = titleData - - // Check if we have valid title values - if title.isEmpty || title.values.allSatisfy({ $0.isEmpty }) { - missingFields.append("title (all values empty)") - } - } else { - missingFields.append("title") - // Create default empty title dictionary - title = ["en": "Episode \(episodeNumber)"] - } - - // Try to get image - if let imageUrl = episodeDetails["image"] as? String { - image = imageUrl - - if imageUrl.isEmpty { - missingFields.append("image (empty string)") - } - } else { - missingFields.append("image") - // Use a default placeholder image - image = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" - } - - // Log if we're missing any fields - if !missingFields.isEmpty { - episodesWithMissingFields.append("Episode \(episodeNumber): missing \(missingFields.joined(separator: ", "))") - } - - // Create metadata object with whatever we have - let metadataInfo = EpisodeMetadataInfo( - title: title, - imageUrl: image, - anilistId: anilistId, - episodeNumber: episodeNumber - ) - - results[episodeNumber] = metadataInfo - - // Cache the metadata - } else { - missingEpisodes.append(episodeNumber) - } - } - - // Log information about missing episodes - if !missingEpisodes.isEmpty { - Logger.shared.log("Episodes not found in response: \(missingEpisodes.map { String($0) }.joined(separator: ", "))", type: "Warning") - } - - // Log information about episodes with missing fields - if !episodesWithMissingFields.isEmpty { - Logger.shared.log("Episodes with missing fields: \(episodesWithMissingFields.joined(separator: "; "))", type: "Warning") - } - - // If we didn't get data for all requested episodes but got some, consider it a partial success - if results.count < episodeNumbers.count && results.count > 0 { - Logger.shared.log("Partial data received: \(results.count)/\(episodeNumbers.count) episodes", type: "Warning") - } - - // If we didn't get any valid results, throw an error to trigger retry - if results.isEmpty { - throw NSError(domain: "com.sora.metadata", code: 7, - userInfo: [NSLocalizedDescriptionKey: "No valid episode data found in response"]) - } - - // Reset retry count on success (even partial) - self.currentRetryAttempts.removeValue(forKey: batchCacheKey) - - return results - } - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] result in - // Handle completion - guard let self = self else { return } - - switch result { - case .finished: - break - case .failure(let error): - // Handle retry logic - var shouldRetry = false - let currentAttempt = self.currentRetryAttempts[batchCacheKey] ?? 0 - - // Check if we should retry based on the error and attempt count - if currentAttempt < self.maxRetryAttempts { - // Increment attempt counter - let nextAttempt = currentAttempt + 1 - self.currentRetryAttempts[batchCacheKey] = nextAttempt - - // Calculate backoff delay using exponential backoff - let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(currentAttempt)) - - Logger.shared.log("Batch fetch failed, retrying (attempt \(nextAttempt)/\(self.maxRetryAttempts)) in \(backoffDelay) seconds", type: "Debug") - - // Schedule retry after backoff delay - DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) { - // Remove the current request before retrying - self.activeRequests.removeValue(forKey: batchCacheKey) - self.fetchBatchFromNetwork(anilistId: anilistId, episodeNumbers: episodeNumbers) - } - shouldRetry = true - } else { - // Max retries reached - Logger.shared.log("Batch fetch failed after \(self.maxRetryAttempts) attempts: \(error.localizedDescription)", type: "Error") - self.currentRetryAttempts.removeValue(forKey: batchCacheKey) - - // Update all requested episodes with error - for episodeNumber in episodeNumbers { - let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)" - self.metadataCache[cacheKey] = .failed(error) - } - } - - if !shouldRetry { - // Remove from active requests - self.activeRequests.removeValue(forKey: batchCacheKey) - } - } - }, receiveValue: { [weak self] results in - // Update cache with results - for (episodeNumber, metadataInfo) in results { - let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)" - self?.metadataCache[cacheKey] = .fetched(metadataInfo) - } - - // Log the results - Logger.shared.log("Batch fetch completed with \(results.count) episodes", type: "Debug") - - // Remove from active requests - self?.activeRequests.removeValue(forKey: batchCacheKey) - }) - - // Store publisher in active requests - activeRequests[batchCacheKey] = publisher - } - - private func waitForRequest(cacheKey: String, completion: @escaping (Result) -> Void) { - // Set up a timer to check the cache periodically - let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in - guard let self = self else { - timer.invalidate() - return - } - - if let status = self.metadataCache[cacheKey] { - switch status { - case .fetched(let metadata): - // Request completed successfully - timer.invalidate() - completion(.success(metadata)) - case .failed(let error): - // Request failed - timer.invalidate() - completion(.failure(error)) - case .fetching, .notRequested: - // Still in progress - break - } - } - } - - // Ensure timer fires even when scrolling - RunLoop.current.add(timer, forMode: .common) - } -} - -// Extension to EpisodeMetadata for integration with the new manager -extension EpisodeMetadata { - func toData() -> Data? { - // Convert to EpisodeMetadataInfo first - let info = EpisodeMetadataInfo( - title: self.title, - imageUrl: self.imageUrl, - anilistId: self.anilistId, - episodeNumber: self.episodeNumber - ) - - // Then encode to Data - return try? JSONEncoder().encode(info) - } - - static func fromData(_ data: Data) -> EpisodeMetadata? { - guard let info = try? JSONDecoder().decode(EpisodeMetadataInfo.self, from: data) else { - return nil - } - - return EpisodeMetadata( - title: info.title, - imageUrl: info.imageUrl, - anilistId: info.anilistId, - episodeNumber: info.episodeNumber - ) - } -} \ No newline at end of file diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 3c62d01..79f8db0 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -58,8 +58,6 @@ struct SoraApp: App { @StateObject private var jsController = JSController.shared init() { - _ = KingfisherCacheManager.shared - if let userAccentColor = UserDefaults.standard.color(forKey: "accentColor") { UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = userAccentColor } diff --git a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift index 934b0f8..a390e68 100644 --- a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift +++ b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift @@ -203,3 +203,21 @@ class AniListMutation { let data: DataField } } + +struct EpisodeMetadataInfo: Codable, Equatable { + let title: [String: String] + let imageUrl: String + let anilistId: Int + let episodeNumber: Int + + var cacheKey: String { + return "anilist_\(anilistId)_episode_\(episodeNumber)" + } +} + +enum MetadataFetchStatus { + case notRequested + case fetching + case fetched(EpisodeMetadataInfo) + case failed(Error) +} diff --git a/Sora/Utils/Cache/EpisodeMetadata.swift b/Sora/Utils/Cache/EpisodeMetadata.swift deleted file mode 100644 index c27e25a..0000000 --- a/Sora/Utils/Cache/EpisodeMetadata.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// EpisodeMetadata.swift -// Sora -// -// Created by doomsboygaming on 5/22/25 -// - -import Foundation - -/// Represents metadata for an episode, used for caching -struct EpisodeMetadata: Codable { - /// Title of the episode - let title: [String: String] - - /// Image URL for the episode - let imageUrl: String - - /// AniList ID of the show - let anilistId: Int - - /// Episode number - let episodeNumber: Int - - /// When this metadata was cached - let cacheDate: Date - - /// Unique cache key for this episode metadata - var cacheKey: String { - return "anilist_\(anilistId)_episode_\(episodeNumber)" - } - - /// Initialize with the basic required data - /// - Parameters: - /// - title: Dictionary of titles by language code - /// - imageUrl: URL of the episode thumbnail image - /// - anilistId: ID of the show in AniList - /// - episodeNumber: Number of the episode - init(title: [String: String], imageUrl: String, anilistId: Int, episodeNumber: Int) { - self.title = title - self.imageUrl = imageUrl - self.anilistId = anilistId - self.episodeNumber = episodeNumber - self.cacheDate = Date() - } -} \ No newline at end of file diff --git a/Sora/Utils/Cache/JPEGCompressionProcessor.swift b/Sora/Utils/Cache/JPEGCompressionProcessor.swift deleted file mode 100644 index 88b7f0d..0000000 --- a/Sora/Utils/Cache/JPEGCompressionProcessor.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// JPEGCompressionProcessor.swift -// Sora -// -// Created by Francesco on 02/06/25. -// - -import UIKit -import Kingfisher - -struct JPEGCompressionProcessor: ImageProcessor { - let identifier: String - let compressionQuality: CGFloat - - init(compressionQuality: CGFloat) { - self.compressionQuality = compressionQuality - self.identifier = "me.cranci.JPEGCompressionProcessor_\(compressionQuality)" - } - - func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? { - switch item { - case .image(let image): - guard let data = image.jpegData(compressionQuality: compressionQuality), - let compressedImage = UIImage(data: data) else { - return image - } - return compressedImage - case .data(let data): - guard let image = UIImage(data: data) else { return nil } - guard let compressedData = image.jpegData(compressionQuality: compressionQuality), - let compressedImage = UIImage(data: compressedData) else { - return image - } - return compressedImage - } - } -} diff --git a/Sora/Utils/Cache/KingfisherManager.swift b/Sora/Utils/Cache/KingfisherManager.swift deleted file mode 100644 index 7da1d25..0000000 --- a/Sora/Utils/Cache/KingfisherManager.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// KingfisherManager.swift -// Sora -// -// Created by doomsboygaming on 5/22/25 -// - - -import SwiftUI -import Foundation -import Kingfisher - -class KingfisherCacheManager { - private let jpegCompressionQuality: CGFloat = 0.7 - - static let shared = KingfisherCacheManager() - private let maxDiskCacheSize: UInt = 16 * 1024 * 1024 - private let maxCacheAgeInDays: TimeInterval = 7 - - private let imageCachingEnabledKey = "imageCachingEnabled" - - var isCachingEnabled: Bool { - get { - UserDefaults.standard.object(forKey: imageCachingEnabledKey) == nil ? - true : UserDefaults.standard.bool(forKey: imageCachingEnabledKey) - } - set { - UserDefaults.standard.set(newValue, forKey: imageCachingEnabledKey) - configureKingfisher() - } - } - - private init() { - configureKingfisher() -#if os(iOS) - NotificationCenter.default.addObserver(self, selector: #selector(clearMemoryCacheOnWarning), name: UIApplication.didReceiveMemoryWarningNotification, object: nil) -#endif - } - - @objc private func clearMemoryCacheOnWarning() { - KingfisherManager.shared.cache.clearMemoryCache() - KingfisherManager.shared.cache.clearDiskCache { - Logger.shared.log("Cleared memory and disk cache due to memory warning", type: "Debug") - } - } - - func configureKingfisher() { - let cache = ImageCache.default - - cache.diskStorage.config.sizeLimit = isCachingEnabled ? maxDiskCacheSize : 0 - cache.diskStorage.config.expiration = isCachingEnabled ? - .days(Int(maxCacheAgeInDays)) : .seconds(1) - - cache.memoryStorage.config.totalCostLimit = isCachingEnabled ? - 4 * 1024 * 1024 : 0 - - cache.memoryStorage.config.cleanInterval = 60 - - KingfisherManager.shared.downloader.downloadTimeout = 15.0 - - let processor = JPEGCompressionProcessor(compressionQuality: jpegCompressionQuality) - KingfisherManager.shared.defaultOptions = [.processor(processor)] - - Logger.shared.log("Configured Kingfisher cache. Enabled: \(isCachingEnabled) | JPEG Compression: \(jpegCompressionQuality)", type: "Debug") - } - - func clearCache(completion: (() -> Void)? = nil) { - KingfisherManager.shared.cache.clearMemoryCache() - KingfisherManager.shared.cache.clearDiskCache { - Logger.shared.log("Cleared Kingfisher image cache", type: "General") - completion?() - } - } - - func calculateCacheSize(completion: @escaping (UInt) -> Void) { - KingfisherManager.shared.cache.calculateDiskStorageSize { result in - switch result { - case .success(let size): - completion(size) - case .failure(let error): - Logger.shared.log("Failed to calculate image cache size: \(error)", type: "Error") - completion(0) - } - } - } - - static func formatCacheSize(_ sizeInBytes: UInt) -> String { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - return formatter.string(fromByteCount: Int64(sizeInBytes)) - } -} diff --git a/Sora/Utils/Cache/MetadataCacheManager.swift b/Sora/Utils/Cache/MetadataCacheManager.swift deleted file mode 100644 index d2f0380..0000000 --- a/Sora/Utils/Cache/MetadataCacheManager.swift +++ /dev/null @@ -1,279 +0,0 @@ -// -// MetadataCacheManager.swift -// Sora -// -// Created by doomsboygaming on 5/22/25 -// - -import Foundation -import SwiftUI -import CryptoKit - -/// A class to manage episode metadata caching, both in-memory and on disk -class MetadataCacheManager { - static let shared = MetadataCacheManager() - - // In-memory cache - private let memoryCache = NSCache() - - // File manager for disk operations - private let fileManager = FileManager.default - - // Cache directory URL - private var cacheDirectory: URL - - // Cache expiration - 7 days by default - private let maxCacheAge: TimeInterval = 7 * 24 * 60 * 60 - - // UserDefaults keys - private let metadataCachingEnabledKey = "metadataCachingEnabled" - private let memoryOnlyModeKey = "metadataMemoryOnlyCache" - private let lastCacheCleanupKey = "lastMetadataCacheCleanup" - - // Analytics counters - private(set) var cacheHits: Int = 0 - private(set) var cacheMisses: Int = 0 - - // MARK: - Public properties - - /// Whether metadata caching is enabled (persisted in UserDefaults) - var isCachingEnabled: Bool { - get { - // Default to true if not set - UserDefaults.standard.object(forKey: metadataCachingEnabledKey) == nil ? - true : UserDefaults.standard.bool(forKey: metadataCachingEnabledKey) - } - set { - UserDefaults.standard.set(newValue, forKey: metadataCachingEnabledKey) - } - } - - /// Whether to use memory-only mode (no disk caching) - var isMemoryOnlyMode: Bool { - get { - UserDefaults.standard.bool(forKey: memoryOnlyModeKey) - } - set { - UserDefaults.standard.set(newValue, forKey: memoryOnlyModeKey) - } - } - - // MARK: - Initialization - - private init() { - // Set up cache directory - do { - let cachesDirectory = try fileManager.url( - for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - cacheDirectory = cachesDirectory.appendingPathComponent("EpisodeMetadata", isDirectory: true) - - // Create the directory if it doesn't exist - if !fileManager.fileExists(atPath: cacheDirectory.path) { - try fileManager.createDirectory(at: cacheDirectory, - withIntermediateDirectories: true, - attributes: nil) - } - - // Set up memory cache - memoryCache.name = "EpisodeMetadataCache" - memoryCache.countLimit = 100 // Limit number of items in memory - - // Clean up old files if needed - cleanupOldCacheFilesIfNeeded() - - } catch { - Logger.shared.log("Failed to set up metadata cache directory: \(error)", type: "Error") - // Fallback to temporary directory - cacheDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("EpisodeMetadata") - } - } - - // MARK: - Public Methods - - /// Store metadata in the cache - /// - Parameters: - /// - data: The metadata to cache - /// - key: The cache key (usually anilist_id + episode_number) - private func safeFileName(for key: String) -> String { - let hash = SHA256.hash(data: Data(key.utf8)) - return hash.compactMap { String(format: "%02x", $0) }.joined() - } - - func storeMetadata(_ data: Data, forKey key: String) { - guard isCachingEnabled else { return } - let keyString = key as NSString - memoryCache.setObject(data as NSData, forKey: keyString) - if !isMemoryOnlyMode { - let fileName = safeFileName(for: key) - let fileURL = cacheDirectory.appendingPathComponent(fileName) - let tempURL = fileURL.appendingPathExtension("tmp") - DispatchQueue.global(qos: .background).async { [weak self] in - do { - try data.write(to: tempURL) - try self?.fileManager.moveItem(at: tempURL, to: fileURL) - - // Add timestamp as a file attribute instead of using extended attributes - let attributes: [FileAttributeKey: Any] = [ - .creationDate: Date() - ] - try self?.fileManager.setAttributes(attributes, ofItemAtPath: fileURL.path) - - Logger.shared.log("Metadata cached for key: \(key)", type: "Debug") - } catch { - Logger.shared.log("Failed to write metadata to disk: \(error)", type: "Error") - } - } - } - } - - /// Retrieve metadata from cache - /// - Parameter key: The cache key - /// - Returns: The cached metadata if available and not expired, nil otherwise - func getMetadata(forKey key: String) -> Data? { - guard isCachingEnabled else { - return nil - } - - let keyString = key as NSString - - // Try memory cache first - if let cachedData = memoryCache.object(forKey: keyString) as Data? { - return cachedData - } - - // If not in memory and not in memory-only mode, try disk - if !isMemoryOnlyMode { - let fileURL = cacheDirectory.appendingPathComponent(key) - - do { - // Check if file exists - if fileManager.fileExists(atPath: fileURL.path) { - // Check if the file is not expired - if !isFileExpired(at: fileURL) { - let data = try Data(contentsOf: fileURL) - - // Store in memory cache for faster access next time - memoryCache.setObject(data as NSData, forKey: keyString) - - return data - } else { - // File is expired, remove it - try fileManager.removeItem(at: fileURL) - } - } - } catch { - Logger.shared.log("Error accessing disk cache: \(error)", type: "Error") - } - } - - return nil - } - - /// Clear all cached metadata - func clearAllCache() { - // Clear memory cache - memoryCache.removeAllObjects() - - // Clear disk cache - if !isMemoryOnlyMode { - do { - let fileURLs = try fileManager.contentsOfDirectory(at: cacheDirectory, - includingPropertiesForKeys: nil, - options: .skipsHiddenFiles) - - for fileURL in fileURLs { - try fileManager.removeItem(at: fileURL) - } - - Logger.shared.log("Cleared all metadata cache", type: "General") - } catch { - Logger.shared.log("Failed to clear disk cache: \(error)", type: "Error") - } - } - - // Reset analytics - cacheHits = 0 - cacheMisses = 0 - } - - /// Clear expired cache entries - func clearExpiredCache() { - guard !isMemoryOnlyMode else { return } - - do { - let fileURLs = try fileManager.contentsOfDirectory(at: cacheDirectory, - includingPropertiesForKeys: nil, - options: .skipsHiddenFiles) - - var removedCount = 0 - - for fileURL in fileURLs { - if isFileExpired(at: fileURL) { - try fileManager.removeItem(at: fileURL) - removedCount += 1 - } - } - - if removedCount > 0 { - Logger.shared.log("Cleared \(removedCount) expired metadata cache items", type: "General") - } - } catch { - Logger.shared.log("Failed to clear expired cache: \(error)", type: "Error") - } - } - - /// Get the total size of the cache on disk - /// - Returns: Size in bytes - func getCacheSize() -> Int64 { - guard !isMemoryOnlyMode else { return 0 } - - do { - let fileURLs = try fileManager.contentsOfDirectory(at: cacheDirectory, - includingPropertiesForKeys: [.fileSizeKey], - options: .skipsHiddenFiles) - - return fileURLs.reduce(0) { result, url in - do { - let attributes = try url.resourceValues(forKeys: [.fileSizeKey]) - return result + Int64(attributes.fileSize ?? 0) - } catch { - return result - } - } - } catch { - Logger.shared.log("Failed to calculate cache size: \(error)", type: "Error") - return 0 - } - } - - // MARK: - Private Helper Methods - - private func isFileExpired(at url: URL) -> Bool { - do { - let attributes = try fileManager.attributesOfItem(atPath: url.path) - if let creationDate = attributes[.creationDate] as? Date { - return Date().timeIntervalSince(creationDate) > maxCacheAge - } - return true // If can't determine age, consider it expired - } catch { - return true // If error reading attributes, consider it expired - } - } - - private func cleanupOldCacheFilesIfNeeded() { - // Only run cleanup once a day - let lastCleanupTime = UserDefaults.standard.double(forKey: lastCacheCleanupKey) - let dayInSeconds: TimeInterval = 24 * 60 * 60 - - if Date().timeIntervalSince1970 - lastCleanupTime > dayInSeconds { - DispatchQueue.global(qos: .background).async { [weak self] in - self?.clearExpiredCache() - UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: self?.lastCacheCleanupKey ?? "") - } - } - } -} \ No newline at end of file diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift index 6008bb7..3e98561 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift @@ -163,44 +163,6 @@ struct SettingsViewData: View { title: "Cache Settings", footer: "Caching helps reduce network usage and load content faster. You can disable it to save storage space." ) { - SettingsToggleRow( - icon: "doc.text", - title: "Enable Metadata Caching", - isOn: $isMetadataCachingEnabled - ) - .onChange(of: isMetadataCachingEnabled) { newValue in - // MetadataCacheManager removed - if !newValue { - calculateCacheSize() - } - } - - SettingsToggleRow( - icon: "photo", - title: "Enable Image Caching", - isOn: $isImageCachingEnabled - ) - .onChange(of: isImageCachingEnabled) { newValue in - KingfisherCacheManager.shared.isCachingEnabled = newValue - if !newValue { - calculateCacheSize() - } - } - - if isMetadataCachingEnabled { - SettingsToggleRow( - icon: "memorychip", - title: "Memory-Only Mode", - isOn: $isMemoryOnlyMode - ) - .onChange(of: isMemoryOnlyMode) { newValue in - // MetadataCacheManager removed - if newValue { - calculateCacheSize() - } - } - } - HStack { Image(systemName: "folder.badge.gearshape") .frame(width: 24, height: 24) @@ -225,7 +187,7 @@ struct SettingsViewData: View { Divider().padding(.horizontal, 16) - Button(action: clearAllCaches) { + Button(action: clearCache) { Text("Clear All Caches") .foregroundColor(.red) } @@ -273,8 +235,6 @@ struct SettingsViewData: View { .scrollViewBottomPadding() .navigationTitle("App Data") .onAppear { - isImageCachingEnabled = KingfisherCacheManager.shared.isCachingEnabled - calculateCacheSize() updateSizes() } .alert(isPresented: $showAlert) { @@ -310,30 +270,6 @@ struct SettingsViewData: View { } } - - func calculateCacheSize() { - isCalculatingSize = true - cacheSizeText = "Calculating..." - DispatchQueue.global(qos: .background).async { - var totalSize: Int64 = 0 - KingfisherCacheManager.shared.calculateCacheSize { imageSize in - totalSize += Int64(imageSize) - DispatchQueue.main.async { - self.cacheSizeText = KingfisherCacheManager.formatCacheSize(UInt(totalSize)) - self.isCalculatingSize = false - } - } - } - } - - func clearAllCaches() { - // MetadataCacheManager removed - KingfisherCacheManager.shared.clearCache { - calculateCacheSize() - } - Logger.shared.log("All caches cleared", type: "General") - } - func eraseAppData() { if let domain = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: domain) @@ -345,6 +281,7 @@ struct SettingsViewData: View { func clearCache() { let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + do { if let cacheURL = cacheURL { let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: []) @@ -352,7 +289,6 @@ struct SettingsViewData: View { try FileManager.default.removeItem(at: filePath) } Logger.shared.log("Cache cleared successfully!", type: "General") - calculateCacheSize() updateSizes() } } catch { @@ -415,7 +351,7 @@ struct SettingsViewData: View { let formatter = ByteCountFormatter() formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] formatter.countStyle = .file - return formatter.string(fromByteCount: bytes) ?? "\(bytes) bytes" + return formatter.string(fromByteCount: bytes) } func updateSizes() { diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 001dc8b..61d7e9c 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -53,7 +53,6 @@ 13637B932DE0ECDB00BDA2FC /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 13637B922DE0ECDB00BDA2FC /* MarqueeLabel */; }; 136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136BBE7F2DB1038000906B5E /* Notification+Name.swift */; }; 137932862DEE28BA006E4BFC /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 137932852DEE28BA006E4BFC /* Kingfisher */; }; - 138A7F542DEDA978005E148F /* JPEGCompressionProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138A7F532DEDA978005E148F /* JPEGCompressionProcessor.swift */; }; 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; }; 138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; }; @@ -86,9 +85,6 @@ 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; 1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */; }; - 7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */; }; - 7205AEDC2DCCEF9500943F3F /* MetadataCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */; }; - 7205AEDD2DCCEF9500943F3F /* KingfisherManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205AED82DCCEF9500943F3F /* KingfisherManager.swift */; }; 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */; }; 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */; }; 722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */; }; @@ -98,7 +94,6 @@ 72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; }; 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */; }; 727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */; }; - 72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */; }; 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; }; /* End PBXBuildFile section */ @@ -148,7 +143,6 @@ 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = ""; }; 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; 136BBE7F2DB1038000906B5E /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = ""; }; - 138A7F532DEDA978005E148F /* JPEGCompressionProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JPEGCompressionProcessor.swift; sourceTree = ""; }; 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = ""; }; 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = ""; }; 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-FetchID.swift"; sourceTree = ""; }; @@ -182,9 +176,6 @@ 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = ""; }; - 7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadata.swift; sourceTree = ""; }; - 7205AED82DCCEF9500943F3F /* KingfisherManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherManager.swift; sourceTree = ""; }; - 7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataCacheManager.swift; sourceTree = ""; }; 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = ""; }; 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = ""; }; 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = ""; }; @@ -194,7 +185,6 @@ 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = ""; }; 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = ""; }; 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = ""; }; - 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadataManager.swift; sourceTree = ""; }; 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -325,7 +315,6 @@ children = ( 130C6BF82D53A4C200DC1432 /* Sora.entitlements */, 13DC0C412D2EC9BA00D0F966 /* Info.plist */, - 72AC3A002DD4DAEA00C60B96 /* Managers */, 13103E802D589D6C000F0673 /* Tracking Services */, 133D7C852D2BE2640075467E /* Utils */, 133D7C7B2D2BE2630075467E /* Views */, @@ -391,7 +380,6 @@ 04F08EE02DE10C22006B29D9 /* Models */, 04F08EDD2DE10C05006B29D9 /* TabBar */, 04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */, - 7205AEDA2DCCEF9500943F3F /* Cache */, 13D842532D45266900EBBFA6 /* Drops */, 1399FAD12D3AB33D00E97C31 /* Logger */, 133D7C882D2BE2640075467E /* Modules */, @@ -602,17 +590,6 @@ path = Components; sourceTree = ""; }; - 7205AEDA2DCCEF9500943F3F /* Cache */ = { - isa = PBXGroup; - children = ( - 7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */, - 138A7F532DEDA978005E148F /* JPEGCompressionProcessor.swift */, - 7205AED82DCCEF9500943F3F /* KingfisherManager.swift */, - 7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */, - ); - path = Cache; - sourceTree = ""; - }; 72443C832DC8046500A61321 /* DownloadUtils */ = { isa = PBXGroup; children = ( @@ -623,14 +600,6 @@ path = DownloadUtils; sourceTree = ""; }; - 72AC3A002DD4DAEA00C60B96 /* Managers */ = { - isa = PBXGroup; - children = ( - 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */, - ); - path = Managers; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -722,10 +691,7 @@ 132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */, 1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, - 7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */, 04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */, - 7205AEDC2DCCEF9500943F3F /* MetadataCacheManager.swift in Sources */, - 7205AEDD2DCCEF9500943F3F /* KingfisherManager.swift in Sources */, 1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */, 136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.swift in Sources */, @@ -787,11 +753,9 @@ 0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */, 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */, 04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */, - 72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */, 72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */, 13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */, 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */, - 138A7F542DEDA978005E148F /* JPEGCompressionProcessor.swift in Sources */, 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */, 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */, 13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */,