mirror of
https://github.com/cranci1/Sora.git
synced 2026-04-21 16:42:01 +00:00
test cache stuff removed
This commit is contained in:
parent
a834e41570
commit
1e909ca9eb
12 changed files with 35 additions and 2124 deletions
|
|
@ -1,646 +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<EpisodeMetadataInfo, Error>) -> 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<EpisodeMetadataInfo, Error>) -> 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
|
|
||||||
if MetadataCacheManager.shared.isCachingEnabled {
|
|
||||||
let metadata = EpisodeMetadata(
|
|
||||||
title: title,
|
|
||||||
imageUrl: image,
|
|
||||||
anilistId: anilistId,
|
|
||||||
episodeNumber: episodeNumber
|
|
||||||
)
|
|
||||||
|
|
||||||
if let metadataData = metadata.toData() {
|
|
||||||
MetadataCacheManager.shared.storeMetadata(
|
|
||||||
metadataData,
|
|
||||||
forKey: cacheKey
|
|
||||||
)
|
|
||||||
Logger.shared.log("Cached metadata for episode \(episodeNumber)", type: "Debug")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
if MetadataCacheManager.shared.isCachingEnabled {
|
|
||||||
let metadata = EpisodeMetadata(
|
|
||||||
title: title,
|
|
||||||
imageUrl: image,
|
|
||||||
anilistId: anilistId,
|
|
||||||
episodeNumber: episodeNumber
|
|
||||||
)
|
|
||||||
|
|
||||||
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
|
|
||||||
if let metadataData = metadata.toData() {
|
|
||||||
MetadataCacheManager.shared.storeMetadata(
|
|
||||||
metadataData,
|
|
||||||
forKey: cacheKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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<EpisodeMetadataInfo, Error>) -> 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
//
|
|
||||||
// ImagePrefetchManager.swift
|
|
||||||
// Sora
|
|
||||||
//
|
|
||||||
// Created by doomsboygaming on 5/22/25
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Kingfisher
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
/// Manager for image prefetching, caching, and optimization
|
|
||||||
class ImagePrefetchManager {
|
|
||||||
static let shared = ImagePrefetchManager()
|
|
||||||
|
|
||||||
// Prefetcher for batch prefetching images
|
|
||||||
private let prefetcher = ImagePrefetcher(
|
|
||||||
urls: [],
|
|
||||||
options: [
|
|
||||||
.processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))),
|
|
||||||
.scaleFactor(UIScreen.main.scale),
|
|
||||||
.cacheOriginalImage
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Keep track of what's already prefetched to avoid duplication
|
|
||||||
private var prefetchedURLs = Set<URL>()
|
|
||||||
private let prefetchQueue = DispatchQueue(label: "com.sora.imagePrefetch", qos: .utility)
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Set up KingfisherManager for optimal image loading
|
|
||||||
ImageCache.default.memoryStorage.config.totalCostLimit = 300 * 1024 * 1024 // 300MB
|
|
||||||
ImageCache.default.diskStorage.config.sizeLimit = 1000 * 1024 * 1024 // 1GB
|
|
||||||
ImageDownloader.default.downloadTimeout = 15.0 // 15 seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prefetch a batch of images
|
|
||||||
func prefetchImages(_ urls: [String]) {
|
|
||||||
prefetchQueue.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
// Filter out already prefetched URLs and invalid URLs
|
|
||||||
let urlObjects = urls.compactMap { URL(string: $0) }
|
|
||||||
.filter { !self.prefetchedURLs.contains($0) }
|
|
||||||
|
|
||||||
guard !urlObjects.isEmpty else { return }
|
|
||||||
|
|
||||||
// Create a new prefetcher with the URLs and start it
|
|
||||||
let newPrefetcher = ImagePrefetcher(
|
|
||||||
urls: urlObjects,
|
|
||||||
options: [
|
|
||||||
.processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))),
|
|
||||||
.scaleFactor(UIScreen.main.scale),
|
|
||||||
.cacheOriginalImage
|
|
||||||
]
|
|
||||||
)
|
|
||||||
newPrefetcher.start()
|
|
||||||
|
|
||||||
// Track prefetched URLs
|
|
||||||
urlObjects.forEach { self.prefetchedURLs.insert($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prefetch a single image
|
|
||||||
func prefetchImage(_ url: String) {
|
|
||||||
guard let urlObject = URL(string: url),
|
|
||||||
!prefetchedURLs.contains(urlObject) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
prefetchQueue.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
// Create a new prefetcher with the URL and start it
|
|
||||||
let newPrefetcher = ImagePrefetcher(
|
|
||||||
urls: [urlObject],
|
|
||||||
options: [
|
|
||||||
.processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))),
|
|
||||||
.scaleFactor(UIScreen.main.scale),
|
|
||||||
.cacheOriginalImage
|
|
||||||
]
|
|
||||||
)
|
|
||||||
newPrefetcher.start()
|
|
||||||
|
|
||||||
// Track prefetched URL
|
|
||||||
self.prefetchedURLs.insert(urlObject)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prefetch episode images for a batch of episodes
|
|
||||||
func prefetchEpisodeImages(anilistId: Int, startEpisode: Int, count: Int) {
|
|
||||||
prefetchQueue.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
// Get metadata for episodes in the range
|
|
||||||
for episodeNumber in startEpisode...(startEpisode + count) where episodeNumber > 0 {
|
|
||||||
EpisodeMetadataManager.shared.fetchMetadata(anilistId: anilistId, episodeNumber: episodeNumber) { result in
|
|
||||||
switch result {
|
|
||||||
case .success(let metadata):
|
|
||||||
self.prefetchImage(metadata.imageUrl)
|
|
||||||
case .failure:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear prefetch queue and stop any ongoing prefetch operations
|
|
||||||
func cancelPrefetching() {
|
|
||||||
prefetcher.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - KFImage Extension
|
|
||||||
extension KFImage {
|
|
||||||
/// Load an image with optimal settings for episode thumbnails
|
|
||||||
static func optimizedEpisodeThumbnail(url: URL?) -> KFImage {
|
|
||||||
return KFImage(url)
|
|
||||||
.setProcessor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56)))
|
|
||||||
.memoryCacheExpiration(.seconds(300))
|
|
||||||
.cacheOriginalImage()
|
|
||||||
.fade(duration: 0.25)
|
|
||||||
.onProgress { _, _ in
|
|
||||||
// Track progress if needed
|
|
||||||
}
|
|
||||||
.onSuccess { _ in
|
|
||||||
// Success logger removed to reduce logs
|
|
||||||
}
|
|
||||||
.onFailure { error in
|
|
||||||
Logger.shared.log("Failed to load image: \(error)", type: "Error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
//
|
|
||||||
// ImageUpscaler.swift
|
|
||||||
// Sulfur
|
|
||||||
//
|
|
||||||
// Created by seiike on 26/05/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import CoreImage
|
|
||||||
import CoreImage.CIFilterBuiltins
|
|
||||||
import Vision
|
|
||||||
import CoreML
|
|
||||||
import Kingfisher
|
|
||||||
|
|
||||||
public enum ImageUpscaler {
|
|
||||||
/// Lanczos interpolation + unsharp mask for sharper upscaling.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - scale: The factor to upscale (e.g. 2.0 doubles width/height).
|
|
||||||
/// - sharpeningIntensity: The unsharp mask intensity (0...1).
|
|
||||||
/// - sharpeningRadius: The unsharp mask radius in pixels.
|
|
||||||
public static func lanczosProcessor(
|
|
||||||
scale: CGFloat,
|
|
||||||
sharpeningIntensity: Float = 0.7,
|
|
||||||
sharpeningRadius: Float = 2.0
|
|
||||||
) -> ImageProcessor {
|
|
||||||
return LanczosUpscaleProcessor(
|
|
||||||
scale: scale,
|
|
||||||
sharpeningIntensity: sharpeningIntensity,
|
|
||||||
sharpeningRadius: sharpeningRadius
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func superResolutionProcessor(modelURL: URL) -> ImageProcessor {
|
|
||||||
return MLScaleProcessor(modelURL: modelURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Lanczos + Unsharp Mask Processor
|
|
||||||
public struct LanczosUpscaleProcessor: ImageProcessor {
|
|
||||||
public let scale: CGFloat
|
|
||||||
public let sharpeningIntensity: Float
|
|
||||||
public let sharpeningRadius: Float
|
|
||||||
public var identifier: String {
|
|
||||||
"com.yourapp.lanczos_\(scale)_sharp_\(sharpeningIntensity)_\(sharpeningRadius)"
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(
|
|
||||||
scale: CGFloat,
|
|
||||||
sharpeningIntensity: Float = 0.7,
|
|
||||||
sharpeningRadius: Float = 2.0
|
|
||||||
) {
|
|
||||||
self.scale = scale
|
|
||||||
self.sharpeningIntensity = sharpeningIntensity
|
|
||||||
self.sharpeningRadius = sharpeningRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
public func process(
|
|
||||||
item: ImageProcessItem,
|
|
||||||
options: KingfisherParsedOptionsInfo
|
|
||||||
) -> KFCrossPlatformImage? {
|
|
||||||
|
|
||||||
let inputImage: KFCrossPlatformImage?
|
|
||||||
switch item {
|
|
||||||
case .image(let image):
|
|
||||||
inputImage = image
|
|
||||||
case .data(let data):
|
|
||||||
inputImage = KFCrossPlatformImage(data: data)
|
|
||||||
}
|
|
||||||
guard let uiImage = inputImage,
|
|
||||||
let cgImage = uiImage.cgImage else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let ciInput = CIImage(cgImage: cgImage)
|
|
||||||
|
|
||||||
let scaleFilter = CIFilter.lanczosScaleTransform()
|
|
||||||
scaleFilter.inputImage = ciInput
|
|
||||||
scaleFilter.scale = Float(scale)
|
|
||||||
scaleFilter.aspectRatio = 1.0
|
|
||||||
guard let scaledCI = scaleFilter.outputImage else {
|
|
||||||
return uiImage
|
|
||||||
}
|
|
||||||
|
|
||||||
let unsharp = CIFilter.unsharpMask()
|
|
||||||
unsharp.inputImage = scaledCI
|
|
||||||
unsharp.intensity = sharpeningIntensity
|
|
||||||
unsharp.radius = sharpeningRadius
|
|
||||||
guard let sharpCI = unsharp.outputImage else {
|
|
||||||
return UIImage(ciImage: scaledCI)
|
|
||||||
}
|
|
||||||
|
|
||||||
let context = CIContext(options: nil)
|
|
||||||
guard let outputCG = context.createCGImage(sharpCI, from: sharpCI.extent) else {
|
|
||||||
return UIImage(ciImage: sharpCI)
|
|
||||||
}
|
|
||||||
return KFCrossPlatformImage(cgImage: outputCG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Core ML Super-Resolution Processor
|
|
||||||
public struct MLScaleProcessor: ImageProcessor {
|
|
||||||
private let request: VNCoreMLRequest
|
|
||||||
private let ciContext = CIContext()
|
|
||||||
public let identifier: String
|
|
||||||
|
|
||||||
public init(modelURL: URL) {
|
|
||||||
|
|
||||||
self.identifier = "com.yourapp.ml_sr_\(modelURL.lastPathComponent)"
|
|
||||||
guard let mlModel = try? MLModel(contentsOf: modelURL),
|
|
||||||
let visionModel = try? VNCoreMLModel(for: mlModel) else {
|
|
||||||
fatalError("Failed to load Core ML model at \(modelURL)")
|
|
||||||
}
|
|
||||||
let req = VNCoreMLRequest(model: visionModel)
|
|
||||||
req.imageCropAndScaleOption = .scaleFill
|
|
||||||
self.request = req
|
|
||||||
}
|
|
||||||
|
|
||||||
public func process(
|
|
||||||
item: ImageProcessItem,
|
|
||||||
options: KingfisherParsedOptionsInfo
|
|
||||||
) -> KFCrossPlatformImage? {
|
|
||||||
|
|
||||||
let inputImage: KFCrossPlatformImage?
|
|
||||||
switch item {
|
|
||||||
case .image(let image):
|
|
||||||
inputImage = image
|
|
||||||
case .data(let data):
|
|
||||||
inputImage = KFCrossPlatformImage(data: data)
|
|
||||||
}
|
|
||||||
guard let uiImage = inputImage,
|
|
||||||
let cgImage = uiImage.cgImage else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
|
||||||
do {
|
|
||||||
try handler.perform([request])
|
|
||||||
} catch {
|
|
||||||
print("[MLScaleProcessor] Vision error: \(error)")
|
|
||||||
return uiImage
|
|
||||||
}
|
|
||||||
guard let obs = request.results?.first as? VNPixelBufferObservation else {
|
|
||||||
return uiImage
|
|
||||||
}
|
|
||||||
|
|
||||||
let ciOutput = CIImage(cvPixelBuffer: obs.pixelBuffer)
|
|
||||||
let rect = CGRect(
|
|
||||||
origin: .zero,
|
|
||||||
size: CGSize(
|
|
||||||
width: CVPixelBufferGetWidth(obs.pixelBuffer),
|
|
||||||
height: CVPixelBufferGetHeight(obs.pixelBuffer)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
guard let finalCG = ciContext.createCGImage(ciOutput, from: rect) else {
|
|
||||||
return uiImage
|
|
||||||
}
|
|
||||||
return KFCrossPlatformImage(cgImage: finalCG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// the sweet spot (for mediainfoview poster)
|
|
||||||
// .setProcessor(ImageUpscaler.lanczosProcessor(scale: 3.2,
|
|
||||||
// sharpeningIntensity: 0.75,
|
|
||||||
// sharpeningRadius: 2.25))
|
|
||||||
|
|
@ -1,510 +0,0 @@
|
||||||
//
|
|
||||||
// PerformanceMonitor.swift
|
|
||||||
// Sora
|
|
||||||
//
|
|
||||||
// Created by doomsboygaming on 5/22/25
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
import Kingfisher
|
|
||||||
import QuartzCore
|
|
||||||
|
|
||||||
/// Performance metrics tracking system with advanced jitter detection
|
|
||||||
class PerformanceMonitor: ObservableObject {
|
|
||||||
static let shared = PerformanceMonitor()
|
|
||||||
|
|
||||||
// Published properties to allow UI observation
|
|
||||||
@Published private(set) var networkRequestCount: Int = 0
|
|
||||||
@Published private(set) var cacheHitCount: Int = 0
|
|
||||||
@Published private(set) var cacheMissCount: Int = 0
|
|
||||||
@Published private(set) var averageLoadTime: TimeInterval = 0
|
|
||||||
@Published private(set) var memoryUsage: UInt64 = 0
|
|
||||||
@Published private(set) var diskUsage: UInt64 = 0
|
|
||||||
@Published private(set) var isEnabled: Bool = false
|
|
||||||
|
|
||||||
// Advanced performance metrics for jitter detection
|
|
||||||
@Published private(set) var currentFPS: Double = 60.0
|
|
||||||
@Published private(set) var mainThreadBlocks: Int = 0
|
|
||||||
@Published private(set) var memorySpikes: Int = 0
|
|
||||||
@Published private(set) var cpuUsage: Double = 0.0
|
|
||||||
@Published private(set) var jitterEvents: Int = 0
|
|
||||||
|
|
||||||
// Internal tracking properties
|
|
||||||
private var loadTimes: [TimeInterval] = []
|
|
||||||
private var startTimes: [String: Date] = [:]
|
|
||||||
private var memoryTimer: Timer?
|
|
||||||
private var logTimer: Timer?
|
|
||||||
|
|
||||||
// Advanced monitoring properties
|
|
||||||
private var displayLink: CADisplayLink?
|
|
||||||
private var frameCount: Int = 0
|
|
||||||
private var lastFrameTime: CFTimeInterval = 0
|
|
||||||
private var frameTimes: [CFTimeInterval] = []
|
|
||||||
private var lastMemoryUsage: UInt64 = 0
|
|
||||||
private var mainThreadOperations: [String: CFTimeInterval] = [:]
|
|
||||||
private var cpuTimer: Timer?
|
|
||||||
|
|
||||||
// Thresholds for performance issues
|
|
||||||
private let mainThreadBlockingThreshold: TimeInterval = 0.016 // 16ms for 60fps
|
|
||||||
private let memorySpikeTreshold: UInt64 = 50 * 1024 * 1024 // 50MB spike
|
|
||||||
private let fpsThreshold: Double = 50.0 // Below 50fps is considered poor
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
// Default is off unless explicitly enabled
|
|
||||||
isEnabled = UserDefaults.standard.bool(forKey: "enablePerformanceMonitoring")
|
|
||||||
|
|
||||||
// Setup memory monitoring if enabled
|
|
||||||
if isEnabled {
|
|
||||||
startMonitoring()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
|
||||||
|
|
||||||
/// Enable or disable the performance monitoring
|
|
||||||
func setEnabled(_ enabled: Bool) {
|
|
||||||
isEnabled = enabled
|
|
||||||
UserDefaults.standard.set(enabled, forKey: "enablePerformanceMonitoring")
|
|
||||||
|
|
||||||
if enabled {
|
|
||||||
startMonitoring()
|
|
||||||
} else {
|
|
||||||
stopMonitoring()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset all tracked metrics
|
|
||||||
func resetMetrics() {
|
|
||||||
networkRequestCount = 0
|
|
||||||
cacheHitCount = 0
|
|
||||||
cacheMissCount = 0
|
|
||||||
averageLoadTime = 0
|
|
||||||
loadTimes = []
|
|
||||||
startTimes = [:]
|
|
||||||
|
|
||||||
// Reset advanced metrics
|
|
||||||
mainThreadBlocks = 0
|
|
||||||
memorySpikes = 0
|
|
||||||
jitterEvents = 0
|
|
||||||
frameTimes = []
|
|
||||||
frameCount = 0
|
|
||||||
mainThreadOperations = [:]
|
|
||||||
|
|
||||||
updateMemoryUsage()
|
|
||||||
|
|
||||||
Logger.shared.log("Performance metrics reset", type: "Debug")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track a network request starting
|
|
||||||
func trackRequestStart(identifier: String) {
|
|
||||||
guard isEnabled else { return }
|
|
||||||
|
|
||||||
networkRequestCount += 1
|
|
||||||
startTimes[identifier] = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track a network request completing
|
|
||||||
func trackRequestEnd(identifier: String) {
|
|
||||||
guard isEnabled, let startTime = startTimes[identifier] else { return }
|
|
||||||
|
|
||||||
let endTime = Date()
|
|
||||||
let duration = endTime.timeIntervalSince(startTime)
|
|
||||||
loadTimes.append(duration)
|
|
||||||
|
|
||||||
// Update average load time
|
|
||||||
if !loadTimes.isEmpty {
|
|
||||||
averageLoadTime = loadTimes.reduce(0, +) / Double(loadTimes.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove start time to avoid memory leaks
|
|
||||||
startTimes.removeValue(forKey: identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track a cache hit
|
|
||||||
func trackCacheHit() {
|
|
||||||
guard isEnabled else { return }
|
|
||||||
cacheHitCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track a cache miss
|
|
||||||
func trackCacheMiss() {
|
|
||||||
guard isEnabled else { return }
|
|
||||||
cacheMissCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Advanced Performance Monitoring
|
|
||||||
|
|
||||||
/// Track the start of a main thread operation
|
|
||||||
func trackMainThreadOperationStart(operation: String) {
|
|
||||||
guard isEnabled else { return }
|
|
||||||
mainThreadOperations[operation] = CACurrentMediaTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track the end of a main thread operation and detect blocking
|
|
||||||
func trackMainThreadOperationEnd(operation: String) {
|
|
||||||
guard isEnabled, let startTime = mainThreadOperations[operation] else { return }
|
|
||||||
|
|
||||||
let endTime = CACurrentMediaTime()
|
|
||||||
let duration = endTime - startTime
|
|
||||||
|
|
||||||
if duration > mainThreadBlockingThreshold {
|
|
||||||
mainThreadBlocks += 1
|
|
||||||
jitterEvents += 1
|
|
||||||
|
|
||||||
let durationMs = Int(duration * 1000)
|
|
||||||
Logger.shared.log("🚨 Main thread blocked for \(durationMs)ms during: \(operation)", type: "Performance")
|
|
||||||
}
|
|
||||||
|
|
||||||
mainThreadOperations.removeValue(forKey: operation)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track memory spikes during downloads
|
|
||||||
func checkMemorySpike() {
|
|
||||||
guard isEnabled else { return }
|
|
||||||
|
|
||||||
let currentMemory = getAppMemoryUsage()
|
|
||||||
|
|
||||||
if lastMemoryUsage > 0 {
|
|
||||||
let spike = currentMemory > lastMemoryUsage ? currentMemory - lastMemoryUsage : 0
|
|
||||||
|
|
||||||
if spike > memorySpikeTreshold {
|
|
||||||
memorySpikes += 1
|
|
||||||
jitterEvents += 1
|
|
||||||
|
|
||||||
let spikeSize = Double(spike) / (1024 * 1024)
|
|
||||||
Logger.shared.log("🚨 Memory spike detected: +\(String(format: "%.1f", spikeSize))MB", type: "Performance")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastMemoryUsage = currentMemory
|
|
||||||
memoryUsage = currentMemory
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start frame rate monitoring
|
|
||||||
private func startFrameRateMonitoring() {
|
|
||||||
guard displayLink == nil else { return }
|
|
||||||
|
|
||||||
displayLink = CADisplayLink(target: self, selector: #selector(frameCallback))
|
|
||||||
displayLink?.add(to: .main, forMode: .common)
|
|
||||||
|
|
||||||
frameCount = 0
|
|
||||||
lastFrameTime = CACurrentMediaTime()
|
|
||||||
frameTimes = []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop frame rate monitoring
|
|
||||||
private func stopFrameRateMonitoring() {
|
|
||||||
displayLink?.invalidate()
|
|
||||||
displayLink = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Frame callback for FPS monitoring
|
|
||||||
@objc private func frameCallback() {
|
|
||||||
let currentTime = CACurrentMediaTime()
|
|
||||||
|
|
||||||
if lastFrameTime > 0 {
|
|
||||||
let frameDuration = currentTime - lastFrameTime
|
|
||||||
frameTimes.append(frameDuration)
|
|
||||||
|
|
||||||
// Keep only last 60 frames for rolling average
|
|
||||||
if frameTimes.count > 60 {
|
|
||||||
frameTimes.removeFirst()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate current FPS
|
|
||||||
if !frameTimes.isEmpty {
|
|
||||||
let averageFrameTime = frameTimes.reduce(0, +) / Double(frameTimes.count)
|
|
||||||
currentFPS = 1.0 / averageFrameTime
|
|
||||||
|
|
||||||
// Detect FPS drops
|
|
||||||
if currentFPS < fpsThreshold {
|
|
||||||
jitterEvents += 1
|
|
||||||
Logger.shared.log("🚨 FPS drop detected: \(String(format: "%.1f", currentFPS))fps", type: "Performance")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastFrameTime = currentTime
|
|
||||||
frameCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current CPU usage
|
|
||||||
private func getCPUUsage() -> Double {
|
|
||||||
var info = mach_task_basic_info()
|
|
||||||
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
|
|
||||||
|
|
||||||
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
|
|
||||||
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
|
||||||
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if kerr == KERN_SUCCESS {
|
|
||||||
// This is a simplified CPU usage calculation
|
|
||||||
// For more accurate results, we'd need to track over time
|
|
||||||
return Double(info.user_time.seconds + info.system_time.seconds)
|
|
||||||
} else {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current cache hit rate
|
|
||||||
var cacheHitRate: Double {
|
|
||||||
let total = cacheHitCount + cacheMissCount
|
|
||||||
guard total > 0 else { return 0 }
|
|
||||||
return Double(cacheHitCount) / Double(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log current performance metrics
|
|
||||||
func logMetrics() {
|
|
||||||
guard isEnabled else { return }
|
|
||||||
|
|
||||||
checkMemorySpike()
|
|
||||||
|
|
||||||
let hitRate = String(format: "%.1f%%", cacheHitRate * 100)
|
|
||||||
let avgLoad = String(format: "%.2f", averageLoadTime)
|
|
||||||
let memory = String(format: "%.1f MB", Double(memoryUsage) / (1024 * 1024))
|
|
||||||
let disk = String(format: "%.1f MB", Double(diskUsage) / (1024 * 1024))
|
|
||||||
let fps = String(format: "%.1f", currentFPS)
|
|
||||||
let cpu = String(format: "%.1f%%", cpuUsage)
|
|
||||||
|
|
||||||
let metrics = """
|
|
||||||
📊 Performance Metrics Report:
|
|
||||||
═══════════════════════════════
|
|
||||||
Network & Cache:
|
|
||||||
- Network Requests: \(networkRequestCount)
|
|
||||||
- Cache Hit Rate: \(hitRate) (\(cacheHitCount)/\(cacheHitCount + cacheMissCount))
|
|
||||||
- Average Load Time: \(avgLoad)s
|
|
||||||
|
|
||||||
System Resources:
|
|
||||||
- Memory Usage: \(memory)
|
|
||||||
- Disk Usage: \(disk)
|
|
||||||
- CPU Usage: \(cpu)
|
|
||||||
|
|
||||||
Performance Issues:
|
|
||||||
- Current FPS: \(fps)
|
|
||||||
- Main Thread Blocks: \(mainThreadBlocks)
|
|
||||||
- Memory Spikes: \(memorySpikes)
|
|
||||||
- Total Jitter Events: \(jitterEvents)
|
|
||||||
═══════════════════════════════
|
|
||||||
"""
|
|
||||||
|
|
||||||
Logger.shared.log(metrics, type: "Performance")
|
|
||||||
|
|
||||||
// Alert if performance is poor
|
|
||||||
if jitterEvents > 0 {
|
|
||||||
Logger.shared.log("⚠️ Performance issues detected! Check logs above for details.", type: "Warning")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
private func startMonitoring() {
|
|
||||||
// Setup timer to update memory usage periodically and check for spikes
|
|
||||||
memoryTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
|
||||||
self?.checkMemorySpike()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup timer to log metrics periodically
|
|
||||||
logTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
|
||||||
self?.logMetrics()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup CPU monitoring timer
|
|
||||||
cpuTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
|
||||||
self?.cpuUsage = self?.getCPUUsage() ?? 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure timers run even when scrolling
|
|
||||||
RunLoop.current.add(memoryTimer!, forMode: .common)
|
|
||||||
RunLoop.current.add(logTimer!, forMode: .common)
|
|
||||||
RunLoop.current.add(cpuTimer!, forMode: .common)
|
|
||||||
|
|
||||||
// Start frame rate monitoring
|
|
||||||
startFrameRateMonitoring()
|
|
||||||
|
|
||||||
Logger.shared.log("Advanced performance monitoring started - tracking FPS, main thread blocks, memory spikes", type: "Debug")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopMonitoring() {
|
|
||||||
memoryTimer?.invalidate()
|
|
||||||
memoryTimer = nil
|
|
||||||
|
|
||||||
logTimer?.invalidate()
|
|
||||||
logTimer = nil
|
|
||||||
|
|
||||||
cpuTimer?.invalidate()
|
|
||||||
cpuTimer = nil
|
|
||||||
|
|
||||||
stopFrameRateMonitoring()
|
|
||||||
|
|
||||||
Logger.shared.log("Performance monitoring stopped", type: "Debug")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateMemoryUsage() {
|
|
||||||
memoryUsage = getAppMemoryUsage()
|
|
||||||
diskUsage = getCacheDiskUsage()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getAppMemoryUsage() -> UInt64 {
|
|
||||||
var info = mach_task_basic_info()
|
|
||||||
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
|
|
||||||
|
|
||||||
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
|
|
||||||
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
|
||||||
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if kerr == KERN_SUCCESS {
|
|
||||||
return info.resident_size
|
|
||||||
} else {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getCacheDiskUsage() -> UInt64 {
|
|
||||||
// Try to get Kingfisher's disk cache size
|
|
||||||
let diskCache = ImageCache.default.diskStorage
|
|
||||||
|
|
||||||
do {
|
|
||||||
let size = try diskCache.totalSize()
|
|
||||||
return UInt64(size)
|
|
||||||
} catch {
|
|
||||||
Logger.shared.log("Failed to get disk cache size: \(error)", type: "Error")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Extensions to integrate with managers
|
|
||||||
|
|
||||||
extension EpisodeMetadataManager {
|
|
||||||
/// Integrate performance tracking
|
|
||||||
func trackFetchStart(anilistId: Int, episodeNumber: Int) {
|
|
||||||
let identifier = "metadata_\(anilistId)_\(episodeNumber)"
|
|
||||||
PerformanceMonitor.shared.trackRequestStart(identifier: identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackFetchEnd(anilistId: Int, episodeNumber: Int) {
|
|
||||||
let identifier = "metadata_\(anilistId)_\(episodeNumber)"
|
|
||||||
PerformanceMonitor.shared.trackRequestEnd(identifier: identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackCacheHit() {
|
|
||||||
PerformanceMonitor.shared.trackCacheHit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackCacheMiss() {
|
|
||||||
PerformanceMonitor.shared.trackCacheMiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ImagePrefetchManager {
|
|
||||||
/// Integrate performance tracking
|
|
||||||
func trackImageLoadStart(url: String) {
|
|
||||||
let identifier = "image_\(url.hashValue)"
|
|
||||||
PerformanceMonitor.shared.trackRequestStart(identifier: identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackImageLoadEnd(url: String) {
|
|
||||||
let identifier = "image_\(url.hashValue)"
|
|
||||||
PerformanceMonitor.shared.trackRequestEnd(identifier: identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackImageCacheHit() {
|
|
||||||
PerformanceMonitor.shared.trackCacheHit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackImageCacheMiss() {
|
|
||||||
PerformanceMonitor.shared.trackCacheMiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Debug View
|
|
||||||
struct PerformanceMetricsView: View {
|
|
||||||
@ObservedObject private var monitor = PerformanceMonitor.shared
|
|
||||||
@State private var isExpanded = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Text("Performance Metrics")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
isExpanded.toggle()
|
|
||||||
}) {
|
|
||||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
if isExpanded {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("Network Requests: \(monitor.networkRequestCount)")
|
|
||||||
Text("Cache Hit Rate: \(Int(monitor.cacheHitRate * 100))%")
|
|
||||||
Text("Avg Load Time: \(String(format: "%.2f", monitor.averageLoadTime))s")
|
|
||||||
Text("Memory: \(String(format: "%.1f MB", Double(monitor.memoryUsage) / (1024 * 1024)))")
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// Advanced metrics
|
|
||||||
Text("FPS: \(String(format: "%.1f", monitor.currentFPS))")
|
|
||||||
.foregroundColor(monitor.currentFPS < 50 ? .red : .primary)
|
|
||||||
Text("Main Thread Blocks: \(monitor.mainThreadBlocks)")
|
|
||||||
.foregroundColor(monitor.mainThreadBlocks > 0 ? .red : .primary)
|
|
||||||
Text("Memory Spikes: \(monitor.memorySpikes)")
|
|
||||||
.foregroundColor(monitor.memorySpikes > 0 ? .orange : .primary)
|
|
||||||
Text("Jitter Events: \(monitor.jitterEvents)")
|
|
||||||
.foregroundColor(monitor.jitterEvents > 0 ? .red : .primary)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Button(action: {
|
|
||||||
monitor.resetMetrics()
|
|
||||||
}) {
|
|
||||||
Text("Reset")
|
|
||||||
.font(.caption)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.blue)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(4)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
monitor.logMetrics()
|
|
||||||
}) {
|
|
||||||
Text("Log")
|
|
||||||
.font(.caption)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.green)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(4)
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("", isOn: Binding(
|
|
||||||
get: { monitor.isEnabled },
|
|
||||||
set: { monitor.setEnabled($0) }
|
|
||||||
))
|
|
||||||
.labelsHidden()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
.background(Color.secondary.opacity(0.1))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.secondary.opacity(0.05))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -58,9 +58,6 @@ struct SoraApp: App {
|
||||||
@StateObject private var jsController = JSController.shared
|
@StateObject private var jsController = JSController.shared
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
_ = MetadataCacheManager.shared
|
|
||||||
_ = KingfisherCacheManager.shared
|
|
||||||
|
|
||||||
if let userAccentColor = UserDefaults.standard.color(forKey: "accentColor") {
|
if let userAccentColor = UserDefaults.standard.color(forKey: "accentColor") {
|
||||||
UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = userAccentColor
|
UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = userAccentColor
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
//
|
|
||||||
// KingfisherManager.swift
|
|
||||||
// Sora
|
|
||||||
//
|
|
||||||
// Created by doomsboygaming on 5/22/25
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Kingfisher
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Manages Kingfisher image caching configuration
|
|
||||||
class KingfisherCacheManager {
|
|
||||||
static let shared = KingfisherCacheManager()
|
|
||||||
|
|
||||||
/// Maximum disk cache size (default 500MB)
|
|
||||||
private let maxDiskCacheSize: UInt = 500 * 1024 * 1024
|
|
||||||
|
|
||||||
/// Maximum cache age (default 7 days)
|
|
||||||
private let maxCacheAgeInDays: TimeInterval = 7
|
|
||||||
|
|
||||||
/// UserDefaults keys
|
|
||||||
private let imageCachingEnabledKey = "imageCachingEnabled"
|
|
||||||
|
|
||||||
/// Whether image caching is enabled
|
|
||||||
var isCachingEnabled: Bool {
|
|
||||||
get {
|
|
||||||
// Default to true if not set
|
|
||||||
UserDefaults.standard.object(forKey: imageCachingEnabledKey) == nil ?
|
|
||||||
true : UserDefaults.standard.bool(forKey: imageCachingEnabledKey)
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
UserDefaults.standard.set(newValue, forKey: imageCachingEnabledKey)
|
|
||||||
configureKingfisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
configureKingfisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure Kingfisher with appropriate caching settings
|
|
||||||
func configureKingfisher() {
|
|
||||||
let cache = ImageCache.default
|
|
||||||
|
|
||||||
// Set disk cache size limit and expiration
|
|
||||||
cache.diskStorage.config.sizeLimit = isCachingEnabled ? maxDiskCacheSize : 0
|
|
||||||
cache.diskStorage.config.expiration = isCachingEnabled ?
|
|
||||||
.days(Int(maxCacheAgeInDays)) : .seconds(1) // 1 second means effectively disabled
|
|
||||||
|
|
||||||
// Set memory cache size
|
|
||||||
cache.memoryStorage.config.totalCostLimit = isCachingEnabled ?
|
|
||||||
30 * 1024 * 1024 : 0 // 30MB memory cache when enabled
|
|
||||||
|
|
||||||
// Configure clean interval
|
|
||||||
cache.memoryStorage.config.cleanInterval = 60 // Clean memory every 60 seconds
|
|
||||||
|
|
||||||
// Configure retry strategy
|
|
||||||
KingfisherManager.shared.downloader.downloadTimeout = 15.0 // 15 second timeout
|
|
||||||
|
|
||||||
Logger.shared.log("Configured Kingfisher cache. Enabled: \(isCachingEnabled)", type: "Debug")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all cached images
|
|
||||||
func clearCache(completion: (() -> Void)? = nil) {
|
|
||||||
KingfisherManager.shared.cache.clearMemoryCache()
|
|
||||||
KingfisherManager.shared.cache.clearDiskCache {
|
|
||||||
Logger.shared.log("Cleared Kingfisher image cache", type: "General")
|
|
||||||
completion?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate current cache size
|
|
||||||
/// - Parameter completion: Closure to call with cache size in bytes
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert cache size to user-friendly string
|
|
||||||
/// - Parameter sizeInBytes: Size in bytes
|
|
||||||
/// - Returns: Formatted string (e.g., "5.2 MB")
|
|
||||||
static func formatCacheSize(_ sizeInBytes: UInt) -> String {
|
|
||||||
let formatter = ByteCountFormatter()
|
|
||||||
formatter.countStyle = .file
|
|
||||||
return formatter.string(fromByteCount: Int64(sizeInBytes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,276 +0,0 @@
|
||||||
//
|
|
||||||
// MetadataCacheManager.swift
|
|
||||||
// Sora
|
|
||||||
//
|
|
||||||
// Created by doomsboygaming on 5/22/25
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// 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<NSString, NSData>()
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
func storeMetadata(_ data: Data, forKey key: String) {
|
|
||||||
guard isCachingEnabled else { return }
|
|
||||||
|
|
||||||
let keyString = key as NSString
|
|
||||||
|
|
||||||
// Always store in memory cache
|
|
||||||
memoryCache.setObject(data as NSData, forKey: keyString)
|
|
||||||
|
|
||||||
// Store on disk if not in memory-only mode
|
|
||||||
if !isMemoryOnlyMode {
|
|
||||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
|
||||||
|
|
||||||
DispatchQueue.global(qos: .background).async { [weak self] in
|
|
||||||
do {
|
|
||||||
try data.write(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 ?? "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -268,7 +268,6 @@ struct EpisodeCell: View {
|
||||||
.onFailure { error in
|
.onFailure { error in
|
||||||
Logger.shared.log("Failed to load episode image: \(error)", type: "Error")
|
Logger.shared.log("Failed to load episode image: \(error)", type: "Error")
|
||||||
}
|
}
|
||||||
.cacheMemoryOnly(!KingfisherCacheManager.shared.isCachingEnabled)
|
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(16/9, contentMode: .fill)
|
.aspectRatio(16/9, contentMode: .fill)
|
||||||
.frame(width: 100, height: 56)
|
.frame(width: 100, height: 56)
|
||||||
|
|
@ -713,25 +712,6 @@ struct EpisodeCell: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchEpisodeDetails() {
|
private func fetchEpisodeDetails() {
|
||||||
if MetadataCacheManager.shared.isCachingEnabled &&
|
|
||||||
(UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil ||
|
|
||||||
UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata")) {
|
|
||||||
|
|
||||||
let cacheKey = "anilist_\(itemID)_episode_\(episodeID + 1)"
|
|
||||||
|
|
||||||
if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
|
|
||||||
let metadata = EpisodeMetadata.fromData(cachedData) {
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.episodeTitle = metadata.title["en"] ?? ""
|
|
||||||
self.episodeImageUrl = metadata.imageUrl
|
|
||||||
self.isLoading = false
|
|
||||||
self.loadedFromCache = true
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAnimeEpisodeDetails()
|
fetchAnimeEpisodeDetails()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -808,22 +788,6 @@ struct EpisodeCell: View {
|
||||||
Logger.shared.log("Episode \(episodeKey) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning")
|
Logger.shared.log("Episode \(episodeKey) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning")
|
||||||
}
|
}
|
||||||
|
|
||||||
if MetadataCacheManager.shared.isCachingEnabled && (!title.isEmpty || !image.isEmpty) {
|
|
||||||
let metadata = EpisodeMetadata(
|
|
||||||
title: title,
|
|
||||||
imageUrl: image,
|
|
||||||
anilistId: self.itemID,
|
|
||||||
episodeNumber: self.episodeID + 1
|
|
||||||
)
|
|
||||||
|
|
||||||
if let metadataData = metadata.toData() {
|
|
||||||
MetadataCacheManager.shared.storeMetadata(
|
|
||||||
metadataData,
|
|
||||||
forKey: metadata.cacheKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.retryAttempts = 0
|
self.retryAttempts = 0
|
||||||
|
|
|
||||||
|
|
@ -220,14 +220,12 @@ struct MediaInfoView: View {
|
||||||
.fill(Color.gray.opacity(0.3))
|
.fill(Color.gray.opacity(0.3))
|
||||||
.shimmering()
|
.shimmering()
|
||||||
}
|
}
|
||||||
.setProcessor(ImageUpscaler.lanczosProcessor(scale: 3, sharpeningIntensity: 1.5, sharpeningRadius: 0.8))
|
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: UIScreen.main.bounds.width, height: 700)
|
.frame(width: UIScreen.main.bounds.width, height: 700)
|
||||||
.clipped()
|
.clipped()
|
||||||
KFImage(URL(string: imageUrl))
|
KFImage(URL(string: imageUrl))
|
||||||
.placeholder { EmptyView() }
|
.placeholder { EmptyView() }
|
||||||
.setProcessor(ImageUpscaler.lanczosProcessor(scale: 3, sharpeningIntensity: 1.5, sharpeningRadius: 0.8))
|
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: UIScreen.main.bounds.width, height: 700)
|
.frame(width: UIScreen.main.bounds.width, height: 700)
|
||||||
|
|
@ -1863,24 +1861,6 @@ struct MediaInfoView: View {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if MetadataCacheManager.shared.isCachingEnabled {
|
|
||||||
let cacheKey = "anilist_\(anilistId)_episode_\(episode.number)"
|
|
||||||
|
|
||||||
if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
|
|
||||||
let metadata = EpisodeMetadata.fromData(cachedData) {
|
|
||||||
|
|
||||||
print("[Bulk Download] Using cached metadata for episode \(episode.number)")
|
|
||||||
let metadataInfo = EpisodeMetadataInfo(
|
|
||||||
title: metadata.title,
|
|
||||||
imageUrl: metadata.imageUrl,
|
|
||||||
anilistId: metadata.anilistId,
|
|
||||||
episodeNumber: metadata.episodeNumber
|
|
||||||
)
|
|
||||||
completion(metadataInfo)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchEpisodeMetadataFromNetwork(anilistId: anilistId, episodeNumber: episode.number, completion: completion)
|
fetchEpisodeMetadataFromNetwork(anilistId: anilistId, episodeNumber: episode.number, completion: completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1939,22 +1919,6 @@ struct MediaInfoView: View {
|
||||||
if let imageUrl = episodeDetails["image"] as? String, !imageUrl.isEmpty {
|
if let imageUrl = episodeDetails["image"] as? String, !imageUrl.isEmpty {
|
||||||
image = imageUrl
|
image = imageUrl
|
||||||
}
|
}
|
||||||
if MetadataCacheManager.shared.isCachingEnabled {
|
|
||||||
let metadata = EpisodeMetadata(
|
|
||||||
title: title,
|
|
||||||
imageUrl: image,
|
|
||||||
anilistId: anilistId,
|
|
||||||
episodeNumber: episodeNumber
|
|
||||||
)
|
|
||||||
|
|
||||||
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
|
|
||||||
if let metadataData = metadata.toData() {
|
|
||||||
MetadataCacheManager.shared.storeMetadata(
|
|
||||||
metadataData,
|
|
||||||
forKey: cacheKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let metadataInfo = EpisodeMetadataInfo(
|
let metadataInfo = EpisodeMetadataInfo(
|
||||||
title: title,
|
title: title,
|
||||||
|
|
|
||||||
|
|
@ -136,22 +136,14 @@ fileprivate struct SettingsButtonRow: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingsViewData: View {
|
struct SettingsViewData: View {
|
||||||
@State private var showEraseAppDataAlert = false
|
@State private var showAlert = false
|
||||||
@State private var showRemoveDocumentsAlert = false
|
|
||||||
@State private var showSizeAlert = false
|
|
||||||
@State private var cacheSizeText: String = "Calculating..."
|
@State private var cacheSizeText: String = "Calculating..."
|
||||||
@State private var isCalculatingSize: Bool = false
|
@State private var isCalculatingSize: Bool = false
|
||||||
@State private var cacheSize: Int64 = 0
|
@State private var cacheSize: Int64 = 0
|
||||||
@State private var documentsSize: Int64 = 0
|
@State private var documentsSize: Int64 = 0
|
||||||
@State private var movPkgSize: Int64 = 0
|
|
||||||
@State private var showRemoveMovPkgAlert = false
|
|
||||||
@State private var isMetadataCachingEnabled: Bool = false
|
|
||||||
@State private var isImageCachingEnabled: Bool = true
|
|
||||||
@State private var isMemoryOnlyMode: Bool = false
|
|
||||||
@State private var showAlert = false
|
|
||||||
|
|
||||||
enum ActiveAlert {
|
enum ActiveAlert {
|
||||||
case eraseData, removeDocs, removeMovPkg
|
case eraseData, removeDocs
|
||||||
}
|
}
|
||||||
|
|
||||||
@State private var activeAlert: ActiveAlert = .eraseData
|
@State private var activeAlert: ActiveAlert = .eraseData
|
||||||
|
|
@ -160,48 +152,9 @@ struct SettingsViewData: View {
|
||||||
return ScrollView {
|
return ScrollView {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: "Cache Settings",
|
title: "Cache",
|
||||||
footer: "Caching helps reduce network usage and load content faster. You can disable it to save storage space."
|
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.shared.isCachingEnabled = newValue
|
|
||||||
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.shared.isMemoryOnlyMode = newValue
|
|
||||||
if newValue {
|
|
||||||
MetadataCacheManager.shared.clearAllCache()
|
|
||||||
calculateCacheSize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "folder.badge.gearshape")
|
Image(systemName: "folder.badge.gearshape")
|
||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
|
|
@ -250,16 +203,6 @@ struct SettingsViewData: View {
|
||||||
)
|
)
|
||||||
Divider().padding(.horizontal, 16)
|
Divider().padding(.horizontal, 16)
|
||||||
|
|
||||||
SettingsButtonRow(
|
|
||||||
icon: "arrow.down.circle",
|
|
||||||
title: "Remove Downloads",
|
|
||||||
subtitle: formatSize(movPkgSize),
|
|
||||||
action: {
|
|
||||||
showRemoveMovPkgAlert = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Divider().padding(.horizontal, 16)
|
|
||||||
|
|
||||||
SettingsButtonRow(
|
SettingsButtonRow(
|
||||||
icon: "exclamationmark.triangle",
|
icon: "exclamationmark.triangle",
|
||||||
title: "Erase all App Data",
|
title: "Erase all App Data",
|
||||||
|
|
@ -274,9 +217,6 @@ struct SettingsViewData: View {
|
||||||
.scrollViewBottomPadding()
|
.scrollViewBottomPadding()
|
||||||
.navigationTitle("App Data")
|
.navigationTitle("App Data")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
isMetadataCachingEnabled = MetadataCacheManager.shared.isCachingEnabled
|
|
||||||
isImageCachingEnabled = KingfisherCacheManager.shared.isCachingEnabled
|
|
||||||
isMemoryOnlyMode = MetadataCacheManager.shared.isMemoryOnlyMode
|
|
||||||
calculateCacheSize()
|
calculateCacheSize()
|
||||||
updateSizes()
|
updateSizes()
|
||||||
}
|
}
|
||||||
|
|
@ -300,53 +240,44 @@ struct SettingsViewData: View {
|
||||||
},
|
},
|
||||||
secondaryButton: .cancel()
|
secondaryButton: .cancel()
|
||||||
)
|
)
|
||||||
case .removeMovPkg:
|
|
||||||
return Alert(
|
|
||||||
title: Text("Remove Downloads"),
|
|
||||||
message: Text("Are you sure you want to remove all Downloads?"),
|
|
||||||
primaryButton: .destructive(Text("Remove")) {
|
|
||||||
removeMovPkgFiles()
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func calculateCacheSize() {
|
func calculateCacheSize() {
|
||||||
isCalculatingSize = true
|
isCalculatingSize = true
|
||||||
cacheSizeText = "Calculating..."
|
cacheSizeText = "Calculating..."
|
||||||
|
|
||||||
DispatchQueue.global(qos: .background).async {
|
DispatchQueue.global(qos: .background).async {
|
||||||
var totalSize: Int64 = 0
|
if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
||||||
let metadataSize = MetadataCacheManager.shared.getCacheSize()
|
let size = calculateDirectorySize(for: cacheURL)
|
||||||
totalSize += metadataSize
|
|
||||||
|
|
||||||
KingfisherCacheManager.shared.calculateCacheSize { imageSize in
|
|
||||||
totalSize += Int64(imageSize)
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.cacheSizeText = KingfisherCacheManager.formatCacheSize(UInt(totalSize))
|
self.cacheSize = size
|
||||||
|
self.cacheSizeText = formatSize(size)
|
||||||
|
self.isCalculatingSize = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.cacheSizeText = "Unknown"
|
||||||
self.isCalculatingSize = false
|
self.isCalculatingSize = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearAllCaches() {
|
func updateSizes() {
|
||||||
MetadataCacheManager.shared.clearAllCache()
|
DispatchQueue.global(qos: .background).async {
|
||||||
KingfisherCacheManager.shared.clearCache {
|
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||||
calculateCacheSize()
|
let size = calculateDirectorySize(for: documentsURL)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.documentsSize = size
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Logger.shared.log("All caches cleared", type: "General")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func eraseAppData() {
|
func clearAllCaches() {
|
||||||
if let domain = Bundle.main.bundleIdentifier {
|
clearCache()
|
||||||
UserDefaults.standard.removePersistentDomain(forName: domain)
|
|
||||||
UserDefaults.standard.synchronize()
|
|
||||||
Logger.shared.log("Cleared app data!", type: "General")
|
|
||||||
exit(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearCache() {
|
func clearCache() {
|
||||||
|
|
@ -377,30 +308,24 @@ struct SettingsViewData: View {
|
||||||
Logger.shared.log("All files in documents folder removed", type: "General")
|
Logger.shared.log("All files in documents folder removed", type: "General")
|
||||||
exit(0)
|
exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
Logger.shared.log("Error removing files in documents folder: $error)", type: "Error")
|
Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeMovPkgFiles() {
|
func eraseAppData() {
|
||||||
let fileManager = FileManager.default
|
if let domain = Bundle.main.bundleIdentifier {
|
||||||
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
UserDefaults.standard.removePersistentDomain(forName: domain)
|
||||||
do {
|
UserDefaults.standard.synchronize()
|
||||||
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
|
Logger.shared.log("Cleared app data!", type: "General")
|
||||||
for fileURL in fileURLs where fileURL.pathExtension == "movpkg" {
|
exit(0)
|
||||||
try fileManager.removeItem(at: fileURL)
|
|
||||||
}
|
|
||||||
Logger.shared.log("All Downloads files removed", type: "General")
|
|
||||||
updateSizes()
|
|
||||||
} catch {
|
|
||||||
Logger.shared.log("Error removing Downloads files: $error)", type: "Error")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func calculateDirectorySize(for url: URL) -> Int64 {
|
private func calculateDirectorySize(for url: URL) -> Int64 {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
var totalSize: Int64 = 0
|
var totalSize: Int64 = 0
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
|
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
|
||||||
for url in contents {
|
for url in contents {
|
||||||
|
|
@ -412,41 +337,17 @@ struct SettingsViewData: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Logger.shared.log("Error calculating directory size: $error)", type: "Error")
|
Logger.shared.log("Error calculating directory size: \(error)", type: "Error")
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalSize
|
return totalSize
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatSize(_ bytes: Int64) -> String {
|
private func formatSize(_ bytes: Int64) -> String {
|
||||||
let formatter = ByteCountFormatter()
|
let formatter = ByteCountFormatter()
|
||||||
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
|
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
|
||||||
formatter.countStyle = .file
|
formatter.countStyle = .file
|
||||||
return formatter.string(fromByteCount: bytes)
|
return formatter.string(fromByteCount: bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSizes() {
|
|
||||||
if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
|
||||||
cacheSize = calculateDirectorySize(for: cacheURL)
|
|
||||||
}
|
|
||||||
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
||||||
documentsSize = calculateDirectorySize(for: documentsURL)
|
|
||||||
movPkgSize = calculateMovPkgSize(in: documentsURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func calculateMovPkgSize(in url: URL) -> Int64 {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
var totalSize: Int64 = 0
|
|
||||||
do {
|
|
||||||
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
|
|
||||||
for url in contents where url.pathExtension == "movpkg" {
|
|
||||||
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey])
|
|
||||||
totalSize += Int64(resourceValues.fileSize ?? 0)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Logger.shared.log("Error calculating MovPkg size: $error)", type: "Error")
|
|
||||||
}
|
|
||||||
return totalSize
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,8 @@
|
||||||
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */; };
|
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */; };
|
||||||
1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */; };
|
1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */; };
|
||||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; };
|
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; };
|
||||||
1EA64DCD2DE5030100AC14BC /* ImageUpscaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA64DCC2DE5030100AC14BC /* ImageUpscaler.swift */; };
|
|
||||||
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; };
|
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; };
|
||||||
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF5C3A82DB988D70032BF07 /* CommunityLib.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 */; };
|
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */; };
|
||||||
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.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 */; };
|
722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */; };
|
||||||
|
|
@ -98,9 +94,6 @@
|
||||||
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; };
|
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; };
|
||||||
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.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 */; };
|
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */; };
|
||||||
72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */; };
|
|
||||||
72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */; };
|
|
||||||
72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */; };
|
|
||||||
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; };
|
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
|
@ -181,12 +174,8 @@
|
||||||
1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = "<group>"; };
|
1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = "<group>"; };
|
||||||
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnilistMatchPopupView.swift; sourceTree = "<group>"; };
|
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnilistMatchPopupView.swift; sourceTree = "<group>"; };
|
||||||
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
|
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
|
||||||
1EA64DCC2DE5030100AC14BC /* ImageUpscaler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUpscaler.swift; sourceTree = "<group>"; };
|
|
||||||
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
|
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
|
||||||
1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = "<group>"; };
|
1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = "<group>"; };
|
||||||
7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadata.swift; sourceTree = "<group>"; };
|
|
||||||
7205AED82DCCEF9500943F3F /* KingfisherManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherManager.swift; sourceTree = "<group>"; };
|
|
||||||
7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataCacheManager.swift; sourceTree = "<group>"; };
|
|
||||||
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = "<group>"; };
|
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = "<group>"; };
|
||||||
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = "<group>"; };
|
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = "<group>"; };
|
||||||
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = "<group>"; };
|
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = "<group>"; };
|
||||||
|
|
@ -196,9 +185,6 @@
|
||||||
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = "<group>"; };
|
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = "<group>"; };
|
||||||
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = "<group>"; };
|
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = "<group>"; };
|
||||||
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = "<group>"; };
|
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = "<group>"; };
|
||||||
72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadataManager.swift; sourceTree = "<group>"; };
|
|
||||||
72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePrefetchManager.swift; sourceTree = "<group>"; };
|
|
||||||
72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMonitor.swift; sourceTree = "<group>"; };
|
|
||||||
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
|
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
|
@ -327,7 +313,6 @@
|
||||||
133D7C6C2D2BE2500075467E /* Sora */ = {
|
133D7C6C2D2BE2500075467E /* Sora */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
72AC3A002DD4DAEA00C60B96 /* Managers */,
|
|
||||||
130C6BF82D53A4C200DC1432 /* Sora.entitlements */,
|
130C6BF82D53A4C200DC1432 /* Sora.entitlements */,
|
||||||
13DC0C412D2EC9BA00D0F966 /* Info.plist */,
|
13DC0C412D2EC9BA00D0F966 /* Info.plist */,
|
||||||
13103E802D589D6C000F0673 /* Tracking Services */,
|
13103E802D589D6C000F0673 /* Tracking Services */,
|
||||||
|
|
@ -395,7 +380,6 @@
|
||||||
04F08EE02DE10C22006B29D9 /* Models */,
|
04F08EE02DE10C22006B29D9 /* Models */,
|
||||||
04F08EDD2DE10C05006B29D9 /* TabBar */,
|
04F08EDD2DE10C05006B29D9 /* TabBar */,
|
||||||
04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */,
|
04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */,
|
||||||
7205AEDA2DCCEF9500943F3F /* Cache */,
|
|
||||||
13D842532D45266900EBBFA6 /* Drops */,
|
13D842532D45266900EBBFA6 /* Drops */,
|
||||||
1399FAD12D3AB33D00E97C31 /* Logger */,
|
1399FAD12D3AB33D00E97C31 /* Logger */,
|
||||||
133D7C882D2BE2640075467E /* Modules */,
|
133D7C882D2BE2640075467E /* Modules */,
|
||||||
|
|
@ -606,16 +590,6 @@
|
||||||
path = Components;
|
path = Components;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
7205AEDA2DCCEF9500943F3F /* Cache */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */,
|
|
||||||
7205AED82DCCEF9500943F3F /* KingfisherManager.swift */,
|
|
||||||
7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */,
|
|
||||||
);
|
|
||||||
path = Cache;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
72443C832DC8046500A61321 /* DownloadUtils */ = {
|
72443C832DC8046500A61321 /* DownloadUtils */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -626,17 +600,6 @@
|
||||||
path = DownloadUtils;
|
path = DownloadUtils;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
72AC3A002DD4DAEA00C60B96 /* Managers */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1EA64DCC2DE5030100AC14BC /* ImageUpscaler.swift */,
|
|
||||||
72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */,
|
|
||||||
72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */,
|
|
||||||
72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */,
|
|
||||||
);
|
|
||||||
path = Managers;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
|
@ -728,10 +691,7 @@
|
||||||
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
|
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
|
||||||
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */,
|
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */,
|
||||||
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
|
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
|
||||||
7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */,
|
|
||||||
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */,
|
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */,
|
||||||
7205AEDC2DCCEF9500943F3F /* MetadataCacheManager.swift in Sources */,
|
|
||||||
7205AEDD2DCCEF9500943F3F /* KingfisherManager.swift in Sources */,
|
|
||||||
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */,
|
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */,
|
||||||
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
|
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
|
||||||
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
||||||
|
|
@ -788,15 +748,11 @@
|
||||||
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */,
|
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */,
|
||||||
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */,
|
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */,
|
||||||
132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */,
|
132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */,
|
||||||
1EA64DCD2DE5030100AC14BC /* ImageUpscaler.swift in Sources */,
|
|
||||||
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */,
|
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */,
|
||||||
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */,
|
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */,
|
||||||
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */,
|
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */,
|
||||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
|
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
|
||||||
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */,
|
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */,
|
||||||
72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */,
|
|
||||||
72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */,
|
|
||||||
72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */,
|
|
||||||
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */,
|
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */,
|
||||||
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,
|
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,
|
||||||
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */,
|
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue