The update we all have been waiting for (#129)

* letstry

* actual build

* eeee

* eeeee

* eeeeeeeeeee

* l

* maybe

* yes

* dwadaw

* e

* eee

* LFG

* yes

* letsdothisagain

* yes

* Persistance Added

* lfg

* Buggy Public Build

* Prevent downloading of already downloaded episodes

* Fix file size estimates maybe

* Add downloading progress in episode cell

* yes

* yes again

* yes

* Graceful degradation implementation for Episode Cells

* Fix download Size

* yay

* e

* implement download queue

* Download Quality

* Remove Package.resolved as it may differ per environment

* Restored Xcode project file from backup branch after merge

* yes

* y

* Added set color method to UserDefaults extension

* maybe

* yes

* eeee

* YES

* fix build

* maybe

* yes

* mp4shi

* yes

* Update build.yml

* maybe fix

* yes

* yes :D

* Okay les go

* LETSGO

* Update scratchpad with latest progress before upstream merge

* Delete .cursor/merge_conflicts.md

* Delete .cursor/scratchpad.md

* Fix the AI stealing my work and crediting itself...

* Bug Fixes

* Multi Download Functionality

* Delete .cursor/scratchpad.md

* Update build.yml

* Delete iosbuild.sh

* Download Sorting

* better select stuff things
This commit is contained in:
realdoomsboygaming 2025-05-23 12:29:31 -05:00 committed by GitHub
parent 178c847c1c
commit c85b6690da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 8760 additions and 721 deletions

View file

@ -0,0 +1,646 @@
//
// 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
)
}
}

View file

@ -0,0 +1,134 @@
//
// 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")
}
}
}

View file

@ -0,0 +1,510 @@
//
// 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)
}
}

View file

@ -6,5 +6,11 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.assets.movies.read-write</key>
<true/>
<key>com.apple.security.assets.music.read-write</key>
<true/>
</dict>
</plist>

View file

@ -6,6 +6,33 @@
//
import SwiftUI
import UIKit
// Add missing extension for UserDefaults
extension UserDefaults {
func color(forKey key: String) -> UIColor? {
guard let colorData = data(forKey: key) else { return nil }
do {
return try NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: colorData)
} catch {
return nil
}
}
func set(_ color: UIColor?, forKey key: String) {
guard let color = color else {
removeObject(forKey: key)
return
}
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: false)
set(data, forKey: key)
} catch {
print("Error archiving color: \(error)")
}
}
}
@main
struct SoraApp: App {
@ -13,8 +40,13 @@ struct SoraApp: App {
@StateObject private var moduleManager = ModuleManager()
@StateObject private var librarykManager = LibraryManager()
@StateObject private var downloadManager = DownloadManager()
@StateObject private var jsController = JSController.shared
init() {
// Initialize caching systems
_ = MetadataCacheManager.shared
_ = KingfisherCacheManager.shared
if let userAccentColor = UserDefaults.standard.color(forKey: "accentColor") {
UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = userAccentColor
}
@ -35,6 +67,7 @@ struct SoraApp: App {
.environmentObject(settings)
.environmentObject(librarykManager)
.environmentObject(downloadManager)
.environmentObject(jsController)
.accentColor(settings.accentColor)
.onAppear {
settings.updateAppearance()

View file

@ -15,6 +15,7 @@ struct AnalyticsResponse: Codable {
let timestamp: String?
}
@MainActor
class AnalyticsManager {
static let shared = AnalyticsManager()
private let analyticsURL = URL(string: "http://151.106.3.14:47474/analytics")!

View file

@ -0,0 +1,45 @@
//
// 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()
}
}

View file

@ -0,0 +1,95 @@
//
// 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))
}
}

View file

@ -0,0 +1,276 @@
//
// 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 ?? "")
}
}
}
}

View file

@ -0,0 +1,95 @@
//
// DownloadManager.swift
// Sulfur
//
// Created by Francesco on 29/04/25.
//
import SwiftUI
import AVKit
import AVFoundation
class DownloadManager: NSObject, ObservableObject {
@Published var activeDownloads: [(URL, Double)] = []
@Published var localPlaybackURL: URL?
private var assetDownloadURLSession: AVAssetDownloadURLSession!
private var activeDownloadTasks: [URLSessionTask: URL] = [:]
override init() {
super.init()
initializeDownloadSession()
loadLocalContent()
}
private func initializeDownloadSession() {
let configuration = URLSessionConfiguration.background(withIdentifier: "hls-downloader")
assetDownloadURLSession = AVAssetDownloadURLSession(
configuration: configuration,
assetDownloadDelegate: self,
delegateQueue: .main
)
}
func downloadAsset(from url: URL) {
let asset = AVURLAsset(url: url)
let task = assetDownloadURLSession.makeAssetDownloadTask(
asset: asset,
assetTitle: "Offline Video",
assetArtworkData: nil,
options: nil
)
task?.resume()
activeDownloadTasks[task!] = url
}
private func loadLocalContent() {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
do {
let contents = try FileManager.default.contentsOfDirectory(
at: documents,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles
)
if let localURL = contents.first(where: { $0.pathExtension == "movpkg" }) {
localPlaybackURL = localURL
}
} catch {
print("Error loading local content: \(error)")
}
}
}
extension DownloadManager: AVAssetDownloadDelegate {
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
activeDownloadTasks.removeValue(forKey: assetDownloadTask)
localPlaybackURL = location
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error = error else { return }
print("Download error: \(error.localizedDescription)")
activeDownloadTasks.removeValue(forKey: task)
}
func urlSession(_ session: URLSession,
assetDownloadTask: AVAssetDownloadTask,
didLoad timeRange: CMTimeRange,
totalTimeRangesLoaded loadedTimeRanges: [NSValue],
timeRangeExpectedToLoad: CMTimeRange) {
guard let url = activeDownloadTasks[assetDownloadTask] else { return }
let progress = loadedTimeRanges
.map { $0.timeRangeValue.duration.seconds / timeRangeExpectedToLoad.duration.seconds }
.reduce(0, +)
if let index = activeDownloads.firstIndex(where: { $0.0 == url }) {
activeDownloads[index].1 = progress
} else {
activeDownloads.append((url, progress))
}
}
}

View file

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

View file

@ -0,0 +1,337 @@
//
// M3U8StreamExtractor.swift
// Sora
//
// Created by Francesco on 30/04/25.
//
import Foundation
enum M3U8StreamExtractorError: Error {
case networkError(Error)
case parsingError(String)
case noStreamFound
case invalidURL
var localizedDescription: String {
switch self {
case .networkError(let error):
return "Network error: \(error.localizedDescription)"
case .parsingError(let message):
return "Parsing error: \(message)"
case .noStreamFound:
return "No suitable stream found in playlist"
case .invalidURL:
return "Invalid stream URL"
}
}
}
class M3U8StreamExtractor {
// Enable verbose logging for development/testing
static var verboseLogging: Bool = true
/// Logs messages with a consistent format if verbose logging is enabled
/// - Parameters:
/// - message: The message to log
/// - function: The calling function (auto-filled)
/// - line: The line number (auto-filled)
private static func log(_ message: String, function: String = #function, line: Int = #line) {
if verboseLogging {
print("[M3U8Extractor:\(function):\(line)] \(message)")
}
}
/// Extracts the appropriate stream URL from a master M3U8 playlist based on quality preference
/// - Parameters:
/// - masterURL: The URL of the master M3U8 playlist
/// - headers: HTTP headers to use for the request
/// - preferredQuality: User's preferred quality ("Best", "High", "Medium", "Low")
/// - jsController: Optional reference to the JSController for header management
/// - completion: Completion handler with the result containing the selected stream URL and headers
static func extractStreamURL(
from masterURL: URL,
headers: [String: String],
preferredQuality: String,
jsController: JSController? = nil,
completion: @escaping (Result<(streamURL: URL, headers: [String: String]), Error>) -> Void
) {
log("Starting extraction from master playlist: \(masterURL.absoluteString)")
log("Preferred quality: \(preferredQuality)")
var requestHeaders = headers
// Use header manager if available
if let controller = jsController {
log("Using JSController for header management")
requestHeaders = controller.ensureStreamingHeaders(headers: headers, for: masterURL)
controller.logHeadersForRequest(headers: requestHeaders, url: masterURL, operation: "Extracting streams from")
} else {
log("JSController not provided, using original headers")
}
var request = URLRequest(url: masterURL)
// Add headers to the request
for (key, value) in requestHeaders {
request.addValue(value, forHTTPHeaderField: key)
}
// Add a unique request ID for tracking in logs
let requestID = UUID().uuidString.prefix(8)
log("Request ID: \(requestID)")
// Fetch the master playlist
log("Sending request to fetch master playlist")
let task = URLSession.shared.dataTask(with: request) { data, response, error in
// Handle network errors
if let error = error {
log("Network error: \(error.localizedDescription)")
completion(.failure(M3U8StreamExtractorError.networkError(error)))
return
}
// Log HTTP status for debugging
if let httpResponse = response as? HTTPURLResponse {
let statusCode = httpResponse.statusCode
log("HTTP Status: \(statusCode) for \(masterURL.absoluteString)")
if statusCode == 403 {
log("HTTP Error 403: Access Forbidden")
// Try to extract domain from URL for logging
let domain = masterURL.host ?? "unknown domain"
log("Access denied by server: \(domain)")
// Check if we have essential headers that might be missing/incorrect
let missingCriticalHeaders = ["Origin", "Referer", "User-Agent"].filter { requestHeaders[$0] == nil }
if !missingCriticalHeaders.isEmpty {
log("Missing critical headers: \(missingCriticalHeaders.joined(separator: ", "))")
}
// Since we got a 403, just fall back to the master URL directly
log("403 error - Falling back to master URL")
completion(.success((streamURL: masterURL, headers: requestHeaders)))
return
} else if statusCode >= 400 {
log("HTTP Error: \(statusCode)")
completion(.failure(M3U8StreamExtractorError.parsingError("HTTP Error: \(statusCode)")))
return
}
// Log response headers for debugging
log("Response Headers:")
for (key, value) in httpResponse.allHeaderFields {
log(" \(key): \(value)")
}
}
// Ensure we have data
guard let data = data else {
log("No data received")
completion(.failure(M3U8StreamExtractorError.parsingError("No data received")))
return
}
// Try to parse as string
guard let content = String(data: data, encoding: .utf8) else {
log("Failed to decode playlist content")
completion(.failure(M3U8StreamExtractorError.parsingError("Failed to decode playlist content")))
return
}
// Log a sample of the content (first 200 chars)
let contentPreview = String(content.prefix(200))
log("Playlist Content (preview): \(contentPreview)...")
// Count the number of lines in the content
let lineCount = content.components(separatedBy: .newlines).count
log("Playlist has \(lineCount) lines")
// Parse the M3U8 content to extract available streams
log("Parsing M3U8 content")
let streams = parseM3U8Content(content: content, baseURL: masterURL)
// Log the extracted streams
log("Extracted \(streams.count) streams from M3U8 playlist")
for (index, stream) in streams.enumerated() {
log("Stream #\(index + 1): \(stream.name), \(stream.resolution.width)x\(stream.resolution.height), URL: \(stream.url)")
}
if streams.isEmpty {
log("No streams found in playlist")
}
// Select the appropriate stream based on quality preference
log("Selecting stream with quality preference: \(preferredQuality)")
if let selectedURL = selectStream(streams: streams, preferredQuality: preferredQuality),
let url = URL(string: selectedURL) {
log("Selected stream URL: \(url.absoluteString)")
var finalHeaders = requestHeaders
// Use header manager to optimize headers for the selected stream if available
if let controller = jsController {
log("Optimizing headers for selected stream")
finalHeaders = controller.ensureStreamingHeaders(headers: requestHeaders, for: url)
controller.logHeadersForRequest(headers: finalHeaders, url: url, operation: "Selected stream")
}
// Return the selected stream URL along with the headers
log("Extraction successful")
completion(.success((streamURL: url, headers: finalHeaders)))
} else if !streams.isEmpty, let fallbackStream = streams.first, let url = URL(string: fallbackStream.url) {
// Fallback to first stream if preferred quality not found
log("Preferred quality '\(preferredQuality)' not found, falling back to: \(fallbackStream.name)")
var finalHeaders = requestHeaders
// Use header manager for fallback stream
if let controller = jsController {
log("Optimizing headers for fallback stream")
finalHeaders = controller.ensureStreamingHeaders(headers: requestHeaders, for: url)
controller.logHeadersForRequest(headers: finalHeaders, url: url, operation: "Fallback stream")
}
log("Fallback extraction successful")
completion(.success((streamURL: url, headers: finalHeaders)))
} else if streams.isEmpty {
// If the playlist doesn't contain any streams, use the master URL as fallback
log("No streams found in the playlist, using master URL as fallback")
log("Using master URL as fallback")
completion(.success((streamURL: masterURL, headers: requestHeaders)))
} else {
log("No suitable stream found")
completion(.failure(M3U8StreamExtractorError.noStreamFound))
}
}
task.resume()
log("Request started")
}
/// Parses M3U8 content to extract available streams
/// - Parameters:
/// - content: The M3U8 playlist content as string
/// - baseURL: The base URL of the playlist for resolving relative URLs
/// - Returns: Array of extracted streams with name, URL, and resolution
private static func parseM3U8Content(
content: String,
baseURL: URL
) -> [(name: String, url: String, resolution: (width: Int, height: Int))] {
let lines = content.components(separatedBy: .newlines)
var streams: [(name: String, url: String, resolution: (width: Int, height: Int))] = []
for (index, line) in lines.enumerated() {
// Look for the stream info tag
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
// Extract resolution information
if let resolutionRange = line.range(of: "RESOLUTION="),
let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",")
?? line[resolutionRange.upperBound...].range(of: "\n") {
let resolutionPart = String(line[resolutionRange.upperBound..<resolutionEndRange.lowerBound])
let dimensions = resolutionPart.components(separatedBy: "x")
if dimensions.count == 2,
let width = Int(dimensions[0]),
let height = Int(dimensions[1]) {
// Get the URL from the next line
let nextLine = lines[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
// Generate a quality name
let qualityName = getQualityName(for: height)
// Handle relative URLs
var streamURL = nextLine
if !nextLine.hasPrefix("http") && nextLine.contains(".m3u8") {
let baseURLString = baseURL.deletingLastPathComponent().absoluteString
streamURL = URL(string: nextLine, relativeTo: baseURL)?.absoluteString
?? baseURLString + "/" + nextLine
}
// Add the stream to our list
streams.append((
name: qualityName,
url: streamURL,
resolution: (width: width, height: height)
))
}
}
}
}
return streams
}
/// Selects a stream based on the user's quality preference
/// - Parameters:
/// - streams: Array of available streams
/// - preferredQuality: User's preferred quality
/// - Returns: URL of the selected stream, or nil if no suitable stream was found
private static func selectStream(
streams: [(name: String, url: String, resolution: (width: Int, height: Int))],
preferredQuality: String
) -> String? {
guard !streams.isEmpty else { return nil }
// Sort streams by resolution (height) in descending order
let sortedStreams = streams.sorted { $0.resolution.height > $1.resolution.height }
switch preferredQuality {
case "Best":
// Return the highest quality stream
return sortedStreams.first?.url
case "High":
// Return a high quality stream (720p or higher, but not the highest)
let highStreams = sortedStreams.filter { $0.resolution.height >= 720 }
if highStreams.count > 1 {
return highStreams[1].url // Second highest if available
} else if !highStreams.isEmpty {
return highStreams[0].url // Highest if only one high quality stream
} else if !sortedStreams.isEmpty {
return sortedStreams.first?.url // Fallback to highest available
}
case "Medium":
// Return a medium quality stream (between 480p and 720p)
let mediumStreams = sortedStreams.filter {
$0.resolution.height >= 480 && $0.resolution.height < 720
}
if !mediumStreams.isEmpty {
return mediumStreams.first?.url
} else if sortedStreams.count > 1 {
let medianIndex = sortedStreams.count / 2
return sortedStreams[medianIndex].url // Return median quality as fallback
} else if !sortedStreams.isEmpty {
return sortedStreams.first?.url // Fallback to highest available
}
case "Low":
// Return the lowest quality stream
return sortedStreams.last?.url
default:
// Default to best quality
return sortedStreams.first?.url
}
return nil
}
/// Generates a quality name based on resolution height
/// - Parameter height: The vertical resolution (height) of the stream
/// - Returns: A human-readable quality name
private static func getQualityName(for height: Int) -> String {
switch height {
case 1080...: return "\(height)p (FHD)"
case 720..<1080: return "\(height)p (HD)"
case 480..<720: return "\(height)p (SD)"
default: return "\(height)p"
}
}
}

View file

@ -11,18 +11,78 @@ import UIKit
class DropManager {
static let shared = DropManager()
private var notificationQueue: [(title: String, subtitle: String, duration: TimeInterval, icon: UIImage?)] = []
private var isProcessingQueue = false
private init() {}
func showDrop(title: String, subtitle: String, duration: TimeInterval, icon: UIImage?) {
let position: Drop.Position = .top
// Add to queue
notificationQueue.append((title: title, subtitle: subtitle, duration: duration, icon: icon))
// Process queue if not already processing
if !isProcessingQueue {
processQueue()
}
}
private func processQueue() {
guard !notificationQueue.isEmpty else {
isProcessingQueue = false
return
}
isProcessingQueue = true
// Get the next notification
let notification = notificationQueue.removeFirst()
// Show the notification
let drop = Drop(
title: title,
subtitle: subtitle,
icon: icon,
position: position,
duration: .seconds(duration)
title: notification.title,
subtitle: notification.subtitle,
icon: notification.icon,
position: .top,
duration: .seconds(notification.duration)
)
Drops.show(drop)
// Schedule next notification
DispatchQueue.main.asyncAfter(deadline: .now() + notification.duration) { [weak self] in
self?.processQueue()
}
}
func success(_ message: String, duration: TimeInterval = 2.0) {
let icon = UIImage(systemName: "checkmark.circle.fill")?.withTintColor(.green, renderingMode: .alwaysOriginal)
showDrop(title: "Success", subtitle: message, duration: duration, icon: icon)
}
func error(_ message: String, duration: TimeInterval = 2.0) {
let icon = UIImage(systemName: "xmark.circle.fill")?.withTintColor(.red, renderingMode: .alwaysOriginal)
showDrop(title: "Error", subtitle: message, duration: duration, icon: icon)
}
func info(_ message: String, duration: TimeInterval = 2.0) {
let icon = UIImage(systemName: "info.circle.fill")?.withTintColor(.blue, renderingMode: .alwaysOriginal)
showDrop(title: "Info", subtitle: message, duration: duration, icon: icon)
}
// Method for handling download notifications with accurate status determination
func downloadStarted(episodeNumber: Int) {
// Use the JSController method to accurately determine if download will start immediately
let willStartImmediately = JSController.shared.willDownloadStartImmediately()
let message = willStartImmediately
? "Episode \(episodeNumber) download started"
: "Episode \(episodeNumber) queued"
showDrop(
title: willStartImmediately ? "Download Started" : "Download Queued",
subtitle: message,
duration: 1.5,
icon: UIImage(systemName: willStartImmediately ? "arrow.down.circle.fill" : "clock.arrow.circlepath")
)
}
}

View file

@ -16,4 +16,18 @@ extension UserDefaults {
return nil
}
}
func set(_ color: UIColor?, forKey key: String) {
guard let color = color else {
removeObject(forKey: key)
return
}
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: false)
set(data, forKey: key)
} catch {
print("Error archiving color: \(error)")
}
}
}

View file

@ -0,0 +1,391 @@
//
// JSController+M3U8Download.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import SwiftUI
// No need to import DownloadQualityPreference as it's in the same module
// Extension for integrating M3U8StreamExtractor with JSController for downloads
extension JSController {
/// Initiates a download for a given URL, handling M3U8 playlists if necessary
/// - Parameters:
/// - url: The URL to download
/// - headers: HTTP headers to use for the request
/// - title: Title for the download (optional)
/// - imageURL: Image URL for the content (optional)
/// - isEpisode: Whether this is an episode (defaults to false)
/// - showTitle: Title of the show this episode belongs to (optional)
/// - season: Season number (optional)
/// - episode: Episode number (optional)
/// - subtitleURL: Optional subtitle URL to download after video (optional)
/// - completionHandler: Called when the download is initiated or fails
func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil,
imageURL: URL? = nil, isEpisode: Bool = false,
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
completionHandler: ((Bool, String) -> Void)? = nil) {
// Use headers passed in from caller rather than generating our own baseUrl
// Receiving code should already be setting module.metadata.baseUrl
print("---- DOWNLOAD PROCESS STARTED ----")
print("Original URL: \(url.absoluteString)")
print("Headers: \(headers)")
print("Title: \(title ?? "None")")
print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")")
if let subtitle = subtitleURL {
print("Subtitle URL: \(subtitle.absoluteString)")
}
// Check if the URL is an M3U8 file
if url.absoluteString.contains(".m3u8") {
// Get the user's quality preference
let preferredQuality = DownloadQualityPreference.current.rawValue
print("URL detected as M3U8 playlist - will select quality based on user preference: \(preferredQuality)")
// Parse the M3U8 content to extract available qualities, matching CustomPlayer approach
parseM3U8(url: url, baseUrl: url.absoluteString, headers: headers) { [weak self] qualities in
DispatchQueue.main.async {
guard let self = self else { return }
if qualities.isEmpty {
print("M3U8 Analysis: No quality options found in M3U8, downloading with original URL")
self.downloadWithOriginalMethod(
url: url,
headers: headers,
title: title,
imageURL: imageURL,
isEpisode: isEpisode,
showTitle: showTitle,
season: season,
episode: episode,
subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler
)
return
}
print("M3U8 Analysis: Found \(qualities.count) quality options")
for (index, quality) in qualities.enumerated() {
print(" \(index + 1). \(quality.0) - \(quality.1)")
}
// Select appropriate quality based on user preference
let selectedQuality = self.selectQualityBasedOnPreference(qualities: qualities, preferredQuality: preferredQuality)
print("M3U8 Analysis: Selected quality: \(selectedQuality.0)")
print("M3U8 Analysis: Selected URL: \(selectedQuality.1)")
if let qualityURL = URL(string: selectedQuality.1) {
print("FINAL DOWNLOAD URL: \(qualityURL.absoluteString)")
print("QUALITY SELECTED: \(selectedQuality.0)")
// Download with standard headers that match the player
self.downloadWithOriginalMethod(
url: qualityURL,
headers: headers,
title: title,
imageURL: imageURL,
isEpisode: isEpisode,
showTitle: showTitle,
season: season,
episode: episode,
subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler
)
} else {
print("M3U8 Analysis: Invalid quality URL, falling back to original URL")
print("FINAL DOWNLOAD URL (fallback): \(url.absoluteString)")
self.downloadWithOriginalMethod(
url: url,
headers: headers,
title: title,
imageURL: imageURL,
isEpisode: isEpisode,
showTitle: showTitle,
season: season,
episode: episode,
subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler
)
}
}
}
} else {
// Not an M3U8 file, use the original download method with standard headers
print("URL is not an M3U8 playlist - downloading directly")
print("FINAL DOWNLOAD URL (direct): \(url.absoluteString)")
downloadWithOriginalMethod(
url: url,
headers: headers,
title: title,
imageURL: imageURL,
isEpisode: isEpisode,
showTitle: showTitle,
season: season,
episode: episode,
subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler
)
}
}
/// Parses an M3U8 file to extract available quality options, matching CustomPlayer's approach exactly
/// - Parameters:
/// - url: The URL of the M3U8 file
/// - baseUrl: The base URL for setting headers
/// - headers: HTTP headers to use for the request
/// - completion: Called with the array of quality options (name, URL)
private func parseM3U8(url: URL, baseUrl: String, headers: [String: String], completion: @escaping ([(String, String)]) -> Void) {
var request = URLRequest(url: url)
// Add headers from headers passed to downloadWithM3U8Support
// This ensures we use the same headers as the player (from module.metadata.baseUrl)
for (key, value) in headers {
request.addValue(value, forHTTPHeaderField: key)
}
print("M3U8 Parser: Fetching M3U8 content from: \(url.absoluteString)")
URLSession.shared.dataTask(with: request) { data, response, error in
// Log HTTP status for debugging
if let httpResponse = response as? HTTPURLResponse {
print("M3U8 Parser: HTTP Status: \(httpResponse.statusCode) for \(url.absoluteString)")
if httpResponse.statusCode >= 400 {
print("M3U8 Parser: HTTP Error: \(httpResponse.statusCode)")
completion([])
return
}
}
if let error = error {
print("M3U8 Parser: Error fetching M3U8: \(error.localizedDescription)")
completion([])
return
}
guard let data = data, let content = String(data: data, encoding: .utf8) else {
print("M3U8 Parser: Failed to load or decode M3U8 file")
completion([])
return
}
print("M3U8 Parser: Successfully fetched M3U8 content (\(data.count) bytes)")
let lines = content.components(separatedBy: .newlines)
print("M3U8 Parser: Found \(lines.count) lines in M3U8 file")
var qualities: [(String, String)] = []
// Always include the original URL as "Auto" option
qualities.append(("Auto (Recommended)", url.absoluteString))
print("M3U8 Parser: Added 'Auto' quality option with original URL")
func getQualityName(for height: Int) -> String {
switch height {
case 1080...: return "\(height)p (FHD)"
case 720..<1080: return "\(height)p (HD)"
case 480..<720: return "\(height)p (SD)"
default: return "\(height)p"
}
}
// Parse the M3U8 content to extract available streams - exactly like CustomPlayer
print("M3U8 Parser: Scanning for quality options...")
var qualitiesFound = 0
for (index, line) in lines.enumerated() {
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
print("M3U8 Parser: Found stream info at line \(index): \(line)")
if let resolutionRange = line.range(of: "RESOLUTION="),
let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",")
?? line[resolutionRange.upperBound...].range(of: "\n") {
let resolutionPart = String(line[resolutionRange.upperBound..<resolutionEndRange.lowerBound])
print("M3U8 Parser: Extracted resolution: \(resolutionPart)")
if let heightStr = resolutionPart.components(separatedBy: "x").last,
let height = Int(heightStr) {
let nextLine = lines[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
let qualityName = getQualityName(for: height)
print("M3U8 Parser: Found height \(height)px, quality name: \(qualityName)")
print("M3U8 Parser: Stream URL from next line: \(nextLine)")
var qualityURL = nextLine
if !nextLine.hasPrefix("http") && nextLine.contains(".m3u8") {
// Handle relative URLs
let baseURLString = url.deletingLastPathComponent().absoluteString
let resolvedURL = URL(string: nextLine, relativeTo: url)?.absoluteString
?? baseURLString + "/" + nextLine
qualityURL = resolvedURL
print("M3U8 Parser: Resolved relative URL to: \(qualityURL)")
}
if !qualities.contains(where: { $0.0 == qualityName }) {
qualities.append((qualityName, qualityURL))
qualitiesFound += 1
print("M3U8 Parser: Added quality option: \(qualityName) - \(qualityURL)")
} else {
print("M3U8 Parser: Skipped duplicate quality: \(qualityName)")
}
} else {
print("M3U8 Parser: Failed to extract height from resolution: \(resolutionPart)")
}
} else {
print("M3U8 Parser: Failed to extract resolution from line: \(line)")
}
}
}
print("M3U8 Parser: Found \(qualitiesFound) distinct quality options (plus Auto)")
print("M3U8 Parser: Total quality options: \(qualities.count)")
completion(qualities)
}.resume()
}
/// Selects the appropriate quality based on user preference
/// - Parameters:
/// - qualities: Available quality options (name, URL)
/// - preferredQuality: User's preferred quality
/// - Returns: The selected quality (name, URL)
private func selectQualityBasedOnPreference(qualities: [(String, String)], preferredQuality: String) -> (String, String) {
// If only one quality is available, return it
if qualities.count <= 1 {
print("Quality Selection: Only one quality option available, returning it directly")
return qualities[0]
}
// Extract "Auto" quality and the remaining qualities
let autoQuality = qualities.first { $0.0.contains("Auto") }
let nonAutoQualities = qualities.filter { !$0.0.contains("Auto") }
print("Quality Selection: Found \(nonAutoQualities.count) non-Auto quality options")
print("Quality Selection: Auto quality option: \(autoQuality?.0 ?? "None")")
// Sort non-auto qualities by resolution (highest first)
let sortedQualities = nonAutoQualities.sorted { first, second in
let firstHeight = Int(first.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
let secondHeight = Int(second.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
return firstHeight > secondHeight
}
print("Quality Selection: Sorted qualities (highest to lowest):")
for (index, quality) in sortedQualities.enumerated() {
print(" \(index + 1). \(quality.0) - \(quality.1)")
}
print("Quality Selection: User preference is '\(preferredQuality)'")
// Select quality based on preference
switch preferredQuality {
case "Best":
// Return the highest quality (first in sorted list)
let selected = sortedQualities.first ?? qualities[0]
print("Quality Selection: Selected 'Best' quality: \(selected.0)")
return selected
case "High":
// Look for 720p quality
let highQuality = sortedQualities.first {
$0.0.contains("720p") || $0.0.contains("HD")
}
if let high = highQuality {
print("Quality Selection: Found specific 'High' (720p/HD) quality: \(high.0)")
return high
} else if let first = sortedQualities.first {
print("Quality Selection: No specific 'High' quality found, using highest available: \(first.0)")
return first
} else {
print("Quality Selection: No non-Auto qualities found, falling back to default: \(qualities[0].0)")
return qualities[0]
}
case "Medium":
// Look for 480p quality
let mediumQuality = sortedQualities.first {
$0.0.contains("480p") || $0.0.contains("SD")
}
if let medium = mediumQuality {
print("Quality Selection: Found specific 'Medium' (480p/SD) quality: \(medium.0)")
return medium
} else if !sortedQualities.isEmpty {
// Return middle quality from sorted list if no exact match
let middleIndex = sortedQualities.count / 2
print("Quality Selection: No specific 'Medium' quality found, using middle quality: \(sortedQualities[middleIndex].0)")
return sortedQualities[middleIndex]
} else {
print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)")
return autoQuality ?? qualities[0]
}
case "Low":
// Return lowest quality (last in sorted list)
if let lowest = sortedQualities.last {
print("Quality Selection: Selected 'Low' quality: \(lowest.0)")
return lowest
} else {
print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)")
return autoQuality ?? qualities[0]
}
default:
// Default to Auto if available, otherwise first quality
if let auto = autoQuality {
print("Quality Selection: Default case, using Auto quality: \(auto.0)")
return auto
} else {
print("Quality Selection: No Auto quality found, using first available: \(qualities[0].0)")
return qualities[0]
}
}
}
/// The original download method (adapted to be called internally)
/// This method should match the existing download implementation in JSController-Downloads.swift
private func downloadWithOriginalMethod(url: URL, headers: [String: String], title: String? = nil,
imageURL: URL? = nil, isEpisode: Bool = false,
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
completionHandler: ((Bool, String) -> Void)? = nil) {
// Call the existing download method
self.startDownload(
url: url,
headers: headers,
title: title,
imageURL: imageURL,
isEpisode: isEpisode,
showTitle: showTitle,
season: season,
episode: episode,
subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler
)
}
}
// MARK: - Private API Compatibility Extension
// This extension ensures compatibility with the existing JSController-Downloads.swift implementation
private extension JSController {
// No longer needed since JSController-Downloads.swift has been implemented
// Remove the duplicate startDownload method to avoid conflicts
}

View file

@ -0,0 +1,273 @@
//
// JSController+MP4Download.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import SwiftUI
// Extension for handling MP4 direct video downloads
extension JSController {
/// Initiates a download for a given MP4 URL
/// - Parameters:
/// - url: The MP4 URL to download
/// - headers: HTTP headers to use for the request
/// - title: Title for the download (optional)
/// - imageURL: Image URL for the content (optional)
/// - isEpisode: Whether this is an episode (defaults to false)
/// - showTitle: Title of the show this episode belongs to (optional)
/// - season: Season number (optional)
/// - episode: Episode number (optional)
/// - subtitleURL: Optional subtitle URL to download after video (optional)
/// - completionHandler: Called when the download is initiated or fails
func downloadMP4(url: URL, headers: [String: String], title: String? = nil,
imageURL: URL? = nil, isEpisode: Bool = false,
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
subtitleURL: URL? = nil,
completionHandler: ((Bool, String) -> Void)? = nil) {
print("---- MP4 DOWNLOAD PROCESS STARTED ----")
print("MP4 URL: \(url.absoluteString)")
print("Headers: \(headers)")
print("Title: \(title ?? "None")")
print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")")
if let subtitle = subtitleURL {
print("Subtitle URL: \(subtitle.absoluteString)")
}
// Create metadata for the download
var metadata: AssetMetadata? = nil
if let title = title {
metadata = AssetMetadata(
title: title,
posterURL: imageURL,
showTitle: showTitle,
season: season,
episode: episode,
showPosterURL: imageURL // Use the correct show poster URL
)
}
// Determine download type based on isEpisode
let downloadType: DownloadType = isEpisode ? .episode : .movie
// Generate a unique download ID
let downloadID = UUID()
// Create an active download object
let activeDownload = JSActiveDownload(
id: downloadID,
originalURL: url,
task: nil, // We'll set this after creating the task
queueStatus: .queued,
type: downloadType,
metadata: metadata,
title: title,
imageURL: imageURL,
subtitleURL: subtitleURL,
headers: headers
)
// Add to active downloads
activeDownloads.append(activeDownload)
// Create a URL session task for downloading the MP4 file
var request = URLRequest(url: url)
for (key, value) in headers {
request.addValue(value, forHTTPHeaderField: key)
}
// Get access to the download directory using the shared instance method
guard let downloadDirectory = getPersistentDownloadDirectory() else {
print("MP4 Download: Failed to get download directory")
completionHandler?(false, "Failed to create download directory")
return
}
// Generate a unique filename for the MP4 file
let filename = "\(downloadID.uuidString).mp4"
let destinationURL = downloadDirectory.appendingPathComponent(filename)
// Use a session configuration that allows handling SSL issues
let sessionConfig = URLSessionConfiguration.default
// Set a longer timeout for large files
sessionConfig.timeoutIntervalForRequest = 60.0
sessionConfig.timeoutIntervalForResource = 600.0
// Create a URL session that handles SSL certificate validation issues
let customSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)
// Create the download task with the custom session
let downloadTask = customSession.downloadTask(with: request) { (tempURL, response, error) in
DispatchQueue.main.async {
if let error = error {
print("MP4 Download Error: \(error.localizedDescription)")
// Update active download status
if let index = self.activeDownloads.firstIndex(where: { $0.id == downloadID }) {
self.activeDownloads[index].queueStatus = .queued
}
// Clean up resources
self.mp4ProgressObservations?[downloadID] = nil
self.mp4CustomSessions?[downloadID] = nil
// Remove the download after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
self.activeDownloads.removeAll { $0.id == downloadID }
}
completionHandler?(false, "Download failed: \(error.localizedDescription)")
return
}
guard let httpResponse = response as? HTTPURLResponse else {
print("MP4 Download: Invalid response")
completionHandler?(false, "Invalid server response")
return
}
if httpResponse.statusCode >= 400 {
print("MP4 Download HTTP Error: \(httpResponse.statusCode)")
completionHandler?(false, "Server error: \(httpResponse.statusCode)")
return
}
guard let tempURL = tempURL else {
print("MP4 Download: No temporary file URL")
completionHandler?(false, "Download data not available")
return
}
do {
// Move the temporary file to the permanent location
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.moveItem(at: tempURL, to: destinationURL)
print("MP4 Download: Successfully moved file to \(destinationURL.path)")
// Create the downloaded asset
let downloadedAsset = DownloadedAsset(
name: title ?? url.lastPathComponent,
downloadDate: Date(),
originalURL: url,
localURL: destinationURL,
type: downloadType,
metadata: metadata,
subtitleURL: subtitleURL
)
// Add to saved assets
self.savedAssets.append(downloadedAsset)
self.saveAssets()
// Update active download and remove after a delay
if let index = self.activeDownloads.firstIndex(where: { $0.id == downloadID }) {
self.activeDownloads[index].progress = 1.0
self.activeDownloads[index].queueStatus = .completed
}
// Download subtitle if provided
if let subtitleURL = subtitleURL {
self.downloadSubtitle(subtitleURL: subtitleURL, assetID: downloadedAsset.id.uuidString)
}
// Notify observers - use downloadCompleted since the download finished
NotificationCenter.default.post(name: NSNotification.Name("downloadCompleted"), object: nil)
completionHandler?(true, "Download completed successfully")
// Clean up resources
self.mp4ProgressObservations?[downloadID] = nil
self.mp4CustomSessions?[downloadID] = nil
// Remove the completed download from active list after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
self.activeDownloads.removeAll { $0.id == downloadID }
}
} catch {
print("MP4 Download Error moving file: \(error.localizedDescription)")
completionHandler?(false, "Error saving download: \(error.localizedDescription)")
}
}
}
// Set up progress tracking
downloadTask.resume()
// Update the task in the active download
if let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) {
activeDownloads[index].queueStatus = .downloading
// Store reference to the downloadTask directly - no need to access private properties
print("MP4 Download: Task started")
// We can't directly store URLSessionDownloadTask in place of AVAssetDownloadTask
// Just continue tracking progress separately
}
// Set up progress observation - fix the key path specification
let observation = downloadTask.progress.observe(\Progress.fractionCompleted) { progress, _ in
DispatchQueue.main.async {
if let index = self.activeDownloads.firstIndex(where: { $0.id == downloadID }) {
self.activeDownloads[index].progress = progress.fractionCompleted
// Notify observers of progress update
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressUpdated"), object: nil)
}
}
}
// Store the observation somewhere to keep it alive - using nonatomic property from main class
if self.mp4ProgressObservations == nil {
self.mp4ProgressObservations = [:]
}
self.mp4ProgressObservations?[downloadID] = observation
// Store the custom session to keep it alive until download is complete
if self.mp4CustomSessions == nil {
self.mp4CustomSessions = [:]
}
self.mp4CustomSessions?[downloadID] = customSession
// Notify that download started successfully
completionHandler?(true, "Download started")
}
}
// Extension for handling SSL certificate validation for MP4 downloads
extension JSController: URLSessionDelegate {
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
// Handle SSL/TLS certificate validation
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
let host = challenge.protectionSpace.host
print("MP4 Download: Handling server trust challenge for host: \(host)")
// Accept the server's certificate for known problematic domains
// or for domains in our custom session downloads
if host.contains("streamtales.cc") ||
host.contains("frembed.xyz") ||
host.contains("vidclouds.cc") ||
self.mp4CustomSessions?.values.contains(session) == true {
if let serverTrust = challenge.protectionSpace.serverTrust {
// Log detailed info about the trust
print("MP4 Download: Accepting certificate for \(host)")
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
return
}
}
}
// For other authentication challenges, use default handling
print("MP4 Download: Using default handling for auth challenge")
completionHandler(.performDefaultHandling, nil)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,166 @@
//
// JSController-HeaderManager.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
// Protocol for header management functionality
protocol HeaderManaging {
func logHeadersForRequest(headers: [String: String], url: URL, operation: String)
func ensureStreamingHeaders(headers: [String: String], for url: URL) -> [String: String]
func combineStreamingHeaders(originalHeaders: [String: String], streamHeaders: [String: String], for url: URL) -> [String: String]
}
// Extension for managing HTTP headers in the JSController
extension JSController: HeaderManaging {
// Enable verbose logging for development/testing
static var verboseHeaderLogging: Bool = true
/// Standard headers needed for most streaming sites
struct StandardHeaders {
// Common header keys
static let origin = "Origin"
static let referer = "Referer"
static let userAgent = "User-Agent"
// Default user agent for streaming
static let defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
}
/// Logs messages with a consistent format if verbose logging is enabled
/// - Parameters:
/// - message: The message to log
/// - function: The calling function (auto-filled)
/// - line: The line number (auto-filled)
private func logHeader(_ message: String, function: String = #function, line: Int = #line) {
if JSController.verboseHeaderLogging {
print("[HeaderManager:\(function):\(line)] \(message)")
}
}
/// Ensures that the necessary headers for streaming are present
/// - Parameters:
/// - headers: Original headers from the request
/// - url: The URL being requested
/// - Returns: Headers with necessary streaming headers added if missing
func ensureStreamingHeaders(headers: [String: String], for url: URL) -> [String: String] {
logHeader("Ensuring streaming headers for URL: \(url.absoluteString)")
logHeader("Original headers count: \(headers.count)")
var updatedHeaders = headers
// Check if we have a URL host
guard let host = url.host else {
logHeader("No host in URL, returning original headers")
return headers
}
// Generate base URL (scheme + host)
let baseUrl = "\(url.scheme ?? "https")://\(host)"
logHeader("Base URL for headers: \(baseUrl)")
// Ensure Origin is set
if updatedHeaders[StandardHeaders.origin] == nil {
logHeader("Adding missing Origin header: \(baseUrl)")
updatedHeaders[StandardHeaders.origin] = baseUrl
}
// Ensure Referer is set
if updatedHeaders[StandardHeaders.referer] == nil {
logHeader("Adding missing Referer header: \(baseUrl)")
updatedHeaders[StandardHeaders.referer] = baseUrl
}
// Ensure User-Agent is set
if updatedHeaders[StandardHeaders.userAgent] == nil {
logHeader("Adding missing User-Agent header")
updatedHeaders[StandardHeaders.userAgent] = StandardHeaders.defaultUserAgent
}
// Add additional common streaming headers that might help with 403 errors
let additionalHeaders: [String: String] = [
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
]
for (key, value) in additionalHeaders {
if updatedHeaders[key] == nil {
updatedHeaders[key] = value
}
}
logHeader("Final headers count: \(updatedHeaders.count)")
return updatedHeaders
}
/// Preserves critical headers from the original stream while adding new ones
/// - Parameters:
/// - originalHeaders: Original headers used for fetching the master playlist
/// - streamHeaders: Headers for the specific stream (may be empty)
/// - url: The URL of the stream
/// - Returns: Combined headers optimized for the stream request
func combineStreamingHeaders(originalHeaders: [String: String], streamHeaders: [String: String], for url: URL) -> [String: String] {
logHeader("Combining headers for URL: \(url.absoluteString)")
logHeader("Original headers count: \(originalHeaders.count), Stream headers count: \(streamHeaders.count)")
var combinedHeaders: [String: String] = [:]
// Add all stream-specific headers first (highest priority)
for (key, value) in streamHeaders {
combinedHeaders[key] = value
}
// Add original headers for any keys not already present
for (key, value) in originalHeaders {
if combinedHeaders[key] == nil {
combinedHeaders[key] = value
}
}
logHeader("Combined headers count before ensuring: \(combinedHeaders.count)")
// Finally, ensure all critical headers are present
let finalHeaders = ensureStreamingHeaders(headers: combinedHeaders, for: url)
return finalHeaders
}
/// Logs the headers being used for a request (for debugging)
/// - Parameters:
/// - headers: The headers to log
/// - url: The URL being requested
/// - operation: The operation being performed (e.g., "Downloading", "Extracting")
func logHeadersForRequest(headers: [String: String], url: URL, operation: String) {
logHeader("\(operation) \(url.absoluteString)")
logHeader("Headers:")
// Get the important headers first
let importantKeys = [
StandardHeaders.origin,
StandardHeaders.referer,
StandardHeaders.userAgent
]
for key in importantKeys {
if let value = headers[key] {
logHeader(" [IMPORTANT] \(key): \(value)")
} else {
logHeader(" [MISSING] \(key)")
}
}
// Then log all other headers
for (key, value) in headers {
if !importantKeys.contains(key) {
logHeader(" \(key): \(value)")
}
}
}
}

View file

@ -0,0 +1,92 @@
//
// JSController-StreamTypeDownload.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import SwiftUI
// Extension that integrates streamType-aware downloading
extension JSController {
/// Main entry point for downloading that determines the appropriate download method based on streamType
/// - Parameters:
/// - url: The URL to download
/// - headers: HTTP headers to use for the request
/// - title: Title for the download (optional)
/// - imageURL: Image URL for the content (optional)
/// - module: The module being used for the download, used to determine streamType
/// - isEpisode: Whether this is an episode (defaults to false)
/// - showTitle: Title of the show this episode belongs to (optional)
/// - season: Season number (optional)
/// - episode: Episode number (optional)
/// - subtitleURL: Optional subtitle URL to download after video (optional)
/// - completionHandler: Called when the download is initiated or fails
func downloadWithStreamTypeSupport(
url: URL,
headers: [String: String],
title: String? = nil,
imageURL: URL? = nil,
module: ScrapingModule,
isEpisode: Bool = false,
showTitle: String? = nil,
season: Int? = nil,
episode: Int? = nil,
subtitleURL: URL? = nil,
showPosterURL: URL? = nil,
completionHandler: ((Bool, String) -> Void)? = nil
) {
print("---- STREAM TYPE DOWNLOAD PROCESS STARTED ----")
print("Original URL: \(url.absoluteString)")
print("Stream Type: \(module.metadata.streamType)")
print("Headers: \(headers)")
print("Title: \(title ?? "None")")
print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")")
if let subtitle = subtitleURL {
print("Subtitle URL: \(subtitle.absoluteString)")
}
// Check the stream type from the module metadata
let streamType = module.metadata.streamType.lowercased()
// Determine which download method to use based on streamType
if streamType == "mp4" || streamType == "direct" || url.absoluteString.contains(".mp4") {
print("MP4 URL detected - downloading not supported")
completionHandler?(false, "MP4 direct downloads are not supported. Please use HLS streams for downloading.")
return
} else if streamType == "hls" || streamType == "m3u8" || url.absoluteString.contains(".m3u8") {
print("Using HLS download method")
downloadWithM3U8Support(
url: url,
headers: headers,
title: title,
imageURL: imageURL,
isEpisode: isEpisode,
showTitle: showTitle,
season: season,
episode: episode,
subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler
)
} else {
// Default to M3U8 method for unknown types, as it has fallback mechanisms
print("Using default HLS download method for unknown stream type: \(streamType)")
downloadWithM3U8Support(
url: url,
headers: headers,
title: title,
imageURL: imageURL,
isEpisode: isEpisode,
showTitle: showTitle,
season: season,
episode: episode,
subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler
)
}
}
}

View file

@ -80,11 +80,22 @@ extension JSController {
}
}
// Check if the result is a Promise object and handle it properly
if resultString == "[object Promise]" {
Logger.shared.log("Received Promise object instead of resolved value, waiting for proper resolution", type: "Stream")
// Skip this result - other methods will provide the resolved URL
let workItem = DispatchWorkItem { completion((nil, nil, nil)) }
DispatchQueue.main.async(execute: workItem)
return
}
Logger.shared.log("Starting stream from: \(resultString)", type: "Stream")
DispatchQueue.main.async { completion(([resultString], nil,nil)) }
let workItem = DispatchWorkItem { completion(([resultString], nil, nil)) }
DispatchQueue.main.async(execute: workItem)
} else {
Logger.shared.log("Failed to extract stream URL", type: "Error")
DispatchQueue.main.async { completion((nil, nil,nil)) }
let workItem = DispatchWorkItem { completion((nil, nil, nil)) }
DispatchQueue.main.async(execute: workItem)
}
}.resume()
}
@ -162,6 +173,14 @@ extension JSController {
let streamUrl = result.toString()
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
// Check if the result is a Promise object and handle it properly
if streamUrl == "[object Promise]" {
Logger.shared.log("Received Promise object instead of resolved value, waiting for proper resolution", type: "Stream")
// Skip this result - other methods will provide the resolved URL
return
}
DispatchQueue.main.async {
completion((streamUrl != nil ? [streamUrl!] : nil, nil,nil))
}
@ -271,6 +290,14 @@ extension JSController {
let streamUrl = result.toString()
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
// Check if the result is a Promise object and handle it properly
if streamUrl == "[object Promise]" {
Logger.shared.log("Received Promise object instead of resolved value, waiting for proper resolution", type: "Stream")
// Skip this result - other methods will provide the resolved URL
return
}
DispatchQueue.main.async {
completion((streamUrl != nil ? [streamUrl!] : nil, nil, nil))
}

View file

@ -6,25 +6,117 @@
//
import JavaScriptCore
import Foundation
import SwiftUI
import AVKit
import AVFoundation
class JSController: ObservableObject {
// Use ScrapingModule from Modules.swift as Module
typealias Module = ScrapingModule
class JSController: NSObject, ObservableObject {
// Shared instance that can be used across the app
static let shared = JSController()
var context: JSContext
init() {
// Downloaded assets storage
@Published var savedAssets: [DownloadedAsset] = []
@Published var activeDownloads: [JSActiveDownload] = []
// Tracking map for download tasks
var activeDownloadMap: [URLSessionTask: UUID] = [:]
// Download queue management
@Published var downloadQueue: [JSActiveDownload] = []
var isProcessingQueue: Bool = false
var maxConcurrentDownloads: Int {
UserDefaults.standard.object(forKey: "maxConcurrentDownloads") as? Int ?? 3
}
// Track downloads that have been cancelled to prevent completion processing
var cancelledDownloadIDs: Set<UUID> = []
// Download session
var downloadURLSession: AVAssetDownloadURLSession?
// For MP4 download progress tracking
var mp4ProgressObservations: [UUID: NSKeyValueObservation]?
// For storing custom URLSessions used for MP4 downloads
var mp4CustomSessions: [UUID: URLSession]?
override init() {
self.context = JSContext()
super.init()
setupContext()
loadSavedAssets()
}
func setupContext() {
context.setupJavaScriptEnvironment()
setupDownloadSession()
}
// Setup download functionality separately from general context setup
private func setupDownloadSession() {
// Only initialize download session if it doesn't exist already
if downloadURLSession == nil {
initializeDownloadSession()
setupDownloadFunction()
}
}
func loadScript(_ script: String) {
context = JSContext()
setupContext()
// Only set up the JavaScript environment without reinitializing the download session
context.setupJavaScriptEnvironment()
context.evaluateScript(script)
if let exception = context.exception {
Logger.shared.log("Error loading script: \(exception)", type: "Error")
}
}
// MARK: - Download Settings
/// Updates the maximum number of concurrent downloads and processes the queue if new slots are available
func updateMaxConcurrentDownloads(_ newLimit: Int) {
print("Updating max concurrent downloads from \(maxConcurrentDownloads) to \(newLimit)")
// The maxConcurrentDownloads computed property will automatically use the new UserDefaults value
// If the new limit is higher and we have queued downloads, process the queue
if !downloadQueue.isEmpty && !isProcessingQueue {
print("Processing download queue due to increased concurrent limit. Queue has \(downloadQueue.count) items.")
// Force UI update before processing queue
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.objectWillChange.send()
// Process the queue with a slight delay to ensure UI is ready
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.processDownloadQueue()
}
}
} else {
print("No queued downloads to process or queue is already being processed")
}
}
// MARK: - Stream URL Functions - Convenience methods
func fetchStreamUrl(episodeUrl: String, module: Module, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
// Implementation for the main fetchStreamUrl method
}
func fetchStreamUrlJS(episodeUrl: String, module: Module, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
// Implementation for the JS based stream URL fetching
}
func fetchStreamUrlJSSecond(episodeUrl: String, module: Module, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
// Implementation for the secondary JS based stream URL fetching
}
// MARK: - Header Management
// Header management functions are implemented in JSController-HeaderManager.swift extension file
}

View file

@ -7,6 +7,7 @@
import Foundation
@MainActor
class ModuleManager: ObservableObject {
@Published var modules: [ScrapingModule] = []
@Published var selectedModuleChanged = false
@ -202,6 +203,12 @@ class ModuleManager: ObservableObject {
return try String(contentsOf: localUrl, encoding: .utf8)
}
func getModule(for episodeUrl: String) -> ScrapingModule {
// For now, return the first active module
// In the future, we might want to add logic to determine which module to use based on the URL
return modules.first(where: { $0.isActive }) ?? modules.first!
}
func refreshModules() async {
for (index, module) in modules.enumerated() {
do {

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@
import SwiftUI
import Kingfisher
import AVFoundation
struct EpisodeLink: Identifiable {
let id = UUID()
@ -20,76 +21,314 @@ struct EpisodeCell: View {
let episodeID: Int
let progress: Double
let itemID: Int
let module: ScrapingModule
var totalEpisodes: Int?
var defaultBannerImage: String
var module: ScrapingModule
var parentTitle: String
var showPosterURL: String? // Add show poster URL for downloads
let onTap: (String) -> Void
let onMarkAllPrevious: () -> Void
// Multi-select support (Task MD-3)
var isMultiSelectMode: Bool = false
var isSelected: Bool = false
var onSelectionChanged: ((Bool) -> Void)?
var onTap: (String) -> Void
var onMarkAllPrevious: () -> Void
@State private var episodeTitle: String = ""
@State private var episodeImageUrl: String = ""
@State private var isLoading: Bool = true
@State private var currentProgress: Double = 0.0
@State private var isFetchingEpisode: Bool = false
@State private var showDownloadConfirmation = false
@State private var isDownloading: Bool = false
@State private var isPlaying = false
@State private var loadedFromCache: Bool = false
@State private var downloadStatus: EpisodeDownloadStatus = .notDownloaded
@State private var downloadRefreshTrigger: Bool = false
@State private var lastUpdateTime: Date = Date()
@State private var activeDownloadTask: AVAssetDownloadTask? = nil
@State private var lastStatusCheck: Date = Date()
@State private var lastLoggedStatus: EpisodeDownloadStatus?
@State private var downloadAnimationScale: CGFloat = 1.0
@StateObject private var jsController = JSController()
@EnvironmentObject private var moduleManager: ModuleManager
@EnvironmentObject private var downloadManager: DownloadManager
// Add retry configuration
@State private var retryAttempts: Int = 0
private let maxRetryAttempts: Int = 3
private let initialBackoffDelay: TimeInterval = 1.0
@ObservedObject private var jsController = JSController.shared
@EnvironmentObject var moduleManager: ModuleManager
@Environment(\.colorScheme) private var colorScheme
@AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system
var defaultBannerImage: String {
let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light)
return isLightMode
? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png"
: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png"
// Simple download status for UI updates
private var downloadStatusString: String {
switch downloadStatus {
case .notDownloaded:
return "notDownloaded"
case .downloading(let download):
return "downloading_\(download.id)"
case .downloaded(let asset):
return "downloaded_\(asset.id)"
}
}
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
itemID: Int, module: ScrapingModule, onTap: @escaping (String) -> Void,
onMarkAllPrevious: @escaping () -> Void) {
itemID: Int, totalEpisodes: Int? = nil, defaultBannerImage: String = "",
module: ScrapingModule, parentTitle: String, showPosterURL: String? = nil,
isMultiSelectMode: Bool = false, isSelected: Bool = false,
onSelectionChanged: ((Bool) -> Void)? = nil,
onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) {
self.episodeIndex = episodeIndex
self.episode = episode
self.episodeID = episodeID
self.progress = progress
self.itemID = itemID
self.totalEpisodes = totalEpisodes
// Initialize banner image based on appearance
let isLightMode = (UserDefaults.standard.string(forKey: "selectedAppearance") == "light") ||
((UserDefaults.standard.string(forKey: "selectedAppearance") == "system") &&
UITraitCollection.current.userInterfaceStyle == .light)
let defaultLightBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png"
let defaultDarkBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png"
self.defaultBannerImage = defaultBannerImage.isEmpty ?
(isLightMode ? defaultLightBanner : defaultDarkBanner) : defaultBannerImage
self.module = module
self.parentTitle = parentTitle
self.showPosterURL = showPosterURL
self.isMultiSelectMode = isMultiSelectMode
self.isSelected = isSelected
self.onSelectionChanged = onSelectionChanged
self.onTap = onTap
self.onMarkAllPrevious = onMarkAllPrevious
}
var body: some View {
HStack {
ZStack {
KFImage(URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl))
.resizable()
.aspectRatio(16/9, contentMode: .fill)
.frame(width: 100, height: 56)
.cornerRadius(8)
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
}
VStack(alignment: .leading) {
Text("Episode \(episodeID + 1)")
.font(.system(size: 15))
if !episodeTitle.isEmpty {
Text(episodeTitle)
.font(.system(size: 13))
.foregroundColor(.secondary)
// Multi-select checkbox (Task MD-3)
if isMultiSelectMode {
Button(action: {
onSelectionChanged?(!isSelected)
}) {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundColor(isSelected ? .accentColor : .secondary)
.font(.title3)
}
.buttonStyle(PlainButtonStyle())
}
episodeThumbnail
episodeInfo
Spacer()
downloadStatusView
CircularProgressBar(progress: currentProgress)
.frame(width: 40, height: 40)
}
.contentShape(Rectangle())
.background(isMultiSelectMode && isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.cornerRadius(8)
.contextMenu {
contextMenuContent
}
.onAppear {
// Stagger operations for better scroll performance
updateProgress()
// Check download status when cell appears (less frequently)
updateDownloadStatus()
// Slightly delay loading episode details to prioritize smooth scrolling
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
fetchEpisodeDetails()
}
// Prefetch next episodes when this one becomes visible
if let totalEpisodes = totalEpisodes, episodeID + 1 < totalEpisodes {
// Prefetch the next 5 episodes when this one appears
let nextEpisodeStart = episodeID + 1
let count = min(5, totalEpisodes - episodeID - 1)
// Also prefetch images for the next few episodes
// Commented out prefetching until ImagePrefetchManager is ready
// ImagePrefetchManager.shared.prefetchEpisodeImages(
// anilistId: itemID,
// startEpisode: nextEpisodeStart,
// count: count
// )
}
}
.onDisappear {
activeDownloadTask = nil
}
.onChange(of: progress) { _ in
updateProgress()
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in
// Update download status less frequently to reduce jitter
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
updateDownloadStatus()
updateProgress()
}
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadStatusChanged"))) { _ in
updateDownloadStatus()
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadCompleted"))) { _ in
updateDownloadStatus()
}
.onTapGesture {
if isMultiSelectMode {
onSelectionChanged?(!isSelected)
} else {
let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl
onTap(imageUrl)
}
}
.alert("Download Episode", isPresented: $showDownloadConfirmation) {
Button("Cancel", role: .cancel) {}
Button("Download") {
downloadEpisode()
}
} message: {
Text("Do you want to download Episode \(episodeID + 1)\(episodeTitle.isEmpty ? "" : ": \(episodeTitle)")?")
}
}
// MARK: - View Components
private var episodeThumbnail: some View {
ZStack {
if let url = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl) {
KFImage.optimizedEpisodeThumbnail(url: url)
// Convert back to the regular KFImage since the extension isn't available yet
.setProcessor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56)))
.memoryCacheExpiration(.seconds(600)) // Increase cache duration to reduce loading
.cacheOriginalImage()
.fade(duration: 0.1) // Shorter fade for better performance
.onFailure { error in
Logger.shared.log("Failed to load episode image: \(error)", type: "Error")
}
.cacheMemoryOnly(!KingfisherCacheManager.shared.isCachingEnabled)
.resizable()
.aspectRatio(16/9, contentMode: .fill)
.frame(width: 100, height: 56)
.cornerRadius(8)
.onAppear {
// Image loading logic if needed
}
} else {
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(width: 100, height: 56)
.cornerRadius(8)
}
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
}
}
private var episodeInfo: some View {
VStack(alignment: .leading) {
Text("Episode \(episodeID + 1)")
.font(.system(size: 15))
if !episodeTitle.isEmpty {
Text(episodeTitle)
.font(.system(size: 13))
.foregroundColor(.secondary)
}
}
}
private var downloadStatusView: some View {
Group {
switch downloadStatus {
case .notDownloaded:
downloadButton
case .downloading(let activeDownload):
if activeDownload.queueStatus == .queued {
queuedIndicator
} else {
downloadProgressView
}
case .downloaded:
downloadedIndicator
}
}
}
private var downloadButton: some View {
Button(action: {
showDownloadConfirmation = true
}) {
Image(systemName: "arrow.down.circle")
.foregroundColor(.blue)
.font(.title3)
}
.padding(.horizontal, 8)
}
private var downloadProgressView: some View {
HStack(spacing: 4) {
Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.blue)
.font(.title3)
.scaleEffect(downloadAnimationScale)
.onAppear {
withAnimation(
Animation.easeInOut(duration: 1.0).repeatForever(autoreverses: true)
) {
downloadAnimationScale = 1.2
}
}
.onDisappear {
downloadAnimationScale = 1.0
}
}
.padding(.horizontal, 8)
}
private var downloadedIndicator: some View {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.title3)
.padding(.horizontal, 8)
// Add animation to stand out more
.scaleEffect(1.1)
// Use more straightforward animation
.animation(.default, value: downloadStatusString)
}
private var queuedIndicator: some View {
HStack(spacing: 4) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.scaleEffect(0.8)
.accentColor(.orange)
Text("Queued")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 8)
}
private var contextMenuContent: some View {
Group {
if case .notDownloaded = downloadStatus {
Button(action: {
showDownloadConfirmation = true
}) {
Label("Download Episode", systemImage: "arrow.down.circle")
}
}
if progress <= 0.9 {
Button(action: markAsWatched) {
Label("Mark as Watched", systemImage: "checkmark.circle")
@ -112,22 +351,233 @@ struct EpisodeCell: View {
Label("Download Episode", systemImage: "arrow.down.circle")
}
}
.onAppear {
updateProgress()
}
private func updateDownloadStatus() {
// Check the current download status with JSController
let newStatus = jsController.isEpisodeDownloadedOrInProgress(
showTitle: parentTitle,
episodeNumber: episodeID + 1
)
// Only update if status actually changed to reduce unnecessary UI updates
if downloadStatus != newStatus {
downloadStatus = newStatus
}
}
private func downloadEpisode() {
// Check the current download status
updateDownloadStatus()
// Don't proceed if the episode is already downloaded or being downloaded
if case .notDownloaded = downloadStatus, !isDownloading {
isDownloading = true
let downloadID = UUID()
if let type = module.metadata.type?.lowercased(), type == "anime" {
fetchAnimeEpisodeDetails()
// Use the new consolidated download notification
DropManager.shared.downloadStarted(episodeNumber: episodeID + 1)
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
// Try download methods sequentially instead of in parallel
tryNextDownloadMethod(methodIndex: 0, downloadID: downloadID, softsub: module.metadata.softsub == true)
} catch {
DropManager.shared.error("Failed to start download: \(error.localizedDescription)")
isDownloading = false
}
}
} else {
// Handle case where download is already in progress or completed
if case .downloaded = downloadStatus {
DropManager.shared.info("Episode \(episodeID + 1) is already downloaded")
} else if case .downloading = downloadStatus {
DropManager.shared.info("Episode \(episodeID + 1) is already being downloaded")
}
}
.onChange(of: progress) { _ in
updateProgress()
}
// Try each download method sequentially
private func tryNextDownloadMethod(methodIndex: Int, downloadID: UUID, softsub: Bool) {
if !isDownloading {
return
}
.onTapGesture {
let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl
onTap(imageUrl)
print("[Download] Trying download method #\(methodIndex+1) for Episode \(episodeID + 1)")
switch methodIndex {
case 0:
// First try fetchStreamUrlJS if asyncJS is true
if module.metadata.asyncJS == true {
jsController.fetchStreamUrlJS(episodeUrl: episode, softsub: softsub, module: module) { result in
self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub)
}
} else {
// Skip to next method if not applicable
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
}
case 1:
// Then try fetchStreamUrlJSSecond if streamAsyncJS is true
if module.metadata.streamAsyncJS == true {
jsController.fetchStreamUrlJSSecond(episodeUrl: episode, softsub: softsub, module: module) { result in
self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub)
}
} else {
// Skip to next method if not applicable
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
}
case 2:
// Finally try fetchStreamUrl (most reliable method)
jsController.fetchStreamUrl(episodeUrl: episode, softsub: softsub, module: module) { result in
self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub)
}
default:
// We've tried all methods and none worked
DropManager.shared.error("Failed to find a valid stream for download after trying all methods")
isDownloading = false
}
}
// Handle result from sequential download attempts
private func handleSequentialDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), downloadID: UUID, methodIndex: Int, softsub: Bool) {
// Skip if we're no longer downloading
if !isDownloading {
return
}
// Check if we have valid streams
if let streams = result.streams, !streams.isEmpty, let url = URL(string: streams[0]) {
// Check if it's a Promise object
if streams[0] == "[object Promise]" {
print("[Download] Method #\(methodIndex+1) returned a Promise object, trying next method")
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
return
}
// We found a valid stream URL, proceed with download
print("[Download] Method #\(methodIndex+1) returned valid stream URL: \(streams[0])")
// Get subtitle URL if available
let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
print("[Download] Found subtitle URL: \(subtitleURL.absoluteString)")
}
startActualDownload(url: url, streamUrl: streams[0], downloadID: downloadID, subtitleURL: subtitleURL)
} else if let sources = result.sources, !sources.isEmpty,
let streamUrl = sources[0]["streamUrl"] as? String,
let url = URL(string: streamUrl) {
print("[Download] Method #\(methodIndex+1) returned valid stream URL with headers: \(streamUrl)")
// Get subtitle URL if available
let subtitleURLString = sources[0]["subtitle"] as? String
let subtitleURL = subtitleURLString.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
print("[Download] Found subtitle URL: \(subtitleURL.absoluteString)")
}
startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURL)
} else {
// No valid streams from this method, try the next one
print("[Download] Method #\(methodIndex+1) did not return valid streams, trying next method")
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
}
}
// Start the actual download process once we have a valid URL
private func startActualDownload(url: URL, streamUrl: String, downloadID: UUID, subtitleURL: URL? = nil) {
// Extract base URL for headers
var headers: [String: String] = [:]
// Always use the module's baseUrl for Origin and Referer
if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") {
print("Using module baseUrl: \(module.metadata.baseUrl)")
// Create comprehensive headers prioritizing the module's baseUrl
headers = [
"Origin": module.metadata.baseUrl,
"Referer": module.metadata.baseUrl,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
]
} else {
// Fallback to using the stream URL's domain if module.baseUrl isn't available
if let scheme = url.scheme, let host = url.host {
let baseUrl = scheme + "://" + host
headers = [
"Origin": baseUrl,
"Referer": baseUrl,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
]
} else {
// Missing URL components
DropManager.shared.error("Invalid stream URL - missing scheme or host")
isDownloading = false
return
}
}
print("Download headers: \(headers)")
// Use episode thumbnail for the individual episode, show poster for grouping
let episodeThumbnailURL = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl)
let showPosterImageURL = URL(string: showPosterURL ?? defaultBannerImage)
// Get the episode title and information
let episodeName = episodeTitle.isEmpty ? "Episode \(episodeID + 1)" : episodeTitle
let fullEpisodeTitle = "Episode \(episodeID + 1): \(episodeName)"
// Extract show title from the parent view
let animeTitle = parentTitle.isEmpty ? "Unknown Anime" : parentTitle
// Use streamType-aware download method instead of M3U8-specific method
jsController.downloadWithStreamTypeSupport(
url: url,
headers: headers,
title: fullEpisodeTitle,
imageURL: episodeThumbnailURL,
module: module,
isEpisode: true,
showTitle: animeTitle,
season: 1, // Default to season 1 if not known
episode: episodeID + 1,
subtitleURL: subtitleURL,
showPosterURL: showPosterImageURL,
completionHandler: { success, message in
if success {
// Log the download for analytics
Logger.shared.log("Started download for Episode \(self.episodeID + 1): \(self.episode)", type: "Download")
AnalyticsManager.shared.sendEvent(
event: "download",
additionalData: ["episode": self.episodeID + 1, "url": streamUrl]
)
} else {
DropManager.shared.error(message)
}
// Mark that we've handled this download
self.isDownloading = false
}
)
}
private func markAsWatched() {
let userDefaults = UserDefaults.standard
let totalTime = 1000.0
@ -155,113 +605,183 @@ struct EpisodeCell: View {
currentProgress = totalTime > 0 ? min(lastPlayedTime / totalTime, 1.0) : 0
}
private func fetchEpisodeDetails() {
// Check if metadata caching is enabled
if MetadataCacheManager.shared.isCachingEnabled &&
(UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil ||
UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata")) {
// Create a cache key using the anilist ID and episode number
let cacheKey = "anilist_\(itemID)_episode_\(episodeID + 1)"
// Try to get from cache first
if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
let metadata = EpisodeMetadata.fromData(cachedData) {
// Successfully loaded from cache
DispatchQueue.main.async {
self.episodeTitle = metadata.title["en"] ?? ""
self.episodeImageUrl = metadata.imageUrl
self.isLoading = false
self.loadedFromCache = true
}
return
}
}
// Cache miss or caching disabled, fetch from network
fetchAnimeEpisodeDetails()
}
private func fetchAnimeEpisodeDetails() {
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else {
isLoading = false
Logger.shared.log("Invalid URL for itemID: \(itemID)", type: "Error")
return
}
URLSession.custom.dataTask(with: url) { data, _, error in
// For debugging
if retryAttempts > 0 {
Logger.shared.log("Retrying episode details fetch (attempt \(retryAttempts)/\(maxRetryAttempts))", type: "Debug")
}
URLSession.custom.dataTask(with: url) { data, response, error in
if let error = error {
Logger.shared.log("Failed to fetch anime episode details: \(error)", type: "Error")
DispatchQueue.main.async { self.isLoading = false }
self.handleFetchFailure(error: error)
return
}
guard let data = data else {
DispatchQueue.main.async { self.isLoading = false }
self.handleFetchFailure(error: NSError(domain: "com.sora.episode", code: 1, userInfo: [NSLocalizedDescriptionKey: "No data received"]))
return
}
do {
let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
guard let json = jsonObject as? [String: Any],
let episodes = json["episodes"] as? [String: Any],
let episodeDetails = episodes["\(episodeID + 1)"] as? [String: Any],
let title = episodeDetails["title"] as? [String: String],
let image = episodeDetails["image"] as? String else {
Logger.shared.log("Invalid anime response format", type: "Error")
DispatchQueue.main.async { self.isLoading = false }
return
}
guard let json = jsonObject as? [String: Any] else {
self.handleFetchFailure(error: NSError(domain: "com.sora.episode", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format"]))
return
}
// Check if episodes object exists
guard let episodes = json["episodes"] as? [String: Any] else {
Logger.shared.log("Missing 'episodes' object in response", type: "Error")
// Still proceed with empty data rather than failing
DispatchQueue.main.async {
self.isLoading = false
self.retryAttempts = 0
}
return
}
// Check if this specific episode exists in the response
let episodeKey = "\(episodeID + 1)"
guard let episodeDetails = episodes[episodeKey] as? [String: Any] else {
Logger.shared.log("Episode \(episodeKey) not found in response", type: "Error")
// Still proceed with empty data rather than failing
DispatchQueue.main.async {
self.isLoading = false
self.retryAttempts = 0
}
return
}
// Extract available fields, log if they're missing but continue anyway
var title: [String: String] = [:]
var image: String = ""
var missingFields: [String] = []
if let titleData = episodeDetails["title"] as? [String: String], !titleData.isEmpty {
title = titleData
// Check if we have any non-empty title values
if title.values.allSatisfy({ $0.isEmpty }) {
missingFields.append("title (all values empty)")
}
} else {
missingFields.append("title")
}
if let imageUrl = episodeDetails["image"] as? String, !imageUrl.isEmpty {
image = imageUrl
} else {
missingFields.append("image")
}
// Log missing fields but continue processing
if !missingFields.isEmpty {
Logger.shared.log("Episode \(episodeKey) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning")
}
// Cache whatever metadata we have if caching is enabled
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
)
}
}
// Update UI with whatever data we have
DispatchQueue.main.async {
self.isLoading = false
self.retryAttempts = 0 // Reset retry counter on success (even partial)
if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil
|| UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") {
self.episodeTitle = title["en"] ?? ""
self.episodeImageUrl = image
// Use whatever title we have, or leave as empty string
self.episodeTitle = title["en"] ?? title.values.first ?? ""
// Use image if available, otherwise leave current value
if !image.isEmpty {
self.episodeImageUrl = image
}
}
}
} catch {
DispatchQueue.main.async { self.isLoading = false }
Logger.shared.log("JSON parsing error: \(error.localizedDescription)", type: "Error")
// Still continue with empty data rather than failing
DispatchQueue.main.async {
self.isLoading = false
self.retryAttempts = 0
}
}
}.resume()
}
private func downloadEpisode() {
isFetchingEpisode = true
private func handleFetchFailure(error: Error) {
Logger.shared.log("Episode details fetch error: \(error.localizedDescription)", type: "Error")
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
DispatchQueue.main.async {
// Check if we should retry
if self.retryAttempts < self.maxRetryAttempts {
// Increment retry counter
self.retryAttempts += 1
if module.metadata.asyncJS == true {
jsController.fetchStreamUrlJS(episodeUrl: episode, softsub: module.metadata.softsub == true, module: module) { result in
if let sources = result.sources, !sources.isEmpty {
let streamUrl = sources[0]["streamUrl"] as? String ?? ""
let headers = sources[0]["headers"] as? [String: String] ?? [:]
self.startDownload(url: streamUrl, headers: headers)
}
else if let streams = result.streams, !streams.isEmpty {
self.startDownload(url: streams[0])
}
DispatchQueue.main.async {
self.isFetchingEpisode = false
}
}
} else {
jsController.fetchStreamUrl(episodeUrl: episode, softsub: module.metadata.softsub == true, module: module) { result in
if let sources = result.sources, !sources.isEmpty {
let streamUrl = sources[0]["streamUrl"] as? String ?? ""
let headers = sources[0]["headers"] as? [String: String] ?? [:]
self.startDownload(url: streamUrl, headers: headers)
}
else if let streams = result.streams, !streams.isEmpty {
self.startDownload(url: streams[0])
}
DispatchQueue.main.async {
self.isFetchingEpisode = false
}
}
}
} catch {
Logger.shared.log("Error starting download: \(error)", type: "Error")
DispatchQueue.main.async {
self.isFetchingEpisode = false
// Calculate backoff delay with exponential backoff
let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(self.retryAttempts - 1))
Logger.shared.log("Will retry episode details fetch in \(backoffDelay) seconds", type: "Debug")
// Schedule retry after backoff delay
DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) {
self.fetchAnimeEpisodeDetails()
}
} else {
// Max retries reached, give up but still update UI with what we have
Logger.shared.log("Failed to fetch episode details after \(self.maxRetryAttempts) attempts", type: "Error")
self.isLoading = false
self.retryAttempts = 0
}
}
}
private func startDownload(url: String, headers: [String: String]? = nil) {
guard let streamUrl = URL(string: url) else {
Logger.shared.log("Invalid stream URL for download", type: "Error")
return
}
downloadManager.downloadAsset(
from: streamUrl,
module: module,
headers: headers
)
DropManager.shared.showDrop(
title: "Download Started",
subtitle: "Episode \(episodeID + 1)",
duration: 1.0,
icon: UIImage(systemName: "arrow.down.circle.fill")
)
}
}

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,7 @@ struct SearchView: View {
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@StateObject private var jsController = JSController()
@StateObject private var jsController = JSController.shared
@EnvironmentObject var moduleManager: ModuleManager
@Environment(\.verticalSizeClass) var verticalSizeClass

View file

@ -11,13 +11,68 @@ struct SettingsViewData: View {
@State private var showEraseAppDataAlert = false
@State private var showRemoveDocumentsAlert = false
@State private var showSizeAlert = false
@State private var cacheSizeText: String = "Calculating..."
@State private var isCalculatingSize: Bool = false
@State private var cacheSize: Int64 = 0
@State private var documentsSize: Int64 = 0
@State private var movPkgSize: Int64 = 0
@State private var showRemoveMovPkgAlert = false
// State bindings for cache settings
@State private var isMetadataCachingEnabled: Bool = true
@State private var isImageCachingEnabled: Bool = true
@State private var isMemoryOnlyMode: Bool = false
var body: some View {
Form {
// New section for cache settings
Section(header: Text("Cache Settings"), footer: Text("Caching helps reduce network usage and load content faster. You can disable it to save storage space.")) {
Toggle("Enable Metadata Caching", isOn: $isMetadataCachingEnabled)
.onChange(of: isMetadataCachingEnabled) { newValue in
MetadataCacheManager.shared.isCachingEnabled = newValue
if !newValue {
calculateCacheSize()
}
}
Toggle("Enable Image Caching", isOn: $isImageCachingEnabled)
.onChange(of: isImageCachingEnabled) { newValue in
KingfisherCacheManager.shared.isCachingEnabled = newValue
if !newValue {
calculateCacheSize()
}
}
if isMetadataCachingEnabled {
Toggle("Memory-Only Mode", isOn: $isMemoryOnlyMode)
.onChange(of: isMemoryOnlyMode) { newValue in
MetadataCacheManager.shared.isMemoryOnlyMode = newValue
if newValue {
// Clear disk cache when switching to memory-only
MetadataCacheManager.shared.clearAllCache()
calculateCacheSize()
}
}
}
HStack {
Text("Current Cache Size")
Spacer()
if isCalculatingSize {
ProgressView()
.scaleEffect(0.7)
.padding(.trailing, 5)
}
Text(cacheSizeText)
.foregroundColor(.secondary)
}
Button(action: clearAllCaches) {
Text("Clear All Caches")
.foregroundColor(.red)
}
}
Section(header: Text("App storage"), footer: Text("The caches used by Sora are stored images that help load content faster\n\nThe App Data should never be erased if you dont know what that will cause.\n\nClearing the documents folder will remove all the modules and downloads")) {
HStack {
Button(action: clearCache) {
@ -63,6 +118,11 @@ struct SettingsViewData: View {
.navigationTitle("App Data")
.navigationViewStyle(StackNavigationViewStyle())
.onAppear {
// Initialize state with current values
isMetadataCachingEnabled = MetadataCacheManager.shared.isCachingEnabled
isImageCachingEnabled = KingfisherCacheManager.shared.isCachingEnabled
isMemoryOnlyMode = MetadataCacheManager.shared.isMemoryOnlyMode
calculateCacheSize()
updateSizes()
}
.alert(isPresented: $showEraseAppDataAlert) {
@ -97,6 +157,46 @@ struct SettingsViewData: View {
}
}
// Calculate and update the combined cache size
func calculateCacheSize() {
isCalculatingSize = true
cacheSizeText = "Calculating..."
// Group all cache size calculations
DispatchQueue.global(qos: .background).async {
var totalSize: Int64 = 0
// Get metadata cache size
let metadataSize = MetadataCacheManager.shared.getCacheSize()
totalSize += metadataSize
// Get image cache size asynchronously
KingfisherCacheManager.shared.calculateCacheSize { imageSize in
totalSize += Int64(imageSize)
// Update the UI on the main thread
DispatchQueue.main.async {
self.cacheSizeText = KingfisherCacheManager.formatCacheSize(UInt(totalSize))
self.isCalculatingSize = false
}
}
}
}
// Clear all caches (both metadata and images)
func clearAllCaches() {
// Clear metadata cache
MetadataCacheManager.shared.clearAllCache()
// Clear image cache
KingfisherCacheManager.shared.clearCache {
// Update cache size after clearing
calculateCacheSize()
}
Logger.shared.log("All caches cleared", type: "General")
}
func eraseAppData() {
if let domain = Bundle.main.bundleIdentifier {
UserDefaults.standard.removePersistentDomain(forName: domain)
@ -116,6 +216,7 @@ struct SettingsViewData: View {
try FileManager.default.removeItem(at: filePath)
}
Logger.shared.log("Cache cleared successfully!", type: "General")
calculateCacheSize()
updateSizes()
}
} catch {

View file

@ -0,0 +1,183 @@
//
// SettingsViewDownloads.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import SwiftUI
import Drops
// No need to import DownloadQualityPreference as it's in the same module
struct SettingsViewDownloads: View {
@EnvironmentObject private var jsController: JSController
@AppStorage(DownloadQualityPreference.userDefaultsKey)
private var downloadQuality = DownloadQualityPreference.defaultPreference.rawValue
@AppStorage("allowCellularDownloads") private var allowCellularDownloads: Bool = true
@AppStorage("maxConcurrentDownloads") private var maxConcurrentDownloads: Int = 3
@State private var showClearConfirmation = false
@State private var totalStorageSize: Int64 = 0
@State private var existingDownloadCount: Int = 0
@State private var isCalculating: Bool = false
var body: some View {
Form {
Section(header: Text("Download Settings"), footer: Text("Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources.")) {
Picker("Quality", selection: $downloadQuality) {
ForEach(DownloadQualityPreference.allCases, id: \.rawValue) { option in
Text(option.rawValue)
.tag(option.rawValue)
}
}
.onChange(of: downloadQuality) { newValue in
print("Download quality preference changed to: \(newValue)")
}
HStack {
Text("Max Concurrent Downloads")
Spacer()
Stepper("\(maxConcurrentDownloads)", value: $maxConcurrentDownloads, in: 1...10)
.onChange(of: maxConcurrentDownloads) { newValue in
// Update JSController when the setting changes
jsController.updateMaxConcurrentDownloads(newValue)
}
}
Toggle("Allow Cellular Downloads", isOn: $allowCellularDownloads)
.tint(.accentColor)
}
Section(header: Text("Quality Information")) {
if let preferenceDescription = DownloadQualityPreference(rawValue: downloadQuality)?.description {
Text(preferenceDescription)
.font(.caption)
.foregroundColor(.secondary)
}
}
Section(header: Text("Storage Management")) {
HStack {
Text("Storage Used")
Spacer()
if isCalculating {
ProgressView()
.scaleEffect(0.7)
.padding(.trailing, 5)
}
Text(formatFileSize(totalStorageSize))
.foregroundColor(.secondary)
}
HStack {
Text("Files Downloaded")
Spacer()
Text("\(existingDownloadCount) of \(jsController.savedAssets.count)")
.foregroundColor(.secondary)
}
Button(action: {
// Recalculate sizes in case files were externally modified
calculateTotalStorage()
}) {
HStack {
Image(systemName: "arrow.clockwise")
Text("Refresh Storage Info")
}
}
Button(action: {
showClearConfirmation = true
}) {
HStack {
Image(systemName: "trash")
.foregroundColor(.red)
Text("Clear All Downloads")
.foregroundColor(.red)
}
}
.alert("Delete All Downloads", isPresented: $showClearConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Delete All", role: .destructive) {
clearAllDownloads(preservePersistentDownloads: false)
}
Button("Clear Library Only", role: .destructive) {
clearAllDownloads(preservePersistentDownloads: true)
}
} message: {
Text("Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use.")
}
}
}
.navigationTitle("Downloads")
.onAppear {
calculateTotalStorage()
// Sync the max concurrent downloads setting with JSController
jsController.updateMaxConcurrentDownloads(maxConcurrentDownloads)
}
}
private func calculateTotalStorage() {
guard !jsController.savedAssets.isEmpty else {
totalStorageSize = 0
existingDownloadCount = 0
return
}
isCalculating = true
// Clear any cached file sizes before recalculating
DownloadedAsset.clearFileSizeCache()
DownloadGroup.clearFileSizeCache()
// Use background task to avoid UI freezes with many files
DispatchQueue.global(qos: .userInitiated).async {
let total = jsController.savedAssets.reduce(0) { $0 + $1.fileSize }
let existing = jsController.savedAssets.filter { $0.fileExists }.count
DispatchQueue.main.async {
self.totalStorageSize = total
self.existingDownloadCount = existing
self.isCalculating = false
}
}
}
private func clearAllDownloads(preservePersistentDownloads: Bool = false) {
let assetsToDelete = jsController.savedAssets
for asset in assetsToDelete {
if preservePersistentDownloads {
// Only remove from library without deleting files
jsController.removeAssetFromLibrary(asset)
} else {
// Delete both library entry and files
jsController.deleteAsset(asset)
}
}
// Reset calculated values
totalStorageSize = 0
existingDownloadCount = 0
// Post a notification so all views can update - use libraryChange since assets were deleted
NotificationCenter.default.post(name: NSNotification.Name("downloadLibraryChanged"), object: nil)
// Show confirmation message
DispatchQueue.main.async {
if preservePersistentDownloads {
DropManager.shared.success("Library cleared successfully")
} else {
DropManager.shared.success("All downloads deleted successfully")
}
}
}
private func formatFileSize(_ size: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useKB, .useMB, .useGB]
formatter.countStyle = .file
return formatter.string(fromByteCount: size)
}
}

View file

@ -29,6 +29,7 @@ class LogFilterViewModel: ObservableObject {
("Stream", "Streaming and video playback.", true),
("Error", "Errors and critical issues.", true),
("Debug", "Debugging and troubleshooting.", false),
("Download", "HLS video downloading.", true),
("HTMLStrings", "", false)
]

View file

@ -0,0 +1,94 @@
//
// SettingsViewPerformance.swift
// Sora
//
// Created by Claude on 19/06/24.
//
import SwiftUI
struct SettingsViewPerformance: View {
@ObservedObject private var performanceMonitor = PerformanceMonitor.shared
@State private var showResetConfirmation = false
var body: some View {
Form {
Section(header: Text("Performance Monitoring")) {
Toggle("Enable Performance Monitoring", isOn: Binding(
get: { performanceMonitor.isEnabled },
set: { performanceMonitor.setEnabled($0) }
))
Button(action: {
showResetConfirmation = true
}) {
HStack {
Text("Reset Metrics")
.foregroundColor(.primary)
Spacer()
Image(systemName: "arrow.clockwise")
}
}
.disabled(!performanceMonitor.isEnabled)
Button(action: {
performanceMonitor.logMetrics()
DropManager.shared.showDrop(title: "Metrics Logged", subtitle: "Check logs for details", duration: 1.0, icon: UIImage(systemName: "doc.text"))
}) {
HStack {
Text("Log Current Metrics")
.foregroundColor(.primary)
Spacer()
Image(systemName: "doc.text")
}
}
.disabled(!performanceMonitor.isEnabled)
}
if performanceMonitor.isEnabled {
Section(header: Text("About Performance Monitoring"), footer: Text("Performance monitoring helps track app resource usage and identify potential issues with network requests, cache efficiency, and memory management.")) {
Text("Performance monitoring helps track app resource usage and identify potential issues with network requests, cache efficiency, and memory management.")
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
.navigationTitle("Performance")
.alert(isPresented: $showResetConfirmation) {
Alert(
title: Text("Reset Performance Metrics"),
message: Text("Are you sure you want to reset all performance metrics? This action cannot be undone."),
primaryButton: .destructive(Text("Reset")) {
performanceMonitor.resetMetrics()
DropManager.shared.showDrop(title: "Metrics Reset", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.clockwise"))
},
secondaryButton: .cancel()
)
}
}
}
struct MetricRow: View {
let title: String
let value: String
var body: some View {
HStack {
Text(title)
.font(.system(size: 15))
.foregroundColor(.secondary)
Spacer()
Text(value)
.font(.system(size: 15, weight: .medium))
.foregroundColor(.primary)
}
}
}
struct SettingsViewPerformance_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
SettingsViewPerformance()
}
}
}

View file

@ -18,6 +18,9 @@ struct SettingsView: View {
NavigationLink(destination: SettingsViewPlayer()) {
Text("Media Player")
}
NavigationLink(destination: SettingsViewDownloads().environmentObject(JSController.shared)) {
Text("Downloads")
}
NavigationLink(destination: SettingsViewModule()) {
Text("Modules")
}
@ -33,6 +36,9 @@ struct SettingsView: View {
NavigationLink(destination: SettingsViewLogger()) {
Text("Logs")
}
NavigationLink(destination: SettingsViewPerformance()) {
Text("Performance")
}
}
Section(header: Text("Info")) {

View file

@ -11,8 +11,6 @@
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; };
131270172DC13A010093AA9C /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131270162DC13A010093AA9C /* DownloadManager.swift */; };
131270192DC13A3C0093AA9C /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131270182DC13A3C0093AA9C /* DownloadView.swift */; };
1314D0AE2DD0F4BB00759A3F /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1314D0AD2DD0F4BB00759A3F /* UserDefaults.swift */; };
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; };
1327FBA72D758CEA00FC6689 /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA62D758CEA00FC6689 /* Analytics.swift */; };
1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */; };
@ -67,6 +65,22 @@
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 */; };
722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */; };
722248662DCBC13E00CABE2D /* JSController-Downloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */; };
72443C7D2DC8036500A61321 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7C2DC8036500A61321 /* DownloadView.swift */; };
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; };
7272206E2DD6336100C2A4A2 /* SettingsViewPerformance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206D2DD6336100C2A4A2 /* SettingsViewPerformance.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 */; };
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 */; };
/* End PBXBuildFile section */
@ -76,8 +90,6 @@
13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = "<group>"; };
131270162DC13A010093AA9C /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
131270182DC13A3C0093AA9C /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = "<group>"; };
1314D0AD2DD0F4BB00759A3F /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = "<group>"; };
131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = "<group>"; };
1327FBA62D758CEA00FC6689 /* Analytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = "<group>"; };
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = "<group>"; };
@ -131,6 +143,22 @@
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>"; };
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+M3U8Download.swift"; sourceTree = "<group>"; };
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Downloads.swift"; sourceTree = "<group>"; };
72443C7C2DC8036500A61321 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = "<group>"; };
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = "<group>"; };
7272206D2DD6336100C2A4A2 /* SettingsViewPerformance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPerformance.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>"; };
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>"; };
/* End PBXFileReference section */
@ -175,14 +203,6 @@
path = SkeletonCells;
sourceTree = "<group>";
};
131270152DC139CD0093AA9C /* DownloadManager */ = {
isa = PBXGroup;
children = (
131270162DC13A010093AA9C /* DownloadManager.swift */,
);
path = DownloadManager;
sourceTree = "<group>";
};
1327FBA52D758CEA00FC6689 /* Analytics */ = {
isa = PBXGroup;
children = (
@ -210,6 +230,7 @@
133D7C6C2D2BE2500075467E /* Sora */ = {
isa = PBXGroup;
children = (
72AC3A002DD4DAEA00C60B96 /* Managers */,
130C6BF82D53A4C200DC1432 /* Sora.entitlements */,
13DC0C412D2EC9BA00D0F966 /* Info.plist */,
13103E802D589D6C000F0673 /* Tracking Services */,
@ -234,11 +255,11 @@
133D7C7B2D2BE2630075467E /* Views */ = {
isa = PBXGroup;
children = (
72443C7C2DC8036500A61321 /* DownloadView.swift */,
133D7C7F2D2BE2630075467E /* MediaInfoView */,
1399FAD22D3AB34F00E97C31 /* SettingsView */,
133F55B92D33B53E00E08EEA /* LibraryView */,
133D7C7C2D2BE2630075467E /* SearchView.swift */,
131270182DC13A3C0093AA9C /* DownloadView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -255,6 +276,7 @@
133D7C832D2BE2630075467E /* SettingsSubViews */ = {
isa = PBXGroup;
children = (
7272206D2DD6336100C2A4A2 /* SettingsViewPerformance.swift */,
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */,
1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */,
133D7C842D2BE2630075467E /* SettingsViewModule.swift */,
@ -262,6 +284,7 @@
135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */,
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */,
13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */,
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */,
);
path = SettingsSubViews;
sourceTree = "<group>";
@ -269,7 +292,8 @@
133D7C852D2BE2640075467E /* Utils */ = {
isa = PBXGroup;
children = (
131270152DC139CD0093AA9C /* DownloadManager */,
7205AEDA2DCCEF9500943F3F /* Cache */,
72443C832DC8046500A61321 /* DownloadUtils */,
13C0E5E82D5F85DD00E7F619 /* ContinueWatching */,
13103E8C2D58E037000F0673 /* SkeletonCells */,
13DC0C442D302C6A00D0F966 /* MediaPlayer */,
@ -289,7 +313,6 @@
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */,
136BBE7F2DB1038000906B5E /* Notification+Name.swift */,
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */,
1314D0AD2DD0F4BB00759A3F /* UserDefaults.swift */,
133D7C872D2BE2640075467E /* URLSession.swift */,
1359ED132D76F49900C13034 /* finTopView.swift */,
13CBEFD92D5F7D1200D011EE /* String.swift */,
@ -313,6 +336,11 @@
133D7C8A2D2BE2640075467E /* JSLoader */ = {
isa = PBXGroup;
children = (
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */,
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */,
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */,
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */,
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */,
133D7C8B2D2BE2640075467E /* JSController.swift */,
132AF1202D99951700A0140B /* JSController-Streams.swift */,
132AF1222D9995C300A0140B /* JSController-Details.swift */,
@ -378,6 +406,7 @@
children = (
13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */,
13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */,
131270162DC13A010093AA9C /* DownloadManager.swift */,
);
path = ContinueWatching;
sourceTree = "<group>";
@ -455,6 +484,35 @@
path = Components;
sourceTree = "<group>";
};
7205AEDA2DCCEF9500943F3F /* Cache */ = {
isa = PBXGroup;
children = (
7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */,
7205AED82DCCEF9500943F3F /* KingfisherManager.swift */,
7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */,
);
path = Cache;
sourceTree = "<group>";
};
72443C832DC8046500A61321 /* DownloadUtils */ = {
isa = PBXGroup;
children = (
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */,
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */,
);
path = DownloadUtils;
sourceTree = "<group>";
};
72AC3A002DD4DAEA00C60B96 /* Managers */ = {
isa = PBXGroup;
children = (
72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */,
72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */,
72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */,
);
path = Managers;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -546,7 +604,9 @@
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */,
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
1314D0AE2DD0F4BB00759A3F /* UserDefaults.swift in Sources */,
7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.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 */,
@ -566,15 +626,19 @@
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */,
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */,
13103E8B2D58E028000F0673 /* View.swift in Sources */,
72443C7D2DC8036500A61321 /* DownloadView.swift in Sources */,
1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */,
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */,
722248662DCBC13E00CABE2D /* JSController-Downloads.swift in Sources */,
133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */,
13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */,
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */,
722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */,
7272206E2DD6336100C2A4A2 /* SettingsViewPerformance.swift in Sources */,
722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */,
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,
131270192DC13A3C0093AA9C /* DownloadView.swift in Sources */,
13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */,
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */,
13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */,
@ -587,8 +651,16 @@
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */,
13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */,
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */,
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */,
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */,
72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */,
72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */,
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */,
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */,
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */,
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -652,6 +724,8 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos macosx";
SUPPORTS_MACCATALYST = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -707,6 +781,8 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos macosx";
SUPPORTS_MACCATALYST = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
@ -721,11 +797,10 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\"";
DEVELOPMENT_TEAM = 399LMK6Q2Y;
DEVELOPMENT_TEAM = V9MT5Y43YG;
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -745,11 +820,10 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.0;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
MARKETING_VERSION = 0.2.2;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur.test;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
@ -765,11 +839,10 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\"";
DEVELOPMENT_TEAM = 399LMK6Q2Y;
DEVELOPMENT_TEAM = V9MT5Y43YG;
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -789,11 +862,10 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.0;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
MARKETING_VERSION = 0.2.2;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur.test;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;

View file

@ -1,34 +1,33 @@
{
"object": {
"pins": [
{
"package": "Drops",
"repositoryURL": "https://github.com/omaralbeik/Drops.git",
"state": {
"branch": "main",
"revision": "5824681795286c36bdc4a493081a63e64e2a064e",
"version": null
}
},
{
"package": "Kingfisher",
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
"state": {
"branch": null,
"revision": "b6f62758f21a8c03cd64f4009c037cfa580a256e",
"version": "7.9.1"
}
},
{
"package": "MarqueeLabel",
"repositoryURL": "https://github.com/cbpowell/MarqueeLabel",
"state": {
"branch": null,
"revision": "cffb6938940d3242882e6a2f9170b7890a4729ea",
"version": "4.2.1"
}
"originHash" : "60d5882290a22b3286d882ec649bd11b12151e9ee052d03237e8071773244b7f",
"pins" : [
{
"identity" : "drops",
"kind" : "remoteSourceControl",
"location" : "https://github.com/omaralbeik/Drops.git",
"state" : {
"branch" : "main",
"revision" : "5824681795286c36bdc4a493081a63e64e2a064e"
}
]
},
"version": 1
},
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher.git",
"state" : {
"revision" : "b6f62758f21a8c03cd64f4009c037cfa580a256e",
"version" : "7.9.1"
}
},
{
"identity" : "marqueelabel",
"kind" : "remoteSourceControl",
"location" : "https://github.com/cbpowell/MarqueeLabel",
"state" : {
"revision" : "cffb6938940d3242882e6a2f9170b7890a4729ea",
"version" : "4.2.1"
}
}
],
"version" : 3
}