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