diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 334ac49..9086c7a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,10 +1,10 @@
-name: Build and Release IPA
+name: Build and Release
on:
push:
branches:
- dev
jobs:
- build:
+ build-ios:
name: Build IPA
runs-on: macOS-latest
steps:
@@ -27,3 +27,31 @@ jobs:
name: Sulfur-IPA
path: build/Sulfur.ipa
compression-level: 0
+
+ build-mac:
+ name: Build Mac Catalyst
+ runs-on: macOS-latest
+ steps:
+ - name: Use Node.js 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Run macbuild.sh
+ run: |
+ chmod +x macbuild.sh
+ ./macbuild.sh
+
+ - name: Create DMG
+ run: |
+ hdiutil create -volname "Sulfur" -srcfolder build/Sulfur.app -ov -format UDZO build/Sulfur.dmg
+
+ - name: Upload Mac artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: Sulfur-Mac
+ path: build/Sulfur.dmg
+ compression-level: 0
diff --git a/README.md b/README.md
index f91b15c..ab7ddad 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,7 @@ Frameworks:
Misc:
- [50/50](https://github.com/50n50) for the app icon
+- Ciro for the episode banner images
## License
diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png b/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png
index dc6328e..c8f6f6f 100644
Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png and b/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png differ
diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/lightmode.png b/Sora/Assets.xcassets/AppIcon.appiconset/lightmode.png
index 272905b..7256d08 100644
Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/lightmode.png and b/Sora/Assets.xcassets/AppIcon.appiconset/lightmode.png differ
diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/tinting.png b/Sora/Assets.xcassets/AppIcon.appiconset/tinting.png
index e9d0104..f55a3d9 100644
Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/tinting.png and b/Sora/Assets.xcassets/AppIcon.appiconset/tinting.png differ
diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift
index afbd4ea..8fa65bb 100644
--- a/Sora/ContentView.swift
+++ b/Sora/ContentView.swift
@@ -6,7 +6,6 @@
//
import SwiftUI
-import Kingfisher
struct ContentView: View {
var body: some View {
@@ -15,6 +14,10 @@ struct ContentView: View {
.tabItem {
Label("Library", systemImage: "books.vertical")
}
+ DownloadView()
+ .tabItem {
+ Label("Downloads", systemImage: "arrow.down.app.fill")
+ }
SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
diff --git a/Sora/Info.plist b/Sora/Info.plist
index 269b520..8758a4e 100644
--- a/Sora/Info.plist
+++ b/Sora/Info.plist
@@ -38,7 +38,5 @@
audio
processing
- UISupportsDocumentBrowser
-
diff --git a/Sora/Managers/EpisodeMetadataManager.swift b/Sora/Managers/EpisodeMetadataManager.swift
new file mode 100644
index 0000000..05dc58d
--- /dev/null
+++ b/Sora/Managers/EpisodeMetadataManager.swift
@@ -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) -> Void) {
+ let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
+
+ // Check if we already have this metadata
+ if let existingStatus = metadataCache[cacheKey] {
+ switch existingStatus {
+ case .fetched(let metadata):
+ // Return cached data immediately
+ completion(.success(metadata))
+ return
+
+ case .fetching:
+ // Already fetching, will be notified via publisher
+ // Set up a listener for when this request completes
+ waitForRequest(cacheKey: cacheKey, completion: completion)
+ return
+
+ case .failed:
+ // Previous attempt failed, try again
+ break
+
+ case .notRequested:
+ // Should not happen but continue to fetch
+ break
+ }
+ }
+
+ // Check persistent cache
+ if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
+ let metadata = EpisodeMetadata.fromData(cachedData) {
+
+ let metadataInfo = EpisodeMetadataInfo(
+ title: metadata.title,
+ imageUrl: metadata.imageUrl,
+ anilistId: anilistId,
+ episodeNumber: episodeNumber
+ )
+
+ // Update memory cache
+ DispatchQueue.main.async {
+ self.metadataCache[cacheKey] = .fetched(metadataInfo)
+ }
+
+ completion(.success(metadataInfo))
+ return
+ }
+
+ // Need to fetch from network
+ DispatchQueue.main.async {
+ self.metadataCache[cacheKey] = .fetching
+ }
+
+ performFetch(anilistId: anilistId, episodeNumber: episodeNumber, cacheKey: cacheKey, completion: completion)
+ }
+
+ /// Fetch metadata for multiple episodes in batch
+ /// - Parameters:
+ /// - anilistId: The Anilist ID of the anime
+ /// - episodeNumbers: Array of episode numbers to fetch
+ func batchFetchMetadata(anilistId: Int, episodeNumbers: [Int]) {
+ // First check which episodes we need to fetch
+ let episodesToFetch = episodeNumbers.filter { episodeNumber in
+ let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
+ if let status = metadataCache[cacheKey] {
+ switch status {
+ case .fetched, .fetching:
+ return false
+ default:
+ return true
+ }
+ }
+ return true
+ }
+
+ guard !episodesToFetch.isEmpty else {
+ Logger.shared.log("No new episodes to fetch in batch", type: "Debug")
+ return
+ }
+
+ // Mark all as fetching
+ for episodeNumber in episodesToFetch {
+ let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
+ DispatchQueue.main.async {
+ self.metadataCache[cacheKey] = .fetching
+ }
+ }
+
+ // Perform batch fetch
+ fetchBatchFromNetwork(anilistId: anilistId, episodeNumbers: episodesToFetch)
+ }
+
+ /// Prefetch metadata for a range of episodes
+ /// - Parameters:
+ /// - anilistId: The Anilist ID of the anime
+ /// - startEpisode: The starting episode number
+ /// - count: How many episodes to prefetch
+ func prefetchMetadata(anilistId: Int, startEpisode: Int, count: Int = 5) {
+ let episodeNumbers = Array(startEpisode..<(startEpisode + count))
+ batchFetchMetadata(anilistId: anilistId, episodeNumbers: episodeNumbers)
+ }
+
+ /// Get metadata for an episode (non-blocking, returns immediately from cache)
+ /// - Parameters:
+ /// - anilistId: The Anilist ID of the anime
+ /// - episodeNumber: The episode number
+ /// - Returns: The metadata fetch status
+ func getMetadataStatus(anilistId: Int, episodeNumber: Int) -> MetadataFetchStatus {
+ let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
+ return metadataCache[cacheKey] ?? .notRequested
+ }
+
+ // MARK: - Private Methods
+
+ private func performFetch(anilistId: Int, episodeNumber: Int, cacheKey: String, completion: @escaping (Result) -> Void) {
+ // Check if there's already an active request for this metadata
+ if activeRequests[cacheKey] != nil {
+ // Already fetching, wait for it to complete
+ waitForRequest(cacheKey: cacheKey, completion: completion)
+ return
+ }
+
+ // Reset retry attempts if this is a new fetch
+ if currentRetryAttempts[cacheKey] == nil {
+ currentRetryAttempts[cacheKey] = 0
+ }
+
+ // Create API request
+ guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(anilistId)") else {
+ let error = NSError(domain: "com.sora.metadata", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
+ DispatchQueue.main.async {
+ self.metadataCache[cacheKey] = .failed(error)
+ }
+ completion(.failure(error))
+ return
+ }
+
+ Logger.shared.log("Fetching metadata for episode \(episodeNumber) from network", type: "Debug")
+
+ // Create publisher for the request
+ let publisher = URLSession.custom.dataTaskPublisher(for: url)
+ .subscribe(on: fetchQueue)
+ .tryMap { [weak self] data, response -> EpisodeMetadataInfo in
+ guard let self = self else {
+ throw NSError(domain: "com.sora.metadata", code: 4,
+ userInfo: [NSLocalizedDescriptionKey: "Manager instance released"])
+ }
+
+ // Validate response
+ guard let httpResponse = response as? HTTPURLResponse,
+ httpResponse.statusCode == 200 else {
+ throw NSError(domain: "com.sora.metadata", code: 2,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
+ }
+
+ // Parse JSON
+ let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
+ guard let json = jsonObject as? [String: Any] else {
+ throw NSError(domain: "com.sora.metadata", code: 3,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid data format"])
+ }
+
+ // Check for episodes object
+ guard let episodes = json["episodes"] as? [String: Any] else {
+ Logger.shared.log("Missing 'episodes' object in response for anilistId: \(anilistId)", type: "Error")
+ throw NSError(domain: "com.sora.metadata", code: 3,
+ userInfo: [NSLocalizedDescriptionKey: "Missing episodes object in response"])
+ }
+
+ // Check if episode exists in response
+ let episodeKey = "\(episodeNumber)"
+ guard let episodeDetails = episodes[episodeKey] as? [String: Any] else {
+ Logger.shared.log("Episode \(episodeNumber) not found in response for anilistId: \(anilistId)", type: "Error")
+ throw NSError(domain: "com.sora.metadata", code: 5,
+ userInfo: [NSLocalizedDescriptionKey: "Episode \(episodeNumber) not found in response"])
+ }
+
+ // Extract available fields, log if they're missing
+ var title: [String: String] = [:]
+ var image: String = ""
+ var missingFields: [String] = []
+
+ // Try to get title
+ if let titleData = episodeDetails["title"] as? [String: String] {
+ title = titleData
+
+ // Check if we have valid title values
+ if title.isEmpty || title.values.allSatisfy({ $0.isEmpty }) {
+ missingFields.append("title (all values empty)")
+ }
+ } else {
+ missingFields.append("title")
+ // Create default empty title dictionary
+ title = ["en": "Episode \(episodeNumber)"]
+ }
+
+ // Try to get image
+ if let imageUrl = episodeDetails["image"] as? String {
+ image = imageUrl
+
+ if imageUrl.isEmpty {
+ missingFields.append("image (empty string)")
+ }
+ } else {
+ missingFields.append("image")
+ // Use a default placeholder image
+ image = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png"
+ }
+
+ // Log missing fields but continue processing
+ if !missingFields.isEmpty {
+ Logger.shared.log("Episode \(episodeNumber) for anilistId \(anilistId) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning")
+ }
+
+ // Create metadata object with whatever we have
+ let metadataInfo = EpisodeMetadataInfo(
+ title: title,
+ imageUrl: image,
+ anilistId: anilistId,
+ episodeNumber: episodeNumber
+ )
+
+ // Cache the metadata
+ 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) -> Void) {
+ // Set up a timer to check the cache periodically
+ let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
+ guard let self = self else {
+ timer.invalidate()
+ return
+ }
+
+ if let status = self.metadataCache[cacheKey] {
+ switch status {
+ case .fetched(let metadata):
+ // Request completed successfully
+ timer.invalidate()
+ completion(.success(metadata))
+ case .failed(let error):
+ // Request failed
+ timer.invalidate()
+ completion(.failure(error))
+ case .fetching, .notRequested:
+ // Still in progress
+ break
+ }
+ }
+ }
+
+ // Ensure timer fires even when scrolling
+ RunLoop.current.add(timer, forMode: .common)
+ }
+}
+
+// Extension to EpisodeMetadata for integration with the new manager
+extension EpisodeMetadata {
+ func toData() -> Data? {
+ // Convert to EpisodeMetadataInfo first
+ let info = EpisodeMetadataInfo(
+ title: self.title,
+ imageUrl: self.imageUrl,
+ anilistId: self.anilistId,
+ episodeNumber: self.episodeNumber
+ )
+
+ // Then encode to Data
+ return try? JSONEncoder().encode(info)
+ }
+
+ static func fromData(_ data: Data) -> EpisodeMetadata? {
+ guard let info = try? JSONDecoder().decode(EpisodeMetadataInfo.self, from: data) else {
+ return nil
+ }
+
+ return EpisodeMetadata(
+ title: info.title,
+ imageUrl: info.imageUrl,
+ anilistId: info.anilistId,
+ episodeNumber: info.episodeNumber
+ )
+ }
+}
\ No newline at end of file
diff --git a/Sora/Managers/ImagePrefetchManager.swift b/Sora/Managers/ImagePrefetchManager.swift
new file mode 100644
index 0000000..a4d342c
--- /dev/null
+++ b/Sora/Managers/ImagePrefetchManager.swift
@@ -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()
+ 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")
+ }
+ }
+}
\ No newline at end of file
diff --git a/Sora/Managers/PerformanceMonitor.swift b/Sora/Managers/PerformanceMonitor.swift
new file mode 100644
index 0000000..3a45fae
--- /dev/null
+++ b/Sora/Managers/PerformanceMonitor.swift
@@ -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.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.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)
+ }
+}
\ No newline at end of file
diff --git a/Sora/Sora.entitlements b/Sora/Sora.entitlements
index ee95ab7..cd2e864 100644
--- a/Sora/Sora.entitlements
+++ b/Sora/Sora.entitlements
@@ -6,5 +6,11 @@
com.apple.security.network.client
+ com.apple.security.files.user-selected.read-write
+
+ com.apple.security.assets.movies.read-write
+
+ com.apple.security.assets.music.read-write
+
diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift
index 8729080..37ba4c1 100644
--- a/Sora/SoraApp.swift
+++ b/Sora/SoraApp.swift
@@ -6,14 +6,24 @@
//
import SwiftUI
+import UIKit
@main
struct SoraApp: App {
@StateObject private var settings = Settings()
@StateObject private var moduleManager = ModuleManager()
@StateObject private var librarykManager = LibraryManager()
-
+ @StateObject private var downloadManager = DownloadManager()
+ @StateObject private var jsController = JSController.shared
+
init() {
+ _ = MetadataCacheManager.shared
+ _ = KingfisherCacheManager.shared
+
+ if let userAccentColor = UserDefaults.standard.color(forKey: "accentColor") {
+ UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = userAccentColor
+ }
+
TraktToken.checkAuthenticationStatus { isAuthenticated in
if isAuthenticated {
Logger.shared.log("Trakt authentication is valid")
@@ -22,13 +32,15 @@ struct SoraApp: App {
}
}
}
-
+
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(moduleManager)
.environmentObject(settings)
.environmentObject(librarykManager)
+ .environmentObject(downloadManager)
+ .environmentObject(jsController)
.accentColor(settings.accentColor)
.onAppear {
settings.updateAppearance()
@@ -47,7 +59,7 @@ struct SoraApp: App {
}
}
}
-
+
private func handleURL(_ url: URL) {
guard url.scheme == "sora", let host = url.host else { return }
switch host {
@@ -57,24 +69,13 @@ struct SoraApp: App {
UserDefaults.standard.set(libraryURL, forKey: "lastCommunityURL")
UserDefaults.standard.set(true, forKey: "didReceiveDefaultPageLink")
-
- let communityView = CommunityLibraryView()
- .environmentObject(moduleManager)
- let hostingController = UIHostingController(rootView: communityView)
- DispatchQueue.main.async {
- if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
- let window = scene.windows.first,
- let root = window.rootViewController {
- root.present(hostingController, animated: true) {
- DropManager.shared.showDrop(
- title: "Module Library Added",
- subtitle: "You can browse the community library in settings.",
- duration: 2,
- icon: UIImage(systemName: "books.vertical.circle.fill")
- )
- }
- }
- }
+
+ DropManager.shared.showDrop(
+ title: "Module Library Added",
+ subtitle: "You can browse the community library in settings.",
+ duration: 2,
+ icon: UIImage(systemName: "books.vertical.circle.fill")
+ )
}
case "module":
@@ -85,11 +86,10 @@ struct SoraApp: App {
else {
return
}
-
- let addModuleView = ModuleAdditionSettingsView(moduleUrl: moduleURL)
- .environmentObject(moduleManager)
+
+ let addModuleView = ModuleAdditionSettingsView(moduleUrl: moduleURL).environmentObject(moduleManager)
let hostingController = UIHostingController(rootView: addModuleView)
-
+
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.rootViewController?.present(hostingController, animated: true)
@@ -99,19 +99,19 @@ struct SoraApp: App {
type: "Error"
)
}
-
+
default:
break
}
}
-
+
static func handleRedirect(url: URL) {
guard let params = url.queryParameters,
let code = params["code"] else {
- Logger.shared.log("Failed to extract authorization code")
- return
- }
-
+ Logger.shared.log("Failed to extract authorization code")
+ return
+ }
+
switch url.host {
case "anilist":
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
@@ -121,7 +121,7 @@ struct SoraApp: App {
Logger.shared.log("AniList token exchange failed", type: "Error")
}
}
-
+
case "trakt":
TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in
if success {
@@ -130,9 +130,15 @@ struct SoraApp: App {
Logger.shared.log("Trakt token exchange failed", type: "Error")
}
}
-
+
default:
Logger.shared.log("Unknown authentication service", type: "Error")
}
}
}
+
+class AppInfo: NSObject {
+ @objc func getBundleIdentifier() -> String {
+ return Bundle.main.bundleIdentifier ?? "me.cranci.sulfur"
+ }
+}
diff --git a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift
index 4383e1f..997ab5a 100644
--- a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift
+++ b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift
@@ -118,16 +118,15 @@ class AniListMutation {
"variables": variables
]
guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody, options: []) else {
- completion(.failure(NSError(domain: "", code: -1,
- userInfo: [NSLocalizedDescriptionKey: "Failed to serialize GraphQL request"])))
+ completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to serialize GraphQL request"])))
return
}
-
+
var request = URLRequest(url: apiURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = jsonData
-
+
URLSession.shared.dataTask(with: request) { data, resp, error in
if let e = error {
return completion(.failure(e))
@@ -135,9 +134,8 @@ class AniListMutation {
guard let data = data,
let json = try? JSONDecoder().decode(AniListMediaResponse.self, from: data),
let mal = json.data.Media?.idMal else {
- return completion(.failure(NSError(domain: "", code: -1,
- userInfo: [NSLocalizedDescriptionKey: "Failed to decode AniList response or idMal missing"])))
- }
+ return completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to decode AniList response or idMal missing"])))
+ }
completion(.success(mal))
}.resume()
}
diff --git a/Sora/Utils/Analytics/Analytics.swift b/Sora/Utils/Analytics/Analytics.swift
index e8b62ad..d002fe6 100644
--- a/Sora/Utils/Analytics/Analytics.swift
+++ b/Sora/Utils/Analytics/Analytics.swift
@@ -8,7 +8,6 @@
import Foundation
import UIKit
-// MARK: - Analytics Response Model
struct AnalyticsResponse: Codable {
let status: String
let message: String
@@ -16,26 +15,22 @@ struct AnalyticsResponse: Codable {
let timestamp: String?
}
-// MARK: - Analytics Manager
+@MainActor
class AnalyticsManager {
-
static let shared = AnalyticsManager()
private let analyticsURL = URL(string: "http://151.106.3.14:47474/analytics")!
private let moduleManager = ModuleManager()
private init() {}
- // MARK: - Send Analytics Data
func sendEvent(event: String, additionalData: [String: Any] = [:]) {
let defaults = UserDefaults.standard
- // Ensure the key is set with a default value if missing
if defaults.object(forKey: "analyticsEnabled") == nil {
defaults.setValue(false, forKey: "analyticsEnabled")
}
-
let analyticsEnabled = UserDefaults.standard.bool(forKey: "analyticsEnabled")
guard analyticsEnabled else {
@@ -48,10 +43,8 @@ class AnalyticsManager {
return
}
- // Prepare analytics data
var safeAdditionalData = additionalData
-
- // Check and convert NSError if present
+
if let errorValue = additionalData["error"] as? NSError {
safeAdditionalData["error"] = errorValue.localizedDescription
}
@@ -68,7 +61,6 @@ class AnalyticsManager {
sendRequest(with: analyticsData)
}
- // MARK: - Private Request Method
private func sendRequest(with data: [String: Any]) {
var request = URLRequest(url: analyticsURL)
request.httpMethod = "POST"
@@ -105,18 +97,14 @@ class AnalyticsManager {
}.resume()
}
- // MARK: - Get App Version
private func getAppVersion() -> String {
return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown_version"
}
- // MARK: - Get Device Model
private func getDeviceModel() -> String {
return UIDevice.modelName
}
-
- // MARK: - Get Selected Module
private func getSelectedModule() -> ScrapingModule? {
guard let selectedModuleId = UserDefaults.standard.string(forKey: "selectedModuleId") else { return nil }
return moduleManager.modules.first { $0.id.uuidString == selectedModuleId }
diff --git a/Sora/Utils/Cache/EpisodeMetadata.swift b/Sora/Utils/Cache/EpisodeMetadata.swift
new file mode 100644
index 0000000..c27e25a
--- /dev/null
+++ b/Sora/Utils/Cache/EpisodeMetadata.swift
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/Sora/Utils/Cache/KingfisherManager.swift b/Sora/Utils/Cache/KingfisherManager.swift
new file mode 100644
index 0000000..860d02c
--- /dev/null
+++ b/Sora/Utils/Cache/KingfisherManager.swift
@@ -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))
+ }
+}
\ No newline at end of file
diff --git a/Sora/Utils/Cache/MetadataCacheManager.swift b/Sora/Utils/Cache/MetadataCacheManager.swift
new file mode 100644
index 0000000..aa1280b
--- /dev/null
+++ b/Sora/Utils/Cache/MetadataCacheManager.swift
@@ -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()
+
+ // 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 ?? "")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Sora/Utils/DownloadManager/DownloadManager.swift b/Sora/Utils/DownloadManager/DownloadManager.swift
index 9c3d90d..b9f5b15 100644
--- a/Sora/Utils/DownloadManager/DownloadManager.swift
+++ b/Sora/Utils/DownloadManager/DownloadManager.swift
@@ -7,23 +7,54 @@
import SwiftUI
import AVKit
+import Foundation
import AVFoundation
+struct DownloadedAsset: Identifiable, Codable {
+ let id: UUID
+ var name: String
+ let downloadDate: Date
+ let originalURL: URL
+ let localURL: URL
+ var fileSize: Int64?
+ let module: ScrapingModule
+
+ init(id: UUID = UUID(), name: String, downloadDate: Date, originalURL: URL, localURL: URL, module: ScrapingModule) {
+ self.id = id
+ self.name = name
+ self.downloadDate = downloadDate
+ self.originalURL = originalURL
+ self.localURL = localURL
+ self.module = module
+ self.fileSize = getFileSize()
+ }
+
+ func getFileSize() -> Int64? {
+ do {
+ let values = try localURL.resourceValues(forKeys: [.fileSizeKey])
+ return Int64(values.fileSize ?? 0)
+ } catch {
+ return nil
+ }
+ }
+}
+
class DownloadManager: NSObject, ObservableObject {
- @Published var activeDownloads: [(URL, Double)] = []
- @Published var localPlaybackURL: URL?
+ @Published var activeDownloads: [ActiveDownload] = []
+ @Published var savedAssets: [DownloadedAsset] = []
private var assetDownloadURLSession: AVAssetDownloadURLSession!
- private var activeDownloadTasks: [URLSessionTask: URL] = [:]
+ private var activeDownloadTasks: [URLSessionTask: (URL, ScrapingModule)] = [:]
override init() {
super.init()
initializeDownloadSession()
- loadLocalContent()
+ loadSavedAssets()
+ reconcileFileSystemAssets()
}
private func initializeDownloadSession() {
- let configuration = URLSessionConfiguration.background(withIdentifier: "hls-downloader")
+ let configuration = URLSessionConfiguration.background(withIdentifier: "downloader-\(UUID().uuidString)")
assetDownloadURLSession = AVAssetDownloadURLSession(
configuration: configuration,
assetDownloadDelegate: self,
@@ -31,65 +62,162 @@ class DownloadManager: NSObject, ObservableObject {
)
}
- func downloadAsset(from url: URL) {
- let asset = AVURLAsset(url: url)
+ func downloadAsset(from url: URL, module: ScrapingModule, headers: [String: String]? = nil) {
+ var urlRequest = URLRequest(url: url)
+
+
+ if let headers = headers {
+ for (key, value) in headers {
+ urlRequest.addValue(value, forHTTPHeaderField: key)
+ }
+ } else {
+ urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Origin")
+ urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Referer")
+ }
+
+ urlRequest.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
+
+ let asset = AVURLAsset(url: urlRequest.url!, options: ["AVURLAssetHTTPHeaderFieldsKey": urlRequest.allHTTPHeaderFields ?? [:]])
+
let task = assetDownloadURLSession.makeAssetDownloadTask(
asset: asset,
- assetTitle: "Offline Video",
+ assetTitle: url.lastPathComponent,
assetArtworkData: nil,
- options: nil
+ options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000]
)
-
+
+ let download = ActiveDownload(
+ id: UUID(),
+ originalURL: url,
+ progress: 0,
+ task: task!
+ )
+
+ activeDownloads.append(download)
+ activeDownloadTasks[task!] = (url, module)
task?.resume()
- activeDownloadTasks[task!] = url
}
- private func loadLocalContent() {
+ func deleteAsset(_ asset: DownloadedAsset) {
+ do {
+ try FileManager.default.removeItem(at: asset.localURL)
+ savedAssets.removeAll { $0.id == asset.id }
+ saveAssets()
+ } catch {
+ Logger.shared.log("Error deleting asset: \(error)")
+ }
+ }
+
+ func renameAsset(_ asset: DownloadedAsset, newName: String) {
+ guard let index = savedAssets.firstIndex(where: { $0.id == asset.id }) else { return }
+ savedAssets[index].name = newName
+ saveAssets()
+ }
+
+ private func saveAssets() {
+ do {
+ let data = try JSONEncoder().encode(savedAssets)
+ UserDefaults.standard.set(data, forKey: "savedAssets")
+ } catch {
+ Logger.shared.log("Error saving assets: \(error)")
+ }
+ }
+
+ private func loadSavedAssets() {
+ guard let data = UserDefaults.standard.data(forKey: "savedAssets") else { return }
+ do {
+ savedAssets = try JSONDecoder().decode([DownloadedAsset].self, from: data)
+ } catch {
+ Logger.shared.log("Error loading saved assets: \(error)")
+ }
+ }
+
+ private func reconcileFileSystemAssets() {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
do {
- let contents = try FileManager.default.contentsOfDirectory(
+ let fileURLs = try FileManager.default.contentsOfDirectory(
at: documents,
- includingPropertiesForKeys: nil,
+ includingPropertiesForKeys: [.creationDateKey, .fileSizeKey],
options: .skipsHiddenFiles
)
-
- if let localURL = contents.first(where: { $0.pathExtension == "movpkg" }) {
- localPlaybackURL = localURL
- }
} catch {
- print("Error loading local content: \(error)")
+ Logger.shared.log("Error reconciling files: \(error)")
}
}
}
extension DownloadManager: AVAssetDownloadDelegate {
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
- activeDownloadTasks.removeValue(forKey: assetDownloadTask)
- localPlaybackURL = location
+ guard let (originalURL, module) = activeDownloadTasks[assetDownloadTask] else { return }
+
+ let newAsset = DownloadedAsset(
+ name: originalURL.lastPathComponent,
+ downloadDate: Date(),
+ originalURL: originalURL,
+ localURL: location,
+ module: module
+ )
+
+ savedAssets.append(newAsset)
+ saveAssets()
+ cleanupDownloadTask(assetDownloadTask)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error = error else { return }
- print("Download error: \(error.localizedDescription)")
- activeDownloadTasks.removeValue(forKey: task)
+ Logger.shared.log("Download error: \(error.localizedDescription)")
+ cleanupDownloadTask(task)
}
- func urlSession(_ session: URLSession,
- assetDownloadTask: AVAssetDownloadTask,
- didLoad timeRange: CMTimeRange,
- totalTimeRangesLoaded loadedTimeRanges: [NSValue],
- timeRangeExpectedToLoad: CMTimeRange) {
+ func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) {
+ guard let (originalURL, _) = activeDownloadTasks[assetDownloadTask], let downloadIndex = activeDownloads.firstIndex(where: { $0.originalURL == originalURL }) else { return }
- 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))
+ activeDownloads[downloadIndex].progress = progress
+ }
+
+ private func cleanupDownloadTask(_ task: URLSessionTask) {
+ activeDownloadTasks.removeValue(forKey: task)
+ activeDownloads.removeAll { $0.task == task }
+ }
+}
+
+struct DownloadProgressView: View {
+ let download: ActiveDownload
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text(download.originalURL.lastPathComponent)
+ .font(.subheadline)
+ ProgressView(value: download.progress)
+ .progressViewStyle(LinearProgressViewStyle())
+ Text("\(Int(download.progress * 100))%")
+ .font(.caption)
}
}
}
+
+struct AssetRowView: View {
+ let asset: DownloadedAsset
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text(asset.name)
+ .font(.headline)
+ Text("\(asset.fileSize ?? 0) bytes • \(asset.downloadDate.formatted())")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+}
+
+struct ActiveDownload: Identifiable {
+ let id: UUID
+ let originalURL: URL
+ var progress: Double
+ let task: URLSessionTask
+}
diff --git a/Sora/Utils/DownloadUtils/DownloadManager.swift b/Sora/Utils/DownloadUtils/DownloadManager.swift
new file mode 100644
index 0000000..9c3d90d
--- /dev/null
+++ b/Sora/Utils/DownloadUtils/DownloadManager.swift
@@ -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))
+ }
+ }
+}
diff --git a/Sora/Utils/DownloadUtils/DownloadModels.swift b/Sora/Utils/DownloadUtils/DownloadModels.swift
new file mode 100644
index 0000000..bb10633
--- /dev/null
+++ b/Sora/Utils/DownloadUtils/DownloadModels.swift
@@ -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
+ }
+}
diff --git a/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift b/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift
new file mode 100644
index 0000000..c0886b9
--- /dev/null
+++ b/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift
@@ -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.. 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"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Sora/Utils/Drops/DropManager.swift b/Sora/Utils/Drops/DropManager.swift
index 8a56eb3..3956f4b 100644
--- a/Sora/Utils/Drops/DropManager.swift
+++ b/Sora/Utils/Drops/DropManager.swift
@@ -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")
+ )
}
}
diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift
index b5ddc34..de71bb5 100644
--- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift
+++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift
@@ -11,6 +11,9 @@ extension JSContext {
func setupConsoleLogging() {
let consoleObject = JSValue(newObjectIn: self)
+ let appInfoBridge = AppInfo()
+ consoleObject?.setObject(appInfoBridge, forKeyedSubscript: "AppInfo" as NSString)
+
let consoleLogFunction: @convention(block) (String) -> Void = { message in
Logger.shared.log(message, type: "Debug")
}
diff --git a/Sora/Utils/Extensions/UIDevice+Model.swift b/Sora/Utils/Extensions/UIDevice+Model.swift
index e07970d..b7a2c2a 100644
--- a/Sora/Utils/Extensions/UIDevice+Model.swift
+++ b/Sora/Utils/Extensions/UIDevice+Model.swift
@@ -18,7 +18,7 @@ public extension UIDevice {
return identifier + String(UnicodeScalar(UInt8(value)))
}
- func mapToDevice(identifier: String) -> String { // swiftlint:disable:this cyclomatic_complexity
+ func mapToDevice(identifier: String) -> String {
#if os(iOS)
switch identifier {
case "iPod5,1":
diff --git a/Sora/Utils/Extensions/URL.swift b/Sora/Utils/Extensions/URL.swift
index 3dc88f2..bb02f84 100644
--- a/Sora/Utils/Extensions/URL.swift
+++ b/Sora/Utils/Extensions/URL.swift
@@ -17,4 +17,9 @@ extension URL {
}
return params
}
+
+ static func isValidHLSURL(string: String) -> Bool {
+ guard let url = URL(string: string), url.pathExtension == "m3u8" else { return false }
+ return true
+ }
}
diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift
index efa4fcb..b712ec1 100644
--- a/Sora/Utils/Extensions/URLSession.swift
+++ b/Sora/Utils/Extensions/URLSession.swift
@@ -6,27 +6,22 @@
//
import Foundation
-// URL DELEGATE CLASS FOR FETCH API
-class FetchDelegate: NSObject, URLSessionTaskDelegate
-{
+
+class FetchDelegate: NSObject, URLSessionTaskDelegate {
private let allowRedirects: Bool
init(allowRedirects: Bool) {
self.allowRedirects = allowRedirects
}
- // This handles the redirection and prevents it.
- func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
- if(allowRedirects)
- {
- completionHandler(request) // Allow Redirect
- }
- else
- {
- completionHandler(nil) // Block Redirect
- }
-
- }
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
+ if(allowRedirects) {
+ completionHandler(request)
+ } else {
+ completionHandler(nil)
+ }
+ }
}
+
extension URLSession {
static let userAgents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
@@ -63,7 +58,7 @@ extension URLSession {
configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent]
return URLSession(configuration: configuration)
}()
- // return url session that redirects based on input
+
static func fetchData(allowRedirects:Bool) -> URLSession
{
let delegate = FetchDelegate(allowRedirects:allowRedirects)
@@ -72,4 +67,3 @@ extension URLSession {
return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
}
}
-
diff --git a/Sora/Utils/Extensions/UserDefaults.swift b/Sora/Utils/Extensions/UserDefaults.swift
new file mode 100644
index 0000000..5c27e66
--- /dev/null
+++ b/Sora/Utils/Extensions/UserDefaults.swift
@@ -0,0 +1,33 @@
+//
+// UserDefaults.swift
+// Sulfur
+//
+// Created by Francesco on 23/05/25.
+//
+
+import UIKit
+
+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)")
+ }
+ }
+}
diff --git a/Sora/Utils/JSLoader/JSController+M3U8Download.swift b/Sora/Utils/JSLoader/JSController+M3U8Download.swift
new file mode 100644
index 0000000..f42e13c
--- /dev/null
+++ b/Sora/Utils/JSLoader/JSController+M3U8Download.swift
@@ -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.. (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
+}
\ No newline at end of file
diff --git a/Sora/Utils/JSLoader/JSController+MP4Download.swift b/Sora/Utils/JSLoader/JSController+MP4Download.swift
new file mode 100644
index 0000000..cb68c49
--- /dev/null
+++ b/Sora/Utils/JSLoader/JSController+MP4Download.swift
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/Sora/Utils/JSLoader/JSController-Downloads.swift b/Sora/Utils/JSLoader/JSController-Downloads.swift
new file mode 100644
index 0000000..a6e9f6b
--- /dev/null
+++ b/Sora/Utils/JSLoader/JSController-Downloads.swift
@@ -0,0 +1,1555 @@
+//
+// JSController-Downloads.swift
+// Sora
+//
+// Created by doomsboygaming on 5/22/25
+//
+
+import Foundation
+import AVKit
+import AVFoundation
+import SwiftUI
+
+/// Enumeration of different download notification types to enable selective UI updates
+enum DownloadNotificationType: String {
+ case progress = "downloadProgressChanged" // Progress updates during download (no cache clearing needed)
+ case statusChange = "downloadStatusChanged" // Download started/queued/cancelled (no cache clearing needed)
+ case completed = "downloadCompleted" // Download finished (cache clearing needed)
+ case deleted = "downloadDeleted" // Asset deleted (cache clearing needed)
+ case libraryChange = "downloadLibraryChanged" // Library updated (cache clearing needed)
+ case cleanup = "downloadCleanup" // Cleanup operations (cache clearing needed)
+}
+
+// Extension for download functionality
+extension JSController {
+
+ // MARK: - Download Session Setup
+
+ // Class-level property to track asset validation
+ private static var hasValidatedAssets = false
+
+ // MARK: - Progress Update Debouncing
+
+ /// Tracks the last time a progress notification was sent for each download
+ private static var lastProgressUpdateTime: [UUID: Date] = [:]
+
+ /// Minimum time interval between progress notifications (in seconds)
+ private static let progressUpdateInterval: TimeInterval = 0.5 // Max 2 updates per second
+
+ /// Pending progress updates to batch and send
+ private static var pendingProgressUpdates: [UUID: (progress: Double, episodeNumber: Int?)] = [:]
+
+ /// Timer for batched progress updates
+ private static var progressUpdateTimer: Timer?
+
+ func initializeDownloadSession() {
+ // Create a unique identifier for the background session
+ let sessionIdentifier = "hls-downloader-\(UUID().uuidString)"
+
+ let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
+
+ // Configure session
+ configuration.allowsCellularAccess = true
+ configuration.shouldUseExtendedBackgroundIdleMode = true
+ configuration.waitsForConnectivity = true
+
+ // Create session with configuration
+ downloadURLSession = AVAssetDownloadURLSession(
+ configuration: configuration,
+ assetDownloadDelegate: self,
+ delegateQueue: .main
+ )
+
+ print("Download session initialized with ID: \(sessionIdentifier)")
+ loadSavedAssets()
+ }
+
+ /// Sets up JavaScript download function if needed
+ func setupDownloadFunction() {
+ // No JavaScript-side setup needed for now
+ print("Download function setup completed")
+ }
+
+ /// Helper function to post download notifications with proper naming
+ private func postDownloadNotification(_ type: DownloadNotificationType, userInfo: [String: Any]? = nil) {
+ DispatchQueue.main.async {
+ NotificationCenter.default.post(
+ name: NSNotification.Name(type.rawValue),
+ object: nil,
+ userInfo: userInfo
+ )
+ }
+ }
+
+ // MARK: - Download Queue Management
+
+ /// Initiates a download for the specified URL with the given headers
+ /// - Parameters:
+ /// - url: The URL to download
+ /// - headers: HTTP headers to use for the request
+ /// - title: Optional title for the download (defaults to filename)
+ /// - imageURL: Optional image URL for the download
+ /// - isEpisode: Indicates if the download is for an episode
+ /// - showTitle: Optional show title for the episode (anime title)
+ /// - season: Optional season number for the episode
+ /// - episode: Optional episode number for the episode
+ /// - subtitleURL: Optional subtitle URL to download after video
+ /// - module: Optional module to determine streamType
+ /// - completionHandler: Optional callback for download status
+ func startDownload(
+ 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,
+ module: ScrapingModule? = nil,
+ completionHandler: ((Bool, String) -> Void)? = nil
+ ) {
+ // If a module is provided, use the stream type aware download
+ if let module = module {
+ // Use the stream type aware download method
+ downloadWithStreamTypeSupport(
+ url: url,
+ headers: headers,
+ title: title,
+ imageURL: imageURL,
+ module: module,
+ isEpisode: isEpisode,
+ showTitle: showTitle,
+ season: season,
+ episode: episode,
+ subtitleURL: subtitleURL,
+ showPosterURL: showPosterURL,
+ completionHandler: completionHandler
+ )
+ return
+ }
+
+ // Legacy path for downloads without a module - use AVAssetDownloadURLSession
+ // Create an asset with custom HTTP header fields for authorization
+ let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
+
+ let downloadTitle = title ?? url.lastPathComponent
+
+ // Ensure we have a proper anime title for episodes
+ let animeTitle = isEpisode ? (showTitle ?? "Unknown Anime") : nil
+
+ // Create metadata for the download with proper anime title
+ let downloadType: DownloadType = isEpisode ? .episode : .movie
+ let assetMetadata = AssetMetadata(
+ title: downloadTitle,
+ overview: nil,
+ posterURL: imageURL, // Episode thumbnail
+ backdropURL: imageURL,
+ releaseDate: nil,
+ showTitle: animeTitle,
+ season: season,
+ episode: episode,
+ showPosterURL: showPosterURL // Main show poster
+ )
+
+ // Create the download ID now so we can use it for notifications
+ let downloadID = UUID()
+
+ // Create a download object with queued status
+ let download = JSActiveDownload(
+ id: downloadID,
+ originalURL: url,
+ progress: 0,
+ task: nil, // Task will be created when the download starts
+ queueStatus: .queued,
+ type: downloadType,
+ metadata: assetMetadata,
+ title: downloadTitle,
+ imageURL: imageURL,
+ subtitleURL: subtitleURL,
+ asset: asset,
+ headers: headers,
+ module: module // Pass the module to store it for queue processing
+ )
+
+ // Add to the download queue
+ downloadQueue.append(download)
+
+ // Immediately notify users about queued download
+ postDownloadNotification(.statusChange)
+
+ // If this is an episode, also post a progress update to force UI refresh with queued status
+ if let episodeNumber = download.metadata?.episode {
+ postDownloadNotification(.progress, userInfo: [
+ "episodeNumber": episodeNumber,
+ "progress": 0.0,
+ "status": "queued"
+ ])
+ }
+
+ // Inform caller of success
+ completionHandler?(true, "Download queued")
+
+ // Process the queue if we're not already doing so
+ if !isProcessingQueue {
+ processDownloadQueue()
+ }
+ }
+
+ /// Process the download queue and start downloads as slots are available
+ func processDownloadQueue() {
+ // Set flag to prevent multiple concurrent processing
+ isProcessingQueue = true
+
+ // Calculate how many more downloads we can start
+ let activeCount = activeDownloads.count
+ let slotsAvailable = max(0, maxConcurrentDownloads - activeCount)
+
+ if slotsAvailable > 0 && !downloadQueue.isEmpty {
+ // Get the next batch of downloads to start (up to available slots)
+ let nextBatch = Array(downloadQueue.prefix(slotsAvailable))
+
+ // Remove these from the queue
+ downloadQueue.removeFirst(min(slotsAvailable, downloadQueue.count))
+
+ // Force UI update for queue changes first
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ // Trigger @Published update for downloadQueue changes
+ self.objectWillChange.send()
+
+ // Post notification for queue status change
+ self.postDownloadNotification(.statusChange)
+ }
+
+ // Start each download with a small delay to ensure UI updates properly
+ for (index, queuedDownload) in nextBatch.enumerated() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.1) { [weak self] in
+ self?.startQueuedDownload(queuedDownload)
+ }
+ }
+ }
+
+ // If we still have queued downloads, schedule another check
+ if !downloadQueue.isEmpty {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
+ self?.processDownloadQueue()
+ }
+ } else {
+ // No more queued downloads
+ isProcessingQueue = false
+ }
+ }
+
+ /// Start a previously queued download
+ private func startQueuedDownload(_ queuedDownload: JSActiveDownload) {
+ print("Starting queued download: \(queuedDownload.title ?? queuedDownload.originalURL.lastPathComponent)")
+
+ // If we have a module, use the same method as manual downloads (this fixes the bug!)
+ if let module = queuedDownload.module {
+ print("Using downloadWithStreamTypeSupport for queued download (same as manual downloads)")
+
+ // Use the exact same method that manual downloads use
+ downloadWithStreamTypeSupport(
+ url: queuedDownload.originalURL,
+ headers: queuedDownload.headers,
+ title: queuedDownload.title,
+ imageURL: queuedDownload.imageURL,
+ module: module,
+ isEpisode: queuedDownload.type == .episode,
+ showTitle: queuedDownload.metadata?.showTitle,
+ season: queuedDownload.metadata?.season,
+ episode: queuedDownload.metadata?.episode,
+ subtitleURL: queuedDownload.subtitleURL,
+ showPosterURL: queuedDownload.metadata?.showPosterURL,
+ completionHandler: { success, message in
+ if success {
+ print("Queued download started successfully via downloadWithStreamTypeSupport")
+ } else {
+ print("Queued download failed: \(message)")
+ }
+ }
+ )
+ return
+ }
+
+ // Legacy fallback for downloads without module (should rarely be used now)
+ print("Using legacy download method for queued download (no module available)")
+
+ guard let asset = queuedDownload.asset else {
+ print("Missing asset for queued download")
+ return
+ }
+
+ // Create the download task
+ guard let task = downloadURLSession?.makeAssetDownloadTask(
+ asset: asset,
+ assetTitle: queuedDownload.title ?? queuedDownload.originalURL.lastPathComponent,
+ assetArtworkData: nil,
+ options: nil
+ ) else {
+ print("Failed to create download task for queued download")
+ return
+ }
+
+ // Create a new download object with the task
+ let download = JSActiveDownload(
+ id: queuedDownload.id,
+ originalURL: queuedDownload.originalURL,
+ progress: 0,
+ task: task,
+ queueStatus: .downloading,
+ type: queuedDownload.type,
+ metadata: queuedDownload.metadata,
+ title: queuedDownload.title,
+ imageURL: queuedDownload.imageURL,
+ subtitleURL: queuedDownload.subtitleURL,
+ asset: asset,
+ headers: queuedDownload.headers,
+ module: queuedDownload.module
+ )
+
+ // Add to active downloads
+ activeDownloads.append(download)
+ activeDownloadMap[task] = download.id
+
+ // Start the download
+ task.resume()
+ print("Queued download started: \(download.title ?? download.originalURL.lastPathComponent)")
+
+ // Save the download state
+ saveDownloadState()
+
+ // Force comprehensive UI updates on main thread
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+
+ // Trigger @Published property updates
+ self.objectWillChange.send()
+
+ // Post general status change notification
+ self.postDownloadNotification(.statusChange)
+
+ // If this is an episode, post detailed progress update with downloading status
+ if let episodeNumber = download.metadata?.episode {
+ self.postDownloadNotification(.progress, userInfo: [
+ "episodeNumber": episodeNumber,
+ "progress": 0.0,
+ "status": "downloading"
+ ])
+
+ // Also post a specific status change notification for this episode
+ NotificationCenter.default.post(
+ name: NSNotification.Name("episodeStatusChanged"),
+ object: nil,
+ userInfo: [
+ "episodeNumber": episodeNumber,
+ "showTitle": download.metadata?.showTitle ?? "",
+ "status": "downloading",
+ "downloadId": download.id.uuidString
+ ]
+ )
+ }
+
+ // Additional UI refresh notification
+ NotificationCenter.default.post(
+ name: NSNotification.Name("forceUIRefresh"),
+ object: nil
+ )
+ }
+ }
+
+ /// Clean up a download task when it's completed or failed
+ private func cleanupDownloadTask(_ task: URLSessionTask) {
+ guard let downloadID = activeDownloadMap[task] else { return }
+
+ activeDownloads.removeAll { $0.id == downloadID }
+ activeDownloadMap.removeValue(forKey: task)
+
+ // Clean up cancelled download tracking
+ cancelledDownloadIDs.remove(downloadID)
+
+ saveDownloadState()
+
+ print("Cleaned up download task")
+
+ // Start processing the queue again if we have pending downloads
+ if !downloadQueue.isEmpty && !isProcessingQueue {
+ processDownloadQueue()
+ }
+ }
+
+ /// Update download progress
+ func updateDownloadProgress(task: AVAssetDownloadTask, progress: Double) {
+ guard let downloadID = activeDownloadMap[task] else { return }
+
+ // Clamp progress between 0 and 1
+ let finalProgress = min(max(progress, 0.0), 1.0)
+
+ // Find and update the download progress
+ if let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) {
+ let download = activeDownloads[downloadIndex]
+ let previousProgress = download.progress
+ activeDownloads[downloadIndex].progress = finalProgress
+
+ // Send notifications for progress updates to ensure smooth real-time updates
+ // Send notification if:
+ // 1. Progress increased by at least 0.5% (0.005) for very smooth updates
+ // 2. Download completed (reached 100%)
+ // 3. This is the first progress update (from 0)
+ // 4. It's been a significant change (covers edge cases)
+ let progressDifference = finalProgress - previousProgress
+ let shouldUpdate = progressDifference >= 0.005 || finalProgress >= 1.0 || previousProgress == 0.0
+
+ if shouldUpdate {
+ // Post progress update notification (no cache clearing needed for progress updates)
+ postDownloadNotification(.progress)
+
+ // Also post detailed progress update with episode number if it's an episode
+ if let episodeNumber = download.metadata?.episode {
+ let status = finalProgress >= 1.0 ? "completed" : "downloading"
+ postDownloadNotification(.progress, userInfo: [
+ "episodeNumber": episodeNumber,
+ "progress": finalProgress,
+ "status": status
+ ])
+ }
+ }
+ }
+ }
+
+ /// Downloads a subtitle file and associates it with an asset
+ /// - Parameters:
+ /// - subtitleURL: The URL of the subtitle file to download
+ /// - assetID: The ID of the asset this subtitle is associated with
+ func downloadSubtitle(subtitleURL: URL, assetID: String) {
+ print("Downloading subtitle from: \(subtitleURL.absoluteString) for asset ID: \(assetID)")
+
+ // Check if this asset belongs to a cancelled download - if so, don't download subtitle
+ if let assetUUID = UUID(uuidString: assetID), cancelledDownloadIDs.contains(assetUUID) {
+ print("Skipping subtitle download for cancelled download: \(assetID)")
+ return
+ }
+
+ let session = URLSession.shared
+ var request = URLRequest(url: subtitleURL)
+
+ // Add more comprehensive headers for subtitle downloads
+ request.addValue("*/*", forHTTPHeaderField: "Accept")
+ request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
+ request.addValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
+ request.addValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
+
+ // Extract domain from subtitle URL to use as referer
+ if let host = subtitleURL.host {
+ let referer = "https://\(host)/"
+ request.addValue(referer, forHTTPHeaderField: "Referer")
+ request.addValue(referer, forHTTPHeaderField: "Origin")
+ }
+
+ print("Subtitle download request headers: \(request.allHTTPHeaderFields ?? [:])")
+
+ // Create a task to download the subtitle file
+ let task = session.downloadTask(with: request) { [weak self] (tempURL, response, error) in
+ guard let self = self else {
+ print("Self reference lost during subtitle download")
+ return
+ }
+
+ if let error = error {
+ print("Subtitle download error: \(error.localizedDescription)")
+ return
+ }
+
+ guard let tempURL = tempURL else {
+ print("No temporary URL received for subtitle download")
+ return
+ }
+
+ guard let downloadDir = self.getPersistentDownloadDirectory() else {
+ print("Failed to get persistent download directory for subtitle")
+ return
+ }
+
+ // Log response details for debugging
+ if let httpResponse = response as? HTTPURLResponse {
+ print("Subtitle download HTTP status: \(httpResponse.statusCode)")
+ print("Subtitle download content type: \(httpResponse.mimeType ?? "unknown")")
+ }
+
+ // Try to read content to validate it's actually a subtitle file
+ do {
+ let subtitleData = try Data(contentsOf: tempURL)
+ let subtitleContent = String(data: subtitleData, encoding: .utf8) ?? ""
+
+ if subtitleContent.isEmpty {
+ print("Warning: Subtitle file appears to be empty")
+ } else {
+ print("Subtitle file contains \(subtitleData.count) bytes of data")
+ if subtitleContent.hasPrefix("WEBVTT") {
+ print("Valid WebVTT subtitle detected")
+ } else if subtitleContent.contains(" --> ") {
+ print("Subtitle file contains timing markers")
+ } else {
+ print("Warning: Subtitle content doesn't appear to be in a recognized format")
+ }
+ }
+ } catch {
+ print("Error reading subtitle content for validation: \(error.localizedDescription)")
+ }
+
+ // Determine file extension based on the content type or URL
+ let fileExtension: String
+ if let mimeType = response?.mimeType {
+ switch mimeType.lowercased() {
+ case "text/vtt", "text/webvtt":
+ fileExtension = "vtt"
+ case "text/srt", "application/x-subrip":
+ fileExtension = "srt"
+ default:
+ // Use original extension or default to vtt
+ fileExtension = subtitleURL.pathExtension.isEmpty ? "vtt" : subtitleURL.pathExtension
+ }
+ } else {
+ fileExtension = subtitleURL.pathExtension.isEmpty ? "vtt" : subtitleURL.pathExtension
+ }
+
+ // Create a filename for the subtitle using the asset ID
+ let localFilename = "subtitle-\(assetID).\(fileExtension)"
+ let localURL = downloadDir.appendingPathComponent(localFilename)
+
+ do {
+ // If file already exists, remove it
+ if FileManager.default.fileExists(atPath: localURL.path) {
+ try FileManager.default.removeItem(at: localURL)
+ print("Removed existing subtitle file at \(localURL.path)")
+ }
+
+ // Move the downloaded file to the persistent location
+ try FileManager.default.moveItem(at: tempURL, to: localURL)
+
+ // Update the asset with the subtitle URL
+ self.updateAssetWithSubtitle(assetID: assetID,
+ subtitleURL: subtitleURL,
+ localSubtitleURL: localURL)
+
+ print("Subtitle downloaded successfully: \(localURL.path)")
+
+ // Show success notification
+ DispatchQueue.main.async {
+ DropManager.shared.success("Subtitle downloaded successfully")
+
+ // Force a UI update for the episode cell
+ NotificationCenter.default.post(
+ name: NSNotification.Name("downloadStatusChanged"),
+ object: nil
+ )
+
+ // If this is an episode, also post a progress update to force UI refresh
+ if let asset = self.savedAssets.first(where: { $0.id.uuidString == assetID }),
+ let episodeNumber = asset.metadata?.episode {
+ NotificationCenter.default.post(
+ name: NSNotification.Name("downloadProgressUpdated"),
+ object: nil,
+ userInfo: [
+ "episodeNumber": episodeNumber,
+ "progress": 1.0
+ ]
+ )
+ }
+ }
+ } catch {
+ print("Error moving subtitle file: \(error.localizedDescription)")
+ }
+ }
+
+ task.resume()
+ print("Subtitle download task started")
+ }
+
+ /// Updates an asset with subtitle information after subtitle download completes
+ /// - Parameters:
+ /// - assetID: The ID of the asset to update
+ /// - subtitleURL: The original subtitle URL
+ /// - localSubtitleURL: The local path where the subtitle file is stored
+ private func updateAssetWithSubtitle(assetID: String, subtitleURL: URL, localSubtitleURL: URL) {
+ // Find the asset in the saved assets array
+ if let index = savedAssets.firstIndex(where: { $0.id.uuidString == assetID }) {
+ // Create a new asset with the subtitle info (since struct is immutable)
+ let existingAsset = savedAssets[index]
+ let updatedAsset = DownloadedAsset(
+ id: existingAsset.id,
+ name: existingAsset.name,
+ downloadDate: existingAsset.downloadDate,
+ originalURL: existingAsset.originalURL,
+ localURL: existingAsset.localURL,
+ type: existingAsset.type,
+ metadata: existingAsset.metadata,
+ subtitleURL: subtitleURL,
+ localSubtitleURL: localSubtitleURL
+ )
+
+ // Dispatch the UI update to the main thread
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ // Replace the old asset with the updated one
+ self.savedAssets[index] = updatedAsset
+
+ // Save the updated assets array
+ self.saveAssets()
+ }
+ }
+ }
+
+ // MARK: - Asset Management
+
+ /// Load saved assets from UserDefaults
+ func loadSavedAssets() {
+ // First, migrate any existing files from Documents to Application Support
+ migrateExistingFilesToPersistentStorage()
+
+ guard let data = UserDefaults.standard.data(forKey: "downloadedAssets") else {
+ print("No saved assets found")
+ JSController.hasValidatedAssets = true // Mark as validated since there's nothing to validate
+ return
+ }
+
+ do {
+ savedAssets = try JSONDecoder().decode([DownloadedAsset].self, from: data)
+ print("Loaded \(savedAssets.count) saved assets")
+
+ // Only validate once per app session to avoid excessive file checks
+ if !JSController.hasValidatedAssets {
+ print("Validating asset locations...")
+ validateAndUpdateAssetLocations()
+ JSController.hasValidatedAssets = true
+ }
+ } catch {
+ print("Error loading saved assets: \(error.localizedDescription)")
+ }
+ }
+
+ /// Migrates any existing .movpkg files from Documents directory to the persistent location
+ private func migrateExistingFilesToPersistentStorage() {
+ let fileManager = FileManager.default
+
+ // Get Documents and Application Support directories
+ guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
+ let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
+ return
+ }
+
+ // Create persistent downloads directory if it doesn't exist
+ let persistentDir = appSupportDir.appendingPathComponent("SoraDownloads", isDirectory: true)
+ do {
+ if !fileManager.fileExists(atPath: persistentDir.path) {
+ try fileManager.createDirectory(at: persistentDir, withIntermediateDirectories: true)
+ print("Created persistent download directory at \(persistentDir.path)")
+ }
+
+ // Find any .movpkg files in the Documents directory
+ let files = try fileManager.contentsOfDirectory(at: documentsDir, includingPropertiesForKeys: nil)
+ let movpkgFiles = files.filter { $0.pathExtension == "movpkg" }
+
+ if !movpkgFiles.isEmpty {
+ print("Found \(movpkgFiles.count) .movpkg files in Documents directory to migrate")
+
+ // Migrate each file
+ for fileURL in movpkgFiles {
+ let filename = fileURL.lastPathComponent
+ let destinationURL = persistentDir.appendingPathComponent(filename)
+
+ // Check if file already exists in destination
+ if fileManager.fileExists(atPath: destinationURL.path) {
+ // Generate a unique name to avoid conflicts
+ let uniqueID = UUID().uuidString
+ let newDestinationURL = persistentDir.appendingPathComponent("\(filename)-\(uniqueID)")
+ try fileManager.copyItem(at: fileURL, to: newDestinationURL)
+ print("Migrated file with unique name: \(filename) → \(newDestinationURL.lastPathComponent)")
+ } else {
+ // Move the file to the persistent directory
+ try fileManager.copyItem(at: fileURL, to: destinationURL)
+ print("Migrated file: \(filename)")
+ }
+ }
+ } else {
+ print("No .movpkg files found in Documents directory for migration")
+ }
+ } catch {
+ print("Error during migration: \(error.localizedDescription)")
+ }
+ }
+
+ /// Validates that saved assets exist and updates their locations if needed
+ private func validateAndUpdateAssetLocations() {
+ let fileManager = FileManager.default
+ var updatedAssets = false
+ var assetsToRemove: [UUID] = []
+
+ // Check each asset and update its location if needed
+ for (index, asset) in savedAssets.enumerated() {
+ var needsUpdate = false
+ var updatedAsset = asset
+
+ // Check if the video file exists at the stored path
+ if !fileManager.fileExists(atPath: asset.localURL.path) {
+ print("Asset file not found at saved path: \(asset.localURL.path)")
+
+ // Try to find the file in the persistent directory
+ if let persistentURL = findAssetInPersistentStorage(assetName: asset.name) {
+ // Update the asset with the new video URL
+ print("Found asset in persistent storage: \(persistentURL.path)")
+ updatedAsset = DownloadedAsset(
+ id: asset.id,
+ name: asset.name,
+ downloadDate: asset.downloadDate,
+ originalURL: asset.originalURL,
+ localURL: persistentURL,
+ type: asset.type,
+ metadata: asset.metadata,
+ subtitleURL: asset.subtitleURL,
+ localSubtitleURL: asset.localSubtitleURL
+ )
+ needsUpdate = true
+ } else {
+ // If we can't find the video file, mark it for removal
+ print("Asset not found in persistent storage. Marking for removal: \(asset.name)")
+ assetsToRemove.append(asset.id)
+ updatedAssets = true
+ continue // Skip subtitle validation for assets being removed
+ }
+ }
+
+ // Check if the subtitle file exists (if one is expected)
+ if let localSubtitleURL = updatedAsset.localSubtitleURL {
+ if !fileManager.fileExists(atPath: localSubtitleURL.path) {
+ print("Subtitle file not found at saved path: \(localSubtitleURL.path)")
+
+ // Try to find the subtitle file in the persistent directory
+ if let foundSubtitleURL = findSubtitleInPersistentStorage(assetID: updatedAsset.id.uuidString) {
+ print("Found subtitle file in persistent storage: \(foundSubtitleURL.path)")
+ updatedAsset = DownloadedAsset(
+ id: updatedAsset.id,
+ name: updatedAsset.name,
+ downloadDate: updatedAsset.downloadDate,
+ originalURL: updatedAsset.originalURL,
+ localURL: updatedAsset.localURL,
+ type: updatedAsset.type,
+ metadata: updatedAsset.metadata,
+ subtitleURL: updatedAsset.subtitleURL,
+ localSubtitleURL: foundSubtitleURL
+ )
+ needsUpdate = true
+ } else {
+ // Subtitle file is missing - remove the subtitle reference but keep the video
+ print("Subtitle file not found in persistent storage for asset: \(updatedAsset.name)")
+ updatedAsset = DownloadedAsset(
+ id: updatedAsset.id,
+ name: updatedAsset.name,
+ downloadDate: updatedAsset.downloadDate,
+ originalURL: updatedAsset.originalURL,
+ localURL: updatedAsset.localURL,
+ type: updatedAsset.type,
+ metadata: updatedAsset.metadata,
+ subtitleURL: updatedAsset.subtitleURL,
+ localSubtitleURL: nil // Remove the invalid subtitle path
+ )
+ needsUpdate = true
+ }
+ }
+ }
+
+ // Update the asset if any changes were made
+ if needsUpdate {
+ savedAssets[index] = updatedAsset
+ updatedAssets = true
+ print("Updated asset paths for: \(updatedAsset.name)")
+ }
+ }
+
+ // Remove assets that don't exist anymore
+ if !assetsToRemove.isEmpty {
+ let countBefore = savedAssets.count
+ savedAssets.removeAll { assetsToRemove.contains($0.id) }
+ print("Removed \(countBefore - savedAssets.count) missing assets from the library")
+
+ // Notify observers of the change (library cleanup requires cache clearing)
+ postDownloadNotification(.cleanup)
+ }
+
+ // Save the updated asset information if changes were made
+ if updatedAssets {
+ saveAssets()
+ print("Asset validation complete. Updated \(updatedAssets ? "some" : "no") asset paths.")
+ }
+ }
+
+ /// Attempts to find an asset in the persistent storage directory
+ /// - Parameter assetName: The name of the asset to find
+ /// - Returns: URL to the found asset or nil if not found
+ private func findAssetInPersistentStorage(assetName: String) -> URL? {
+ let fileManager = FileManager.default
+
+ // Get Application Support directory
+ guard let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
+ return nil
+ }
+
+ // Path to our downloads directory
+ let downloadDir = appSupportDir.appendingPathComponent("SoraDownloads", isDirectory: true)
+
+ // Check if directory exists
+ guard fileManager.fileExists(atPath: downloadDir.path) else {
+ return nil
+ }
+
+ do {
+ // Get all files in the directory
+ let files = try fileManager.contentsOfDirectory(at: downloadDir, includingPropertiesForKeys: nil)
+
+ // Try to find a file that contains the asset name
+ for file in files where file.pathExtension == "movpkg" {
+ let filename = file.lastPathComponent
+
+ // If the filename contains the asset name, it's likely our file
+ if filename.contains(assetName) || assetName.contains(filename.components(separatedBy: "-").first ?? "") {
+ return file
+ }
+ }
+ } catch {
+ print("Error searching for asset in persistent storage: \(error.localizedDescription)")
+ }
+
+ return nil
+ }
+
+ /// Attempts to find a subtitle file in the persistent storage directory
+ /// - Parameter assetID: The ID of the asset to find subtitles for
+ /// - Returns: URL to the found subtitle file or nil if not found
+ private func findSubtitleInPersistentStorage(assetID: String) -> URL? {
+ let fileManager = FileManager.default
+
+ // Get Application Support directory
+ guard let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
+ print("Cannot access Application Support directory for subtitle search")
+ return nil
+ }
+
+ // Path to our downloads directory
+ let downloadDir = appSupportDir.appendingPathComponent("SoraDownloads", isDirectory: true)
+
+ // Check if directory exists
+ guard fileManager.fileExists(atPath: downloadDir.path) else {
+ print("Download directory does not exist for subtitle search")
+ return nil
+ }
+
+ do {
+ // Get all files in the directory
+ let files = try fileManager.contentsOfDirectory(at: downloadDir, includingPropertiesForKeys: nil)
+
+ // Common subtitle file extensions
+ let subtitleExtensions = ["vtt", "srt", "webvtt"]
+
+ // Try to find a subtitle file that matches the asset ID pattern
+ for file in files {
+ let filename = file.lastPathComponent
+ let fileExtension = file.pathExtension.lowercased()
+
+ // Check if this is a subtitle file with the correct naming pattern
+ if subtitleExtensions.contains(fileExtension) &&
+ filename.hasPrefix("subtitle-\(assetID).") {
+ print("Found subtitle file for asset \(assetID): \(filename)")
+ return file
+ }
+ }
+
+ print("No subtitle file found for asset ID: \(assetID)")
+ } catch {
+ print("Error searching for subtitle in persistent storage: \(error.localizedDescription)")
+ }
+
+ return nil
+ }
+
+ /// Save assets to UserDefaults
+ func saveAssets() {
+ do {
+ let data = try JSONEncoder().encode(savedAssets)
+ UserDefaults.standard.set(data, forKey: "downloadedAssets")
+ print("Saved \(savedAssets.count) assets to UserDefaults")
+ } catch {
+ print("Error saving assets: \(error.localizedDescription)")
+ }
+ }
+
+ /// Save the current state of downloads
+ private func saveDownloadState() {
+ // Only metadata needs to be saved since the tasks themselves can't be serialized
+ let downloadInfo = activeDownloads.map { download -> [String: Any] in
+ return [
+ "id": download.id.uuidString,
+ "url": download.originalURL.absoluteString,
+ "type": download.type.rawValue,
+ "title": download.title ?? download.originalURL.lastPathComponent
+ ]
+ }
+
+ UserDefaults.standard.set(downloadInfo, forKey: "activeDownloads")
+ print("Saved download state with \(downloadInfo.count) active downloads")
+ }
+
+ /// Delete an asset
+ func deleteAsset(_ asset: DownloadedAsset) {
+ do {
+ // Check if video file exists before attempting to delete
+ if FileManager.default.fileExists(atPath: asset.localURL.path) {
+ try FileManager.default.removeItem(at: asset.localURL)
+ print("Deleted asset file: \(asset.localURL.path)")
+ } else {
+ print("Asset file not found at path: \(asset.localURL.path)")
+ }
+
+ // Also delete subtitle file if it exists
+ if let subtitleURL = asset.localSubtitleURL,
+ FileManager.default.fileExists(atPath: subtitleURL.path) {
+ try FileManager.default.removeItem(at: subtitleURL)
+ print("Deleted subtitle file: \(subtitleURL.path)")
+ } else if asset.localSubtitleURL != nil {
+ print("Subtitle file not found at saved path, but reference existed")
+ }
+
+ // Remove from saved assets regardless of whether files were found
+ savedAssets.removeAll { $0.id == asset.id }
+ saveAssets()
+ print("Removed asset from library: \(asset.name)")
+
+ // Notify observers that an asset was deleted (cache clearing needed)
+ postDownloadNotification(.deleted)
+ } catch {
+ print("Error deleting asset: \(error.localizedDescription)")
+ }
+ }
+
+ /// Remove an asset from the library without deleting the file
+ func removeAssetFromLibrary(_ asset: DownloadedAsset) {
+ // Only remove the entry from savedAssets
+ savedAssets.removeAll { $0.id == asset.id }
+ saveAssets()
+ print("Removed asset from library (file preserved): \(asset.name)")
+
+ // Notify observers that the library changed (cache clearing needed)
+ postDownloadNotification(.libraryChange)
+ }
+
+ /// Returns the directory for persistent downloads
+ func getPersistentDownloadDirectory() -> URL? {
+ let fileManager = FileManager.default
+
+ // Get Application Support directory
+ guard let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
+ print("Cannot access Application Support directory")
+ return nil
+ }
+
+ // Create a dedicated subdirectory for our downloads if it doesn't exist
+ let downloadDir = appSupportDir.appendingPathComponent("SoraDownloads", isDirectory: true)
+
+ do {
+ if !fileManager.fileExists(atPath: downloadDir.path) {
+ try fileManager.createDirectory(at: downloadDir, withIntermediateDirectories: true)
+ print("Created persistent download directory at \(downloadDir.path)")
+ }
+ return downloadDir
+ } catch {
+ print("Error creating download directory: \(error.localizedDescription)")
+ return nil
+ }
+ }
+
+ /// Checks if an asset file exists before attempting to play it
+ /// - Parameter asset: The asset to verify
+ /// - Returns: True if the file exists, false otherwise
+ func verifyAssetFileExists(_ asset: DownloadedAsset) -> Bool {
+ let fileExists = FileManager.default.fileExists(atPath: asset.localURL.path)
+
+ if !fileExists {
+ // Try to find the file in a different location
+ if let newLocation = findAssetInPersistentStorage(assetName: asset.name) {
+ // Update the asset with the new location
+ if let index = savedAssets.firstIndex(where: { $0.id == asset.id }) {
+ savedAssets[index] = DownloadedAsset(
+ id: asset.id,
+ name: asset.name,
+ downloadDate: asset.downloadDate,
+ originalURL: asset.originalURL,
+ localURL: newLocation,
+ type: asset.type,
+ metadata: asset.metadata,
+ subtitleURL: asset.subtitleURL,
+ localSubtitleURL: asset.localSubtitleURL
+ )
+ saveAssets()
+ return true
+ }
+ } else {
+ // File is truly missing - remove it from saved assets
+ savedAssets.removeAll { $0.id == asset.id }
+ saveAssets()
+
+ // Show an error to the user
+ DispatchQueue.main.async {
+ DropManager.shared.error("File not found: \(asset.name)")
+ }
+ }
+ }
+
+ return fileExists
+ }
+
+ /// Determines if a new download will start immediately or be queued
+ /// - Returns: true if the download will start immediately, false if it will be queued
+ func willDownloadStartImmediately() -> Bool {
+ let activeCount = activeDownloads.count
+ let slotsAvailable = max(0, maxConcurrentDownloads - activeCount)
+ return slotsAvailable > 0
+ }
+
+ /// Checks if an episode is already downloaded or currently being downloaded
+ /// - Parameters:
+ /// - showTitle: The title of the show (anime title)
+ /// - episodeNumber: The episode number
+ /// - season: The season number (defaults to 1)
+ /// - Returns: Download status indicating if the episode is downloaded, being downloaded, or not downloaded
+ func isEpisodeDownloadedOrInProgress(
+ showTitle: String,
+ episodeNumber: Int,
+ season: Int = 1
+ ) -> EpisodeDownloadStatus {
+ // First check if it's already downloaded
+ for asset in savedAssets {
+ // Skip if not an episode or show title doesn't match
+ if asset.type != .episode { continue }
+ guard let metadata = asset.metadata,
+ let assetShowTitle = metadata.showTitle,
+ assetShowTitle.caseInsensitiveCompare(showTitle) == .orderedSame else {
+ continue
+ }
+
+ // Check episode number
+ let assetEpisode = metadata.episode ?? 0
+ let assetSeason = metadata.season ?? 1
+
+ if assetEpisode == episodeNumber && assetSeason == season {
+ return .downloaded(asset)
+ }
+ }
+
+ // Then check if it's currently being downloaded (actively downloading)
+ for download in activeDownloads {
+ // Skip if not an episode or show title doesn't match
+ if download.type != .episode { continue }
+ guard let metadata = download.metadata,
+ let assetShowTitle = metadata.showTitle,
+ assetShowTitle.caseInsensitiveCompare(showTitle) == .orderedSame else {
+ continue
+ }
+
+ // Check episode number
+ let assetEpisode = metadata.episode ?? 0
+ let assetSeason = metadata.season ?? 1
+
+ if assetEpisode == episodeNumber && assetSeason == season {
+ return .downloading(download)
+ }
+ }
+
+ // Finally check if it's queued for download
+ for download in downloadQueue {
+ // Skip if not an episode or show title doesn't match
+ if download.type != .episode { continue }
+ guard let metadata = download.metadata,
+ let assetShowTitle = metadata.showTitle,
+ assetShowTitle.caseInsensitiveCompare(showTitle) == .orderedSame else {
+ continue
+ }
+
+ // Check episode number
+ let assetEpisode = metadata.episode ?? 0
+ let assetSeason = metadata.season ?? 1
+
+ if assetEpisode == episodeNumber && assetSeason == season {
+ return .downloading(download)
+ }
+ }
+
+ // Not downloaded or being downloaded
+ return .notDownloaded
+ }
+
+ /// Cancel a queued download that hasn't started yet
+ func cancelQueuedDownload(_ downloadID: UUID) {
+ // Remove from the download queue if it exists there
+ if let index = downloadQueue.firstIndex(where: { $0.id == downloadID }) {
+ let downloadTitle = downloadQueue[index].title ?? downloadQueue[index].originalURL.lastPathComponent
+ downloadQueue.remove(at: index)
+
+ // Show notification
+ DropManager.shared.info("Download cancelled: \(downloadTitle)")
+
+ // Notify observers of status change (no cache clearing needed for cancellation)
+ postDownloadNotification(.statusChange)
+
+ print("Cancelled queued download: \(downloadTitle)")
+ }
+ }
+
+ /// Cancel an active download that is currently in progress
+ func cancelActiveDownload(_ downloadID: UUID) {
+ // First, immediately mark this download as cancelled to prevent any completion processing
+ cancelledDownloadIDs.insert(downloadID)
+
+ // Find the active download and cancel its task
+ if let activeDownload = activeDownloads.first(where: { $0.id == downloadID }),
+ let task = activeDownload.task {
+ let downloadTitle = activeDownload.title ?? activeDownload.originalURL.lastPathComponent
+
+ // Cancel the actual download task
+ task.cancel()
+
+ // Show notification
+ DropManager.shared.info("Download cancelled: \(downloadTitle)")
+
+ print("Cancelled active download: \(downloadTitle)")
+ }
+ }
+}
+
+// MARK: - AVAssetDownloadDelegate
+extension JSController: AVAssetDownloadDelegate {
+
+ /// Called when a download task finishes downloading the asset
+ func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
+ guard let downloadID = activeDownloadMap[assetDownloadTask],
+ let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
+ print("Download task finished but couldn't find associated download")
+ return
+ }
+
+ // Check if this download was cancelled - if so, don't process completion
+ if cancelledDownloadIDs.contains(downloadID) {
+ print("Ignoring completion for cancelled download: \(downloadID)")
+ // Delete any temporary files that may have been created
+ try? FileManager.default.removeItem(at: location)
+ return
+ }
+
+ let download = activeDownloads[downloadIndex]
+
+ // Move the downloaded file to Application Support directory for persistence
+ guard let persistentURL = moveToApplicationSupportDirectory(from: location, filename: download.title ?? download.originalURL.lastPathComponent) else {
+ print("Failed to move downloaded file to persistent storage")
+ return
+ }
+
+ // Create a new DownloadedAsset with metadata from the active download
+ let newAsset = DownloadedAsset(
+ name: download.title ?? download.originalURL.lastPathComponent,
+ downloadDate: Date(),
+ originalURL: download.originalURL,
+ localURL: persistentURL,
+ type: download.type,
+ metadata: download.metadata, // Use the metadata we created when starting the download
+ subtitleURL: download.subtitleURL // Store the subtitle URL, but localSubtitleURL will be nil until subtitle is downloaded
+ )
+
+ // Add to saved assets and save
+ savedAssets.append(newAsset)
+ saveAssets()
+
+ // If there's a subtitle URL, download it now that the video is saved
+ if let subtitleURL = download.subtitleURL {
+ downloadSubtitle(subtitleURL: subtitleURL, assetID: newAsset.id.uuidString)
+ } else {
+ // No subtitle URL, so we can consider the download complete
+ // Notify that download completed (cache clearing needed for new file)
+ postDownloadNotification(.completed)
+
+ // If this is an episode, also post a progress update to force UI refresh
+ if let episodeNumber = download.metadata?.episode {
+ postDownloadNotification(.progress, userInfo: [
+ "episodeNumber": episodeNumber,
+ "progress": 1.0,
+ "status": "completed"
+ ])
+ }
+ }
+
+ // Clean up the download task
+ cleanupDownloadTask(assetDownloadTask)
+
+ print("Download completed and moved to persistent storage: \(newAsset.name)")
+ }
+
+ /// Moves a downloaded file to Application Support directory to preserve it across app updates
+ /// - Parameters:
+ /// - location: The original location from the download task
+ /// - filename: Name to use for the file
+ /// - Returns: URL to the new persistent location or nil if move failed
+ private func moveToApplicationSupportDirectory(from location: URL, filename: String) -> URL? {
+ let fileManager = FileManager.default
+
+ // Get Application Support directory
+ guard let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
+ print("Cannot access Application Support directory")
+ return nil
+ }
+
+ // Create a dedicated subdirectory for our downloads if it doesn't exist
+ let downloadDir = appSupportDir.appendingPathComponent("SoraDownloads", isDirectory: true)
+
+ do {
+ if !fileManager.fileExists(atPath: downloadDir.path) {
+ try fileManager.createDirectory(at: downloadDir, withIntermediateDirectories: true)
+ print("Created persistent download directory at \(downloadDir.path)")
+ }
+
+ // Generate unique filename with UUID to avoid conflicts
+ let uniqueID = UUID().uuidString
+ let safeFilename = filename.replacingOccurrences(of: "/", with: "-")
+ .replacingOccurrences(of: ":", with: "-")
+
+ let destinationURL = downloadDir.appendingPathComponent("\(safeFilename)-\(uniqueID).movpkg")
+
+ // Move the file to the persistent location
+ try fileManager.moveItem(at: location, to: destinationURL)
+ print("Successfully moved download to persistent storage: \(destinationURL.path)")
+
+ return destinationURL
+ } catch {
+ print("Error moving download to persistent storage: \(error.localizedDescription)")
+ return nil
+ }
+ }
+
+ /// Called when a download task encounters an error
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
+ if let error = error {
+ // Enhanced error logging
+ print("Download error: \(error.localizedDescription)")
+
+ // Extract and log the underlying error details
+ let nsError = error as NSError
+ print("Error domain: \(nsError.domain), code: \(nsError.code)")
+
+ if let underlyingError = nsError.userInfo["NSUnderlyingError"] as? NSError {
+ print("Underlying error: \(underlyingError)")
+ }
+
+ for (key, value) in nsError.userInfo {
+ print("Error info - \(key): \(value)")
+ }
+
+ // Check if there's a system network error
+ if let urlError = error as? URLError {
+ print("URLError code: \(urlError.code.rawValue)")
+
+ // Handle cancellation specifically
+ if urlError.code == .cancelled {
+ print("Download was cancelled by user")
+ handleDownloadCancellation(task)
+ return
+ } else if urlError.code == .notConnectedToInternet || urlError.code == .networkConnectionLost {
+ print("Network error: \(urlError.localizedDescription)")
+
+ DispatchQueue.main.async {
+ DropManager.shared.error("Network error: \(urlError.localizedDescription)")
+ }
+ } else if urlError.code == .userAuthenticationRequired || urlError.code == .userCancelledAuthentication {
+ print("Authentication error: \(urlError.localizedDescription)")
+
+ DispatchQueue.main.async {
+ DropManager.shared.error("Authentication error: Check headers")
+ }
+ }
+ } else if error.localizedDescription.contains("403") {
+ // Specific handling for 403 Forbidden errors
+ print("403 Forbidden error - Server rejected the request")
+
+ DispatchQueue.main.async {
+ DropManager.shared.error("Access denied (403): The server refused access to this content")
+ }
+ } else {
+ DispatchQueue.main.async {
+ DropManager.shared.error("Download failed: \(error.localizedDescription)")
+ }
+ }
+ }
+
+ cleanupDownloadTask(task)
+ }
+
+ /// Handle download cancellation - clean up without treating as completion
+ private func handleDownloadCancellation(_ task: URLSessionTask) {
+ guard let downloadID = activeDownloadMap[task] else {
+ print("Cancelled download task not found in active downloads")
+ cleanupDownloadTask(task)
+ return
+ }
+
+ // Mark this download as cancelled to prevent completion processing
+ cancelledDownloadIDs.insert(downloadID)
+
+ // Find the download object to get its title
+ let downloadTitle = activeDownloads.first { $0.id == downloadID }?.title ?? "Unknown"
+
+ // Check if there's a partially downloaded file that needs to be deleted
+ if let assetDownloadTask = task as? AVAssetDownloadTask {
+ // For AVAssetDownloadTask, we need to check if any partial files were created
+ // and delete them to prevent them from being considered completed downloads
+ deletePartiallyDownloadedAsset(downloadID: downloadID)
+ }
+
+ // Show user notification
+ DropManager.shared.info("Download cancelled: \(downloadTitle)")
+
+ // Clean up the download task (this removes it from activeDownloads and activeDownloadMap)
+ cleanupDownloadTask(task)
+
+ // Notify observers of cancellation (no cache clearing needed)
+ postDownloadNotification(.statusChange)
+
+ print("Successfully handled cancellation for: \(downloadTitle)")
+ }
+
+ /// Delete any partially downloaded assets for a cancelled download
+ private func deletePartiallyDownloadedAsset(downloadID: UUID) {
+ // Check if the asset was already saved to our permanent collection
+ // and remove it if it was (this prevents cancelled downloads from appearing as completed)
+ if let savedAssetIndex = savedAssets.firstIndex(where: { savedAsset in
+ // We can't directly match by download ID since savedAssets don't store it,
+ // so we'll match by checking if this asset was just added (within last few seconds)
+ // and if the download was in progress
+ let wasRecentlyAdded = Date().timeIntervalSince(savedAsset.downloadDate) < 30 // Within 30 seconds
+ return wasRecentlyAdded
+ }) {
+ let assetToDelete = savedAssets[savedAssetIndex]
+ print("Removing cancelled download from saved assets: \(assetToDelete.name)")
+
+ // Delete the actual file if it exists
+ if FileManager.default.fileExists(atPath: assetToDelete.localURL.path) {
+ do {
+ try FileManager.default.removeItem(at: assetToDelete.localURL)
+ print("Deleted partially downloaded file: \(assetToDelete.localURL.path)")
+ } catch {
+ print("Error deleting partially downloaded file: \(error.localizedDescription)")
+ }
+ }
+
+ // Remove from saved assets
+ savedAssets.remove(at: savedAssetIndex)
+ saveAssets()
+
+ // Notify observers that an asset was deleted
+ postDownloadNotification(.deleted)
+ }
+ }
+
+ /// Update progress of download task
+ func urlSession(_ session: URLSession,
+ assetDownloadTask: AVAssetDownloadTask,
+ didLoad timeRange: CMTimeRange,
+ totalTimeRangesLoaded loadedTimeRanges: [NSValue],
+ timeRangeExpectedToLoad: CMTimeRange) {
+
+ // Do a quick check to see if task is still registered
+ guard let downloadID = activeDownloadMap[assetDownloadTask] else {
+ print("Received progress for unknown download task")
+ return
+ }
+
+ // Calculate download progress
+ var totalProgress: Double = 0
+
+ // Calculate the total progress by summing all loaded time ranges and dividing by expected time range
+ for value in loadedTimeRanges {
+ let loadedTimeRange = value.timeRangeValue
+ let duration = loadedTimeRange.duration.seconds
+ let expectedDuration = timeRangeExpectedToLoad.duration.seconds
+
+ // Only add if the expected duration is valid (greater than 0)
+ if expectedDuration > 0 {
+ totalProgress += (duration / expectedDuration)
+ }
+ }
+
+ // Clamp total progress between 0 and 1
+ let finalProgress = min(max(totalProgress, 0.0), 1.0)
+
+ // Update the download object with the new progress
+ updateDownloadProgress(task: assetDownloadTask, progress: finalProgress)
+ }
+}
+
+// MARK: - URLSessionTaskDelegate
+extension JSController: URLSessionTaskDelegate {
+ /// Called when a redirect is received
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
+ // Log information about the redirect
+ print("==== REDIRECT DETECTED ====")
+ print("Redirecting to: \(request.url?.absoluteString ?? "unknown")")
+ print("Redirect status code: \(response.statusCode)")
+
+ // Don't try to access originalRequest for AVAssetDownloadTask
+ if !(task is AVAssetDownloadTask), let originalRequest = task.originalRequest {
+ print("Original URL: \(originalRequest.url?.absoluteString ?? "unknown")")
+ print("Original Headers: \(originalRequest.allHTTPHeaderFields ?? [:])")
+
+ // Create a modified request that preserves ALL original headers
+ var modifiedRequest = request
+
+ // Add all original headers to the new request
+ for (key, value) in originalRequest.allHTTPHeaderFields ?? [:] {
+ // Only add if not already present in the redirect request
+ if modifiedRequest.value(forHTTPHeaderField: key) == nil {
+ print("Adding missing header: \(key): \(value)")
+ modifiedRequest.addValue(value, forHTTPHeaderField: key)
+ }
+ }
+
+ print("Final redirect headers: \(modifiedRequest.allHTTPHeaderFields ?? [:])")
+
+ // Allow the redirect with our modified request
+ completionHandler(modifiedRequest)
+ } else {
+ // For AVAssetDownloadTask, just accept the redirect as is
+ print("Accepting redirect for AVAssetDownloadTask without header modification")
+ completionHandler(request)
+ }
+ }
+
+ /// Handle authentication challenges
+ func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
+ print("==== AUTH CHALLENGE ====")
+ print("Authentication method: \(challenge.protectionSpace.authenticationMethod)")
+ print("Host: \(challenge.protectionSpace.host)")
+
+ if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
+ // Handle SSL/TLS certificate validation
+ if let serverTrust = challenge.protectionSpace.serverTrust {
+ let credential = URLCredential(trust: serverTrust)
+ print("Accepting server trust for host: \(challenge.protectionSpace.host)")
+ completionHandler(.useCredential, credential)
+ return
+ }
+ }
+
+ // Default to performing authentication without credentials
+ print("Using default handling for authentication challenge")
+ completionHandler(.performDefaultHandling, nil)
+ }
+}
+
+// MARK: - Download Types
+/// Struct to represent an active download in JSController
+struct JSActiveDownload: Identifiable, Equatable {
+ let id: UUID
+ let originalURL: URL
+ var progress: Double
+ let task: AVAssetDownloadTask?
+ let type: DownloadType
+ var metadata: AssetMetadata?
+ var title: String?
+ var imageURL: URL?
+ var subtitleURL: URL?
+ var queueStatus: DownloadQueueStatus
+ var asset: AVURLAsset?
+ var headers: [String: String]
+ var module: ScrapingModule? // Add module property to store ScrapingModule
+
+ // Implement Equatable
+ static func == (lhs: JSActiveDownload, rhs: JSActiveDownload) -> Bool {
+ return lhs.id == rhs.id &&
+ lhs.originalURL == rhs.originalURL &&
+ lhs.progress == rhs.progress &&
+ lhs.type == rhs.type &&
+ lhs.title == rhs.title &&
+ lhs.imageURL == rhs.imageURL &&
+ lhs.subtitleURL == rhs.subtitleURL &&
+ lhs.queueStatus == rhs.queueStatus
+ }
+
+ init(
+ id: UUID = UUID(),
+ originalURL: URL,
+ progress: Double = 0,
+ task: AVAssetDownloadTask? = nil,
+ queueStatus: DownloadQueueStatus = .queued,
+ type: DownloadType = .movie,
+ metadata: AssetMetadata? = nil,
+ title: String? = nil,
+ imageURL: URL? = nil,
+ subtitleURL: URL? = nil,
+ asset: AVURLAsset? = nil,
+ headers: [String: String] = [:],
+ module: ScrapingModule? = nil // Add module parameter to initializer
+ ) {
+ self.id = id
+ self.originalURL = originalURL
+ self.progress = progress
+ self.task = task
+ self.type = type
+ self.metadata = metadata
+ self.title = title
+ self.imageURL = imageURL
+ self.subtitleURL = subtitleURL
+ self.queueStatus = queueStatus
+ self.asset = asset
+ self.headers = headers
+ self.module = module // Store the module
+ }
+}
+
+/// Represents the download status of an episode
+enum EpisodeDownloadStatus: Equatable {
+ /// Episode is not downloaded and not being downloaded
+ case notDownloaded
+ /// Episode is currently being downloaded
+ case downloading(JSActiveDownload)
+ /// Episode is already downloaded
+ case downloaded(DownloadedAsset)
+
+ /// Returns true if the episode is either downloaded or being downloaded
+ var isDownloadedOrInProgress: Bool {
+ switch self {
+ case .notDownloaded:
+ return false
+ case .downloading, .downloaded:
+ return true
+ }
+ }
+
+ static func == (lhs: EpisodeDownloadStatus, rhs: EpisodeDownloadStatus) -> Bool {
+ switch (lhs, rhs) {
+ case (.notDownloaded, .notDownloaded):
+ return true
+ case (.downloading(let lhsDownload), .downloading(let rhsDownload)):
+ return lhsDownload.id == rhsDownload.id
+ case (.downloaded(let lhsAsset), .downloaded(let rhsAsset)):
+ return lhsAsset.id == rhsAsset.id
+ default:
+ return false
+ }
+ }
+}
+
+/// Represents the download queue status of a download
+enum DownloadQueueStatus: Equatable {
+ /// Download is queued and not started
+ case queued
+ /// Download is currently being processed
+ case downloading
+ /// Download has been completed
+ case completed
+}
\ No newline at end of file
diff --git a/Sora/Utils/JSLoader/JSController-HeaderManager.swift b/Sora/Utils/JSLoader/JSController-HeaderManager.swift
new file mode 100644
index 0000000..12a8859
--- /dev/null
+++ b/Sora/Utils/JSLoader/JSController-HeaderManager.swift
@@ -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)")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Sora/Utils/JSLoader/JSController-StreamTypeDownload.swift b/Sora/Utils/JSLoader/JSController-StreamTypeDownload.swift
new file mode 100644
index 0000000..a6a17a9
--- /dev/null
+++ b/Sora/Utils/JSLoader/JSController-StreamTypeDownload.swift
@@ -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
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/Sora/Utils/JSLoader/JSController-Streams.swift b/Sora/Utils/JSLoader/JSController-Streams.swift
index 2f29a05..b245658 100644
--- a/Sora/Utils/JSLoader/JSController-Streams.swift
+++ b/Sora/Utils/JSLoader/JSController-Streams.swift
@@ -36,7 +36,6 @@ extension JSController {
if let data = resultString.data(using: .utf8) {
do {
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
- print("JSON DATA IS \(json) 2")
var streamUrls: [String]? = nil
var subtitleUrls: [String]? = nil
var streamUrlsAndHeaders : [[String:Any]]? = nil
@@ -81,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()
}
@@ -117,7 +127,6 @@ extension JSController {
let data = jsonString.data(using: .utf8) {
do {
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
- print("JSON object is \(json) 1")
var streamUrls: [String]? = nil
var subtitleUrls: [String]? = nil
var streamUrlsAndHeaders : [[String:Any]]? = nil
@@ -164,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))
}
@@ -227,7 +244,6 @@ extension JSController {
let data = jsonString.data(using: .utf8) {
do {
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
- print("JSON object is \(json) 3 ")
var streamUrls: [String]? = nil
var subtitleUrls: [String]? = nil
var streamUrlsAndHeaders : [[String:Any]]? = nil
@@ -274,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))
}
diff --git a/Sora/Utils/JSLoader/JSController.swift b/Sora/Utils/JSLoader/JSController.swift
index 2485ded..486693d 100644
--- a/Sora/Utils/JSLoader/JSController.swift
+++ b/Sora/Utils/JSLoader/JSController.swift
@@ -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 = []
+
+ // 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
}
diff --git a/Sora/Utils/Logger/Logger.swift b/Sora/Utils/Logger/Logger.swift
index 28ce20a..7749132 100644
--- a/Sora/Utils/Logger/Logger.swift
+++ b/Sora/Utils/Logger/Logger.swift
@@ -21,6 +21,8 @@ class Logger {
private let logFileURL: URL
private let logFilterViewModel = LogFilterViewModel.shared
+ private let maxFileSize = 1024 * 512
+
private init() {
let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
logFileURL = documentDirectory.appendingPathComponent("logs.txt")
@@ -64,10 +66,33 @@ class Logger {
if let data = logString.data(using: .utf8) {
if FileManager.default.fileExists(atPath: logFileURL.path) {
- if let handle = try? FileHandle(forWritingTo: logFileURL) {
- handle.seekToEndOfFile()
- handle.write(data)
- handle.closeFile()
+ do {
+ let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
+ let fileSize = attributes[.size] as? UInt64 ?? 0
+
+ if fileSize + UInt64(data.count) > maxFileSize {
+ guard var content = try? String(contentsOf: logFileURL, encoding: .utf8) else { return }
+
+ while (content.data(using: .utf8)?.count ?? 0) + data.count > maxFileSize {
+ if let rangeOfFirstLine = content.range(of: "\n---\n") {
+ content.removeSubrange(content.startIndex...rangeOfFirstLine.upperBound)
+ } else {
+ content = ""
+ break
+ }
+ }
+
+ content += logString
+ try? content.data(using: .utf8)?.write(to: logFileURL)
+ } else {
+ if let handle = try? FileHandle(forWritingTo: logFileURL) {
+ handle.seekToEndOfFile()
+ handle.write(data)
+ handle.closeFile()
+ }
+ }
+ } catch {
+ print("Error managing log file: \(error)")
}
} else {
try? data.write(to: logFileURL)
diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift
index 9709dba..1dbfc78 100644
--- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift
+++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift
@@ -96,6 +96,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
var skip85Button: UIButton!
var qualityButton: UIButton!
var holdSpeedIndicator: UIButton!
+ private var lockButton: UIButton!
var isHLSStream: Bool = false
var qualities: [(String, String)] = []
@@ -138,6 +139,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private var loadedTimeRangesObservation: NSKeyValueObservation?
private var playerTimeControlStatusObserver: NSKeyValueObservation?
+ private var controlsLocked = false
+ private var lockButtonTimer: Timer?
+
private var isDimmed = false
private var dimButton: UIButton!
private var dimButtonToSlider: NSLayoutConstraint!
@@ -149,15 +153,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
playPauseButton,
backwardButton,
forwardButton,
- sliderHostingController!.view,
+ sliderHostingController?.view,
skip85Button,
marqueeLabel,
menuButton,
qualityButton,
speedButton,
watchNextButton,
- volumeSliderHostingView!
- ]
+ volumeSliderHostingView
+ ].compactMap { $0 }
private var originalHiddenStates: [UIView: Bool] = [:]
@@ -199,20 +203,16 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
var request = URLRequest(url: url)
- if let mydict = headers, !mydict.isEmpty
- {
- for (key,value) in mydict
- {
+ if let mydict = headers, !mydict.isEmpty {
+ for (key,value) in mydict {
request.addValue(value, forHTTPHeaderField: key)
}
- }
- else
- {
+ } else {
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
}
- request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
- forHTTPHeaderField: "User-Agent")
+
+ request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
let playerItem = AVPlayerItem(asset: asset)
@@ -252,6 +252,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
setupSkipAndDismissGestures()
addTimeObserver()
startUpdateTimer()
+ setupLockButton()
setupAudioSession()
updateSkipButtonsVisibility()
setupHoldSpeedIndicator()
@@ -295,9 +296,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
}
+#if os(iOS) && !targetEnvironment(macCatalyst)
if #available(iOS 16.0, *) {
playerViewController.allowsVideoFrameAnalysis = false
}
+#endif
if let url = subtitlesURL, !url.isEmpty {
subtitlesLoader.load(from: url)
@@ -1135,6 +1138,32 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
dimButtonToSlider.isActive = true
}
+ private func setupLockButton() {
+ let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular)
+ lockButton = UIButton(type: .system)
+ lockButton.setImage(
+ UIImage(systemName: "lock.open.fill", withConfiguration: cfg),
+ for: .normal
+ )
+ lockButton.tintColor = .white
+ lockButton.layer.shadowColor = UIColor.black.cgColor
+ lockButton.layer.shadowOffset = CGSize(width: 0, height: 2)
+ lockButton.layer.shadowOpacity = 0.6
+ lockButton.layer.shadowRadius = 4
+ lockButton.layer.masksToBounds = false
+
+ lockButton.addTarget(self, action: #selector(lockTapped), for: .touchUpInside)
+
+ view.addSubview(lockButton)
+ lockButton.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ lockButton.topAnchor.constraint(equalTo: volumeSliderHostingView!.bottomAnchor, constant: 60),
+ lockButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor),
+ lockButton.widthAnchor.constraint(equalToConstant: 24),
+ lockButton.heightAnchor.constraint(equalToConstant: 24),
+ ])
+ }
+
func updateMarqueeConstraints() {
UIView.performWithoutAnimation {
NSLayoutConstraint.deactivate(currentMarqueeConstraints)
@@ -1462,20 +1491,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
}
- @objc private func skipIntro() {
- if let range = skipIntervals.op {
- player.seek(to: range.end)
- skipIntroButton.isHidden = true
- }
- }
-
- @objc private func skipOutro() {
- if let range = skipIntervals.ed {
- player.seek(to: range.end)
- skipOutroButton.isHidden = true
- }
- }
-
func startUpdateTimer() {
updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
@@ -1501,30 +1516,46 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
@objc func toggleControls() {
+ if controlsLocked {
+ lockButton.alpha = 1.0
+ lockButtonTimer?.invalidate()
+ lockButtonTimer = Timer.scheduledTimer(
+ withTimeInterval: 3.0,
+ repeats: false
+ ) { [weak self] _ in
+ UIView.animate(withDuration: 0.3) {
+ self?.lockButton.alpha = 0
+ }
+ }
+ updateSkipButtonsVisibility()
+ return
+ }
+
if isDimmed {
dimButton.isHidden = false
dimButton.alpha = 1.0
dimButtonTimer?.invalidate()
dimButtonTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
- guard let self = self else { return }
- UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut]) {
- self.dimButton.alpha = 0
+ UIView.animate(withDuration: 0.3) {
+ self?.dimButton.alpha = 0
}
}
- } else {
- isControlsVisible.toggle()
- UIView.animate(withDuration: 0.2) {
- let a: CGFloat = self.isControlsVisible ? 1 : 0
- self.controlsContainerView.alpha = a
- self.skip85Button.alpha = a
-
- self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible
- self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible
-
- self.view.layoutIfNeeded()
- }
- self.updateSkipButtonsVisibility()
+
+ updateSkipButtonsVisibility()
+ return
}
+
+ isControlsVisible.toggle()
+ UIView.animate(withDuration: 0.2) {
+ let alpha: CGFloat = self.isControlsVisible ? 1.0 : 0.0
+ self.controlsContainerView.alpha = alpha
+ self.skip85Button.alpha = alpha
+ self.lockButton.alpha = alpha
+ self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible
+ self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible
+ self.view.layoutIfNeeded()
+ }
+ updateSkipButtonsVisibility()
}
@objc func seekBackwardLongPress(_ gesture: UILongPressGestureRecognizer) {
@@ -1610,6 +1641,61 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
}
+ @objc private func lockTapped() {
+ controlsLocked.toggle()
+
+ isControlsVisible = !controlsLocked
+ lockButtonTimer?.invalidate()
+
+ if controlsLocked {
+ UIView.animate(withDuration: 0.25) {
+ self.controlsContainerView.alpha = 0
+ self.dimButton.alpha = 0
+ for v in self.controlsToHide { v.alpha = 0 }
+ self.skipIntroButton.alpha = 0
+ self.skipOutroButton.alpha = 0
+ self.skip85Button.alpha = 0
+ self.lockButton.alpha = 0
+
+ self.subtitleBottomToSafeAreaConstraint?.isActive = true
+ self.subtitleBottomToSliderConstraint?.isActive = false
+
+ self.view.layoutIfNeeded()
+ }
+
+ lockButton.setImage(UIImage(systemName: "lock.fill"), for: .normal)
+
+ } else {
+ UIView.animate(withDuration: 0.25) {
+ self.controlsContainerView.alpha = 1
+ self.dimButton.alpha = 1
+ for v in self.controlsToHide { v.alpha = 1 }
+
+ self.subtitleBottomToSafeAreaConstraint?.isActive = false
+ self.subtitleBottomToSliderConstraint?.isActive = true
+
+ self.view.layoutIfNeeded()
+ }
+
+ lockButton.setImage(UIImage(systemName: "lock.open.fill"), for: .normal)
+ updateSkipButtonsVisibility()
+ }
+ }
+
+ @objc private func skipIntro() {
+ if let range = skipIntervals.op {
+ player.seek(to: range.end)
+ skipIntroButton.isHidden = true
+ }
+ }
+
+ @objc private func skipOutro() {
+ if let range = skipIntervals.ed {
+ player.seek(to: range.end)
+ skipOutroButton.isHidden = true
+ }
+ }
+
@objc func dismissTapped() {
dismiss(animated: true, completion: nil)
}
@@ -1636,22 +1722,26 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
@objc private func dimTapped() {
isDimmed.toggle()
+ isControlsVisible = !isDimmed
dimButtonTimer?.invalidate()
UIView.animate(withDuration: 0.25) {
self.blackCoverView.alpha = self.isDimmed ? 1.0 : 0.4
+ // fade all controls (and lock button) in or out
+ for v in self.controlsToHide { v.alpha = self.isDimmed ? 0 : 1 }
+ self.dimButton.alpha = self.isDimmed ? 0 : 1
+ self.lockButton.alpha = self.isDimmed ? 0 : 1
+
+ // switch subtitle constraints just like toggleControls()
+ self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible
+ self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible
+
+ self.view.layoutIfNeeded()
}
- UIView.animate(withDuration: 0.25) {
- for view in self.controlsToHide {
- view.alpha = self.isDimmed ? 0 : 1
- }
- self.dimButton.alpha = self.isDimmed ? 0 : 1
- }
-
+ // slide the dim-icon over
dimButtonToSlider.isActive = !isDimmed
- dimButtonToRight.isActive = isDimmed
- UIView.animate(withDuration: 0.25) { self.view.layoutIfNeeded() }
+ dimButtonToRight.isActive = isDimmed
}
func speedChangerMenu() -> UIMenu {
diff --git a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift
index fb49ef0..005673f 100644
--- a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift
+++ b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift
@@ -131,7 +131,7 @@ struct ModuleAdditionSettingsView: View {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Cancel")
- .foregroundColor(.accentColor)
+ .foregroundColor((Color.accentColor))
.padding(.top, 10)
}
}
diff --git a/Sora/Utils/Modules/ModuleManager.swift b/Sora/Utils/Modules/ModuleManager.swift
index e8ae65c..137fe82 100644
--- a/Sora/Utils/Modules/ModuleManager.swift
+++ b/Sora/Utils/Modules/ModuleManager.swift
@@ -7,8 +7,10 @@
import Foundation
+@MainActor
class ModuleManager: ObservableObject {
@Published var modules: [ScrapingModule] = []
+ @Published var selectedModuleChanged = false
private let fileManager = FileManager.default
private let modulesFileName = "modules.json"
@@ -180,6 +182,7 @@ class ModuleManager: ObservableObject {
DispatchQueue.main.async {
self.modules.append(module)
self.saveModules()
+ self.selectedModuleChanged = true
Logger.shared.log("Added module: \(module.metadata.sourceName)")
}
@@ -200,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 {
diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift
index d98a28a..0d5584a 100644
--- a/Sora/Views/DownloadView.swift
+++ b/Sora/Views/DownloadView.swift
@@ -1,47 +1,793 @@
//
// DownloadView.swift
-// Sulfur
+// Sora
//
-// Created by Francesco on 29/04/25.
+// Created by doomsboygaming on 5/22/25
//
import SwiftUI
import AVKit
+import Kingfisher
struct DownloadView: View {
- @StateObject private var viewModel = DownloadManager()
- @State private var hlsURL = "https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8"
+ @EnvironmentObject var jsController: JSController
+ @State private var searchText = ""
+ @State private var selectedTab = 0
+ @State private var sortOption: SortOption = .newest
+ @State private var showDeleteAlert = false
+ @State private var assetToDelete: DownloadedAsset?
+
+ enum SortOption: String, CaseIterable, Identifiable {
+ case newest = "Newest"
+ case oldest = "Oldest"
+ case title = "Title"
+
+ var id: String { self.rawValue }
+ }
var body: some View {
NavigationView {
- VStack {
- TextField("Enter HLS URL", text: $hlsURL)
- .textFieldStyle(RoundedBorderTextFieldStyle())
- .padding()
-
- Button("Download Stream") {
- viewModel.downloadAsset(from: URL(string: hlsURL)!)
+ VStack(spacing: 0) {
+ Picker("Download Status", selection: $selectedTab) {
+ Text("Active").tag(0)
+ Text("Downloaded").tag(1)
}
- .padding()
+ .pickerStyle(SegmentedPickerStyle())
+ .padding(.horizontal)
+ .padding(.top, 8)
- List(viewModel.activeDownloads, id: \.0) { (url, progress) in
- VStack(alignment: .leading) {
- Text(url.absoluteString)
- ProgressView(value: progress)
- .progressViewStyle(LinearProgressViewStyle())
- }
+ if selectedTab == 0 {
+ activeDownloadsView
+ } else {
+ downloadedContentView
}
-
- NavigationLink("Play Offline Content") {
- if let url = viewModel.localPlaybackURL {
- VideoPlayer(player: AVPlayer(url: url))
- } else {
- Text("No offline content available")
- }
- }
- .padding()
}
- .navigationTitle("HLS Downloader")
+ .navigationTitle("Downloads")
+ .toolbar {
+ if selectedTab == 1 && !jsController.savedAssets.isEmpty {
+ Menu {
+ Button("Sort by Newest") { sortOption = .newest }
+ Button("Sort by Oldest") { sortOption = .oldest }
+ Button("Sort by Title") { sortOption = .title }
+ } label: {
+ Image(systemName: "arrow.up.arrow.down")
+ }
+ }
+ }
+ .searchable(text: $searchText, prompt: "Search downloads")
+ .alert("Delete Download", isPresented: $showDeleteAlert) {
+ Button("Delete", role: .destructive) {
+ if let asset = assetToDelete {
+ jsController.deleteAsset(asset)
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ if let asset = assetToDelete {
+ Text("Are you sure you want to delete '\(asset.episodeDisplayName)'?")
+ }
+ }
+ }
+ }
+
+ private var activeDownloadsView: some View {
+ Group {
+ if jsController.activeDownloads.isEmpty && jsController.downloadQueue.isEmpty {
+ emptyActiveDownloadsView
+ } else {
+ ScrollView {
+ LazyVStack(spacing: 12) {
+ ForEach(jsController.downloadQueue) { download in
+ ActiveDownloadCard(download: download)
+ .padding(.horizontal)
+ }
+
+ ForEach(jsController.activeDownloads) { download in
+ ActiveDownloadCard(download: download)
+ .padding(.horizontal)
+ }
+ }
+ .padding(.vertical)
+ }
+ }
+ }
+ }
+
+ private var downloadedContentView: some View {
+ Group {
+ if filteredAndSortedAssets.isEmpty {
+ emptyDownloadsView
+ } else {
+ ScrollView {
+ LazyVStack(spacing: 12) {
+ ForEach(groupedAssets, id: \.title) { group in
+ DownloadGroupCard(
+ group: group,
+ onDelete: { asset in
+ assetToDelete = asset
+ showDeleteAlert = true
+ },
+ onPlay: playAsset
+ )
+ .padding(.horizontal)
+ }
+ }
+ .padding(.vertical)
+ }
+ }
+ }
+ }
+
+ private var emptyActiveDownloadsView: some View {
+ VStack {
+ Image(systemName: "arrow.down.circle")
+ .font(.system(size: 60))
+ .foregroundColor(.gray)
+ .padding()
+
+ Text("No Active Downloads")
+ .font(.title2)
+ .foregroundColor(.gray)
+
+ Text("Download episodes from the episode list")
+ .font(.subheadline)
+ .foregroundColor(.gray)
+ .multilineTextAlignment(.center)
+ .padding()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private var emptyDownloadsView: some View {
+ VStack {
+ Image(systemName: "arrow.down.circle")
+ .font(.system(size: 60))
+ .foregroundColor(.gray)
+ .padding()
+
+ Text("No Downloads")
+ .font(.title2)
+ .foregroundColor(.gray)
+
+ Text("Your downloaded assets will appear here")
+ .font(.subheadline)
+ .foregroundColor(.gray)
+ .multilineTextAlignment(.center)
+ .padding()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private var filteredAndSortedAssets: [DownloadedAsset] {
+ let filtered = searchText.isEmpty
+ ? jsController.savedAssets
+ : jsController.savedAssets.filter { asset in
+ asset.name.localizedCaseInsensitiveContains(searchText) ||
+ (asset.metadata?.showTitle?.localizedCaseInsensitiveContains(searchText) ?? false)
+ }
+
+ switch sortOption {
+ case .newest:
+ return filtered.sorted { $0.downloadDate > $1.downloadDate }
+ case .oldest:
+ return filtered.sorted { $0.downloadDate < $1.downloadDate }
+ case .title:
+ return filtered.sorted { $0.name < $1.name }
+ }
+ }
+
+ private var groupedAssets: [SimpleDownloadGroup] {
+ let grouped = Dictionary(grouping: filteredAndSortedAssets) { asset in
+ asset.metadata?.showTitle ?? asset.name
+ }
+
+ return grouped.map { title, assets in
+ SimpleDownloadGroup(
+ title: title,
+ assets: assets,
+ posterURL: assets.first?.metadata?.posterURL
+ )
+ }.sorted { $0.title < $1.title }
+ }
+
+ private func playAsset(_ asset: DownloadedAsset) {
+ guard jsController.verifyAssetFileExists(asset) else { return }
+
+ let streamType = asset.localURL.pathExtension.lowercased() == "mp4" ? "mp4" : "hls"
+
+ let dummyMetadata = ModuleMetadata(
+ sourceName: "",
+ author: ModuleMetadata.Author(name: "", icon: ""),
+ iconUrl: "",
+ version: "",
+ language: "",
+ baseUrl: "",
+ streamType: streamType,
+ quality: "",
+ searchBaseUrl: "",
+ scriptUrl: "",
+ asyncJS: nil,
+ streamAsyncJS: nil,
+ softsub: nil,
+ multiStream: nil,
+ multiSubs: nil,
+ type: nil
+ )
+
+ let dummyModule = ScrapingModule(
+ metadata: dummyMetadata,
+ localPath: "",
+ metadataUrl: ""
+ )
+
+ if streamType == "mp4" {
+ let playerItem = AVPlayerItem(url: asset.localURL)
+ let player = AVPlayer(playerItem: playerItem)
+ let playerController = AVPlayerViewController()
+ playerController.player = player
+
+ if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let rootViewController = windowScene.windows.first?.rootViewController {
+ rootViewController.present(playerController, animated: true) {
+ player.play()
+ }
+ }
+ } else {
+ let customPlayer = CustomMediaPlayerViewController(
+ module: dummyModule,
+ urlString: asset.localURL.absoluteString,
+ fullUrl: asset.originalURL.absoluteString,
+ title: asset.name,
+ episodeNumber: asset.metadata?.episode ?? 0,
+ onWatchNext: {},
+ subtitlesURL: asset.localSubtitleURL?.absoluteString,
+ aniListID: 0,
+ episodeImageUrl: asset.metadata?.posterURL?.absoluteString ?? "",
+ headers: nil
+ )
+
+ customPlayer.modalPresentationStyle = UIModalPresentationStyle.fullScreen
+
+ if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let rootViewController = windowScene.windows.first?.rootViewController {
+ rootViewController.present(customPlayer, animated: true)
+ }
}
}
}
+
+struct SimpleDownloadGroup {
+ let title: String
+ let assets: [DownloadedAsset]
+ let posterURL: URL?
+
+ var assetCount: Int { assets.count }
+ var totalFileSize: Int64 {
+ assets.reduce(0) { $0 + $1.fileSize }
+ }
+}
+
+struct ActiveDownloadCard: View {
+ let download: JSActiveDownload
+ @State private var currentProgress: Double
+ @State private var taskState: URLSessionTask.State
+
+ init(download: JSActiveDownload) {
+ self.download = download
+ _currentProgress = State(initialValue: download.progress)
+ _taskState = State(initialValue: download.task?.state ?? .suspended)
+ }
+
+ var body: some View {
+ HStack(spacing: 12) {
+ if let imageURL = download.imageURL {
+ KFImage(imageURL)
+ .placeholder {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ }
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 60, height: 60)
+ .cornerRadius(8)
+ } else {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ .frame(width: 60, height: 60)
+ .cornerRadius(8)
+ }
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text(download.title ?? download.originalURL.lastPathComponent)
+ .font(.headline)
+ .lineLimit(1)
+
+ VStack(alignment: .leading, spacing: 4) {
+ if download.queueStatus == .queued {
+ ProgressView()
+ .progressViewStyle(LinearProgressViewStyle())
+ .tint(.orange)
+ } else {
+ ProgressView(value: currentProgress)
+ .progressViewStyle(LinearProgressViewStyle())
+ .tint(currentProgress >= 1.0 ? .green : .blue)
+ }
+
+ HStack {
+ if download.queueStatus == .queued {
+ Text("Queued")
+ .font(.caption)
+ .foregroundColor(.orange)
+ } else {
+ Text("\(Int(currentProgress * 100))%")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ if taskState == .running {
+ Text("Downloading")
+ .font(.caption)
+ .foregroundColor(.blue)
+ } else if taskState == .suspended {
+ Text("Paused")
+ .font(.caption)
+ .foregroundColor(.orange)
+ }
+ }
+ }
+ }
+
+ Spacer()
+
+ HStack(spacing: 8) {
+ if download.queueStatus == .queued {
+ Button(action: cancelDownload) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.red)
+ .font(.title2)
+ }
+ } else {
+ Button(action: toggleDownload) {
+ Image(systemName: taskState == .running ? "pause.circle.fill" : "play.circle.fill")
+ .foregroundColor(taskState == .running ? .orange : .blue)
+ .font(.title2)
+ }
+
+ Button(action: cancelDownload) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.red)
+ .font(.title2)
+ }
+ }
+ }
+ }
+ .padding()
+ .background(Color(UIColor.secondarySystemBackground))
+ .cornerRadius(12)
+ .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in
+ updateProgress()
+ }
+ }
+
+ private func updateProgress() {
+ if let currentDownload = JSController.shared.activeDownloads.first(where: { $0.id == download.id }) {
+ withAnimation(.easeInOut(duration: 0.1)) {
+ currentProgress = currentDownload.progress
+ }
+ if let task = currentDownload.task {
+ taskState = task.state
+ }
+ }
+ }
+
+ private func toggleDownload() {
+ if taskState == .running {
+ download.task?.suspend()
+ taskState = .suspended
+ } else if taskState == .suspended {
+ download.task?.resume()
+ taskState = .running
+ }
+ }
+
+ private func cancelDownload() {
+ if download.queueStatus == .queued {
+ JSController.shared.cancelQueuedDownload(download.id)
+ } else {
+ JSController.shared.cancelActiveDownload(download.id)
+ }
+ }
+}
+
+struct DownloadGroupCard: View {
+ let group: SimpleDownloadGroup
+ let onDelete: (DownloadedAsset) -> Void
+ let onPlay: (DownloadedAsset) -> Void
+
+ var body: some View {
+ NavigationLink(destination: ShowEpisodesView(group: group, onDelete: onDelete, onPlay: onPlay)) {
+ HStack(spacing: 12) {
+ if let posterURL = group.posterURL {
+ KFImage(posterURL)
+ .placeholder {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ }
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 50, height: 75)
+ .cornerRadius(6)
+ } else {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ .frame(width: 50, height: 75)
+ .cornerRadius(6)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(group.title)
+ .font(.headline)
+ .lineLimit(2)
+
+ Text("\(group.assetCount) Episodes")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+
+ Text(formatFileSize(group.totalFileSize))
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .foregroundColor(.gray)
+ .font(.caption)
+ }
+ }
+ .buttonStyle(PlainButtonStyle())
+ }
+
+ private func formatFileSize(_ size: Int64) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.allowedUnits = [.useKB, .useMB, .useGB]
+ formatter.countStyle = .file
+ return formatter.string(fromByteCount: size)
+ }
+}
+
+struct EpisodeRow: View {
+ let asset: DownloadedAsset
+ let onDelete: () -> Void
+ let onPlay: () -> Void
+
+ var body: some View {
+ HStack(spacing: 12) {
+ if let backdropURL = asset.metadata?.backdropURL {
+ KFImage(backdropURL)
+ .placeholder {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ }
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 60, height: 40)
+ .cornerRadius(6)
+ } else {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ .frame(width: 60, height: 40)
+ .cornerRadius(6)
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(asset.episodeDisplayName)
+ .font(.subheadline)
+ .lineLimit(1)
+
+ HStack(spacing: 4) {
+ Text(asset.downloadDate.formatted(date: .abbreviated, time: .omitted))
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ if asset.localSubtitleURL != nil {
+ Image(systemName: "captions.bubble")
+ .foregroundColor(.blue)
+ .font(.caption)
+ }
+
+ if !asset.fileExists {
+ Image(systemName: "exclamationmark.triangle")
+ .foregroundColor(.orange)
+ .font(.caption)
+ }
+ }
+ }
+
+ Spacer()
+
+ Button(action: onPlay) {
+ Image(systemName: "play.circle.fill")
+ .foregroundColor(asset.fileExists ? .blue : .gray)
+ .font(.title3)
+ }
+ .disabled(!asset.fileExists)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(Color(UIColor.tertiarySystemBackground))
+ .cornerRadius(8)
+ .contextMenu {
+ Button(action: onPlay) {
+ Label("Play", systemImage: "play.fill")
+ }
+ .disabled(!asset.fileExists)
+
+ Button(role: .destructive, action: onDelete) {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ }
+}
+
+struct ShowEpisodesView: View {
+ let group: SimpleDownloadGroup
+ let onDelete: (DownloadedAsset) -> Void
+ let onPlay: (DownloadedAsset) -> Void
+ @State private var showDeleteAlert = false
+ @State private var showDeleteAllAlert = false
+ @State private var assetToDelete: DownloadedAsset?
+ @EnvironmentObject var jsController: JSController
+
+ @State private var episodeSortOption: EpisodeSortOption = .downloadDate
+
+ enum EpisodeSortOption: String, CaseIterable, Identifiable {
+ case downloadDate = "Download Date"
+ case episodeOrder = "Episode Order"
+
+ var id: String { self.rawValue }
+
+ var systemImage: String {
+ switch self {
+ case .downloadDate:
+ return "clock.arrow.circlepath"
+ case .episodeOrder:
+ return "list.number"
+ }
+ }
+ }
+
+ private var sortedEpisodes: [DownloadedAsset] {
+ switch episodeSortOption {
+ case .downloadDate:
+ return group.assets.sorted { $0.downloadDate > $1.downloadDate }
+ case .episodeOrder:
+ return group.assets.sorted { $0.episodeOrderPriority < $1.episodeOrderPriority }
+ }
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 20) {
+ HStack(alignment: .top, spacing: 16) {
+ if let posterURL = group.posterURL {
+ KFImage(posterURL)
+ .placeholder {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ }
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 120, height: 180)
+ .cornerRadius(10)
+ } else {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ .frame(width: 120, height: 180)
+ .cornerRadius(10)
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(group.title)
+ .font(.title2)
+ .fontWeight(.bold)
+ .lineLimit(3)
+
+ Text("\(group.assetCount) Episodes")
+ .font(.headline)
+ .foregroundColor(.secondary)
+
+ Text(formatFileSize(group.totalFileSize))
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.horizontal)
+
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ Text("Episodes")
+ .font(.title3)
+ .fontWeight(.bold)
+
+ Spacer()
+
+ Menu {
+ ForEach(EpisodeSortOption.allCases) { option in
+ Button(action: {
+ episodeSortOption = option
+ }) {
+ HStack {
+ Text(option.rawValue)
+ if episodeSortOption == option {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ }
+ } label: {
+ HStack(spacing: 4) {
+ Image(systemName: episodeSortOption.systemImage)
+ Text("Sort")
+ }
+ .font(.subheadline)
+ .foregroundColor(.blue)
+ }
+
+ Button(action: {
+ showDeleteAllAlert = true
+ }) {
+ Label("Delete All", systemImage: "trash")
+ .foregroundColor(.red)
+ .font(.subheadline)
+ }
+ }
+ .padding(.horizontal)
+
+ if group.assets.isEmpty {
+ Text("No episodes available")
+ .foregroundColor(.gray)
+ .italic()
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .center)
+ } else {
+ LazyVStack(spacing: 8) {
+ ForEach(sortedEpisodes) { asset in
+ DetailedEpisodeRow(asset: asset)
+ .padding(.horizontal)
+ .background(Color(UIColor.secondarySystemBackground))
+ .cornerRadius(10)
+ .padding(.horizontal)
+ .contextMenu {
+ Button(action: { onPlay(asset) }) {
+ Label("Play", systemImage: "play.fill")
+ }
+ .disabled(!asset.fileExists)
+
+ Button(role: .destructive, action: {
+ assetToDelete = asset
+ showDeleteAlert = true
+ }) {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ .onTapGesture {
+ onPlay(asset)
+ }
+ }
+ }
+ }
+ }
+ }
+ .padding(.vertical)
+ }
+ .navigationTitle("Episodes")
+ .navigationBarTitleDisplayMode(.inline)
+ .alert("Delete Episode", isPresented: $showDeleteAlert) {
+ Button("Cancel", role: .cancel) { }
+ Button("Delete", role: .destructive) {
+ if let asset = assetToDelete {
+ onDelete(asset)
+ }
+ }
+ } message: {
+ if let asset = assetToDelete {
+ Text("Are you sure you want to delete '\(asset.episodeDisplayName)'?")
+ }
+ }
+ .alert("Delete All Episodes", isPresented: $showDeleteAllAlert) {
+ Button("Cancel", role: .cancel) { }
+ Button("Delete All", role: .destructive) {
+ deleteAllAssets()
+ }
+ } message: {
+ Text("Are you sure you want to delete all \(group.assetCount) episodes in '\(group.title)'?")
+ }
+ }
+
+ private func formatFileSize(_ size: Int64) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.allowedUnits = [.useKB, .useMB, .useGB]
+ formatter.countStyle = .file
+ return formatter.string(fromByteCount: size)
+ }
+
+ private func deleteAllAssets() {
+ for asset in group.assets {
+ jsController.deleteAsset(asset)
+ }
+ }
+}
+
+struct DetailedEpisodeRow: View {
+ let asset: DownloadedAsset
+
+ var body: some View {
+ HStack(spacing: 12) {
+ if let backdropURL = asset.metadata?.backdropURL ?? asset.metadata?.posterURL {
+ KFImage(backdropURL)
+ .placeholder {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ }
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 100, height: 60)
+ .cornerRadius(8)
+ } else {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ .frame(width: 100, height: 60)
+ .cornerRadius(8)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(asset.episodeDisplayName)
+ .font(.headline)
+ .lineLimit(2)
+
+ Text(formatFileSize(asset.fileSize))
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ HStack(spacing: 6) {
+ Text(asset.downloadDate.formatted(date: .abbreviated, time: .shortened))
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ if asset.localSubtitleURL != nil {
+ Image(systemName: "captions.bubble")
+ .foregroundColor(.blue)
+ .font(.caption)
+ }
+
+ if !asset.fileExists {
+ Image(systemName: "exclamationmark.triangle")
+ .foregroundColor(.orange)
+ .font(.caption)
+ }
+ }
+ }
+
+ Spacer()
+
+ Image(systemName: "play.circle.fill")
+ .foregroundColor(asset.fileExists ? .blue : .gray)
+ .font(.title2)
+ }
+ .padding(.vertical, 8)
+ }
+
+ private func formatFileSize(_ size: Int64) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.allowedUnits = [.useKB, .useMB, .useGB]
+ formatter.countStyle = .file
+ return formatter.string(fromByteCount: size)
+ }
+}
+
diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift
index 94ea459..32cae84 100644
--- a/Sora/Views/LibraryView/LibraryView.swift
+++ b/Sora/Views/LibraryView/LibraryView.swift
@@ -102,9 +102,9 @@ struct LibraryView: View {
ForEach(libraryManager.bookmarks) { item in
if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) {
Button(action: {
- selectedBookmark = item
- isDetailActive = true
- }) {
+ selectedBookmark = item
+ isDetailActive = true
+ }) {
VStack(alignment: .leading) {
ZStack {
KFImage(URL(string: item.imageUrl))
@@ -148,21 +148,21 @@ struct LibraryView: View {
}
.padding(.horizontal, 20)
NavigationLink(
- destination: Group {
- if let bookmark = selectedBookmark,
- let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
- MediaInfoView(title: bookmark.title,
- imageUrl: bookmark.imageUrl,
- href: bookmark.href,
- module: module)
- } else {
- Text("No Data Available")
- }
- },
- isActive: $isDetailActive
- ) {
- EmptyView()
- }
+ destination: Group {
+ if let bookmark = selectedBookmark,
+ let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
+ MediaInfoView(title: bookmark.title,
+ imageUrl: bookmark.imageUrl,
+ href: bookmark.href,
+ module: module)
+ } else {
+ Text("No Data Available")
+ }
+ },
+ isActive: $isDetailActive
+ ) {
+ EmptyView()
+ }
.onAppear {
updateOrientation()
}
@@ -360,7 +360,6 @@ struct ContinueWatchingCell: View {
if totalTime > 0 {
let ratio = lastPlayedTime / totalTime
- // Clamp ratio between 0 and 1:
currentProgress = max(0, min(ratio, 1))
} else {
currentProgress = max(0, min(item.progress, 1))
diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift
index bf41b8b..677e9fe 100644
--- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift
+++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift
@@ -7,6 +7,7 @@
import SwiftUI
import Kingfisher
+import AVFoundation
struct EpisodeLink: Identifiable {
let id = UUID()
@@ -20,68 +21,285 @@ struct EpisodeCell: View {
let episodeID: Int
let progress: Double
let itemID: Int
+ var totalEpisodes: Int?
+ var defaultBannerImage: String
+ var module: ScrapingModule
+ var parentTitle: String
+ var showPosterURL: String?
- let onTap: (String) -> Void
- let onMarkAllPrevious: () -> Void
+ 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 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
+
+ @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"
+ 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, 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
+
+ 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)
+ 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 {
+ updateProgress()
+ updateDownloadStatus()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ fetchEpisodeDetails()
+ }
+
+ if let totalEpisodes = totalEpisodes, episodeID + 1 < totalEpisodes {
+ let nextEpisodeStart = episodeID + 1
+ let count = min(5, totalEpisodes - episodeID - 1)
+ }
+ }
+ .onDisappear {
+ activeDownloadTask = nil
+ }
+ .onChange(of: progress) { _ in
+ updateProgress()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in
+ 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)")?")
+ }
+ }
+
+ private var episodeThumbnail: some View {
+ ZStack {
+ if let url = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl) {
+ KFImage.optimizedEpisodeThumbnail(url: url)
+ .setProcessor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56)))
+ .memoryCacheExpiration(.seconds(600))
+ .cacheOriginalImage()
+ .fade(duration: 0.1)
+ .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)
+ } 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)
+ .scaleEffect(1.1)
+ .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")
@@ -99,18 +317,200 @@ struct EpisodeCell: View {
Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill")
}
}
+
+ Button(action: downloadEpisode) {
+ Label("Download Episode", systemImage: "arrow.down.circle")
+ }
}
- .onAppear {
- updateProgress()
- fetchEpisodeDetails()
+ }
+
+ private func updateDownloadStatus() {
+ let newStatus = jsController.isEpisodeDownloadedOrInProgress(
+ showTitle: parentTitle,
+ episodeNumber: episodeID + 1
+ )
+
+ if downloadStatus != newStatus {
+ downloadStatus = newStatus
}
- .onChange(of: progress) { _ in
- updateProgress()
+ }
+
+ private func downloadEpisode() {
+ updateDownloadStatus()
+
+ if case .notDownloaded = downloadStatus, !isDownloading {
+ isDownloading = true
+ let downloadID = UUID()
+
+ DropManager.shared.downloadStarted(episodeNumber: episodeID + 1)
+
+ Task {
+ do {
+ let jsContent = try moduleManager.getModuleContent(module)
+ jsController.loadScript(jsContent)
+ tryNextDownloadMethod(methodIndex: 0, downloadID: downloadID, softsub: module.metadata.softsub == true)
+ } catch {
+ DropManager.shared.error("Failed to start download: \(error.localizedDescription)")
+ isDownloading = false
+ }
+ }
+ } else {
+ 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")
+ }
}
- .onTapGesture {
- let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl
- onTap(imageUrl)
+ }
+
+ private func tryNextDownloadMethod(methodIndex: Int, downloadID: UUID, softsub: Bool) {
+ if !isDownloading {
+ return
}
+
+ print("[Download] Trying download method #\(methodIndex+1) for Episode \(episodeID + 1)")
+
+ switch methodIndex {
+ case 0:
+ 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 {
+ tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
+ }
+
+ case 1:
+ 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 {
+ tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
+ }
+
+ case 2:
+ jsController.fetchStreamUrl(episodeUrl: episode, softsub: softsub, module: module) { result in
+ self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub)
+ }
+
+ default:
+ DropManager.shared.error("Failed to find a valid stream for download after trying all methods")
+ isDownloading = false
+ }
+ }
+
+ private func handleSequentialDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), downloadID: UUID, methodIndex: Int, softsub: Bool) {
+ if !isDownloading {
+ return
+ }
+
+ if let streams = result.streams, !streams.isEmpty, let url = URL(string: streams[0]) {
+ 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
+ }
+
+ print("[Download] Method #\(methodIndex+1) returned valid stream URL: \(streams[0])")
+
+ 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)")
+
+ 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 {
+ print("[Download] Method #\(methodIndex+1) did not return valid streams, trying next method")
+ tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
+ }
+ }
+
+ private func startActualDownload(url: URL, streamUrl: String, downloadID: UUID, subtitleURL: URL? = nil) {
+ var headers: [String: String] = [:]
+
+ if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") {
+ print("Using module baseUrl: \(module.metadata.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 {
+ 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 {
+ DropManager.shared.error("Invalid stream URL - missing scheme or host")
+ isDownloading = false
+ return
+ }
+ }
+
+ print("Download headers: \(headers)")
+
+ let episodeThumbnailURL = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl)
+ let showPosterImageURL = URL(string: showPosterURL ?? defaultBannerImage)
+
+ let episodeName = episodeTitle.isEmpty ? "Episode \(episodeID + 1)" : episodeTitle
+ let fullEpisodeTitle = "Episode \(episodeID + 1): \(episodeName)"
+
+ let animeTitle = parentTitle.isEmpty ? "Unknown Anime" : parentTitle
+
+ jsController.downloadWithStreamTypeSupport(
+ url: url,
+ headers: headers,
+ title: fullEpisodeTitle,
+ imageURL: episodeThumbnailURL,
+ module: module,
+ isEpisode: true,
+ showTitle: animeTitle,
+ season: 1,
+ episode: episodeID + 1,
+ subtitleURL: subtitleURL,
+ showPosterURL: showPosterImageURL,
+ completionHandler: { success, message in
+ if success {
+ 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)
+ }
+ self.isDownloading = false
+ }
+ )
}
private func markAsWatched() {
@@ -141,50 +541,159 @@ struct EpisodeCell: View {
}
private func fetchEpisodeDetails() {
+ if MetadataCacheManager.shared.isCachingEnabled &&
+ (UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil ||
+ UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata")) {
+
+ let cacheKey = "anilist_\(itemID)_episode_\(episodeID + 1)"
+
+ if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
+ let metadata = EpisodeMetadata.fromData(cachedData) {
+
+ DispatchQueue.main.async {
+ self.episodeTitle = metadata.title["en"] ?? ""
+ self.episodeImageUrl = metadata.imageUrl
+ self.isLoading = false
+ self.loadedFromCache = true
+ }
+ return
+ }
+ }
+
fetchAnimeEpisodeDetails()
}
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
+ 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
+ }
+
+ guard let episodes = json["episodes"] as? [String: Any] else {
+ Logger.shared.log("Missing 'episodes' object in response", type: "Error")
+ DispatchQueue.main.async {
+ self.isLoading = false
+ self.retryAttempts = 0
+ }
+ return
+ }
+
+ let episodeKey = "\(episodeID + 1)"
+ guard let episodeDetails = episodes[episodeKey] as? [String: Any] else {
+ Logger.shared.log("Episode \(episodeKey) not found in response", type: "Error")
+ DispatchQueue.main.async {
+ self.isLoading = false
+ self.retryAttempts = 0
+ }
+ return
+ }
+
+ var title: [String: String] = [:]
+ var image: String = ""
+ var missingFields: [String] = []
+
+ if let titleData = episodeDetails["title"] as? [String: String], !titleData.isEmpty {
+ title = titleData
+
+ 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")
+ }
+
+ if !missingFields.isEmpty {
+ Logger.shared.log("Episode \(episodeKey) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning")
+ }
+
+ if MetadataCacheManager.shared.isCachingEnabled && (!title.isEmpty || !image.isEmpty) {
+ let metadata = EpisodeMetadata(
+ title: title,
+ imageUrl: image,
+ anilistId: self.itemID,
+ episodeNumber: self.episodeID + 1
+ )
+
+ if let metadataData = metadata.toData() {
+ MetadataCacheManager.shared.storeMetadata(
+ metadataData,
+ forKey: metadata.cacheKey
+ )
+ }
+ }
DispatchQueue.main.async {
self.isLoading = false
+ self.retryAttempts = 0
+
if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil
|| UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") {
- self.episodeTitle = title["en"] ?? ""
- self.episodeImageUrl = image
+ self.episodeTitle = title["en"] ?? title.values.first ?? ""
+
+ if !image.isEmpty {
+ self.episodeImageUrl = image
+ }
}
}
} catch {
- DispatchQueue.main.async { self.isLoading = false }
+ Logger.shared.log("JSON parsing error: \(error.localizedDescription)", type: "Error")
+ DispatchQueue.main.async {
+ self.isLoading = false
+ self.retryAttempts = 0
+ }
}
}.resume()
}
+
+ private func handleFetchFailure(error: Error) {
+ Logger.shared.log("Episode details fetch error: \(error.localizedDescription)", type: "Error")
+
+ DispatchQueue.main.async {
+ if self.retryAttempts < self.maxRetryAttempts {
+ self.retryAttempts += 1
+
+ let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(self.retryAttempts - 1))
+
+ Logger.shared.log("Will retry episode details fetch in \(backoffDelay) seconds", type: "Debug")
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) {
+ self.fetchAnimeEpisodeDetails()
+ }
+ } else {
+ Logger.shared.log("Failed to fetch episode details after \(self.maxRetryAttempts) attempts", type: "Error")
+ self.isLoading = false
+ self.retryAttempts = 0
+ }
+ }
+ }
}
diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift
index 9d4b525..d23f437 100644
--- a/Sora/Views/MediaInfoView/MediaInfoView.swift
+++ b/Sora/Views/MediaInfoView/MediaInfoView.swift
@@ -45,7 +45,10 @@ struct MediaInfoView: View {
@AppStorage("externalPlayer") private var externalPlayer: String = "Default"
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
- @StateObject private var jsController = JSController()
+ @State private var isModuleSelectorPresented = false
+ @State private var isError = false
+
+ @StateObject private var jsController = JSController.shared
@EnvironmentObject var moduleManager: ModuleManager
@EnvironmentObject private var libraryManager: LibraryManager
@@ -58,375 +61,53 @@ struct MediaInfoView: View {
@State private var activeFetchID: UUID? = nil
@Environment(\.dismiss) private var dismiss
- @State private var orientationChanged: Bool = false
@State private var showLoadingAlert: Bool = false
+ @Environment(\.colorScheme) private var colorScheme
+ @Environment(\.verticalSizeClass) private var verticalSizeClass
+ @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system
+
+ @State private var isMultiSelectMode: Bool = false
+ @State private var selectedEpisodes: Set = []
+ @State private var showRangeInput: Bool = false
+ @State private var isBulkDownloading: Bool = false
+ @State private var bulkDownloadProgress: String = ""
+
private var isGroupedBySeasons: Bool {
return groupedEpisodes().count > 1
}
+ private var isCompactLayout: Bool {
+ return verticalSizeClass == .compact
+ }
+
+ private var useIconOnlyButtons: Bool {
+ if UIDevice.current.userInterfaceIdiom == .pad {
+ return false
+ }
+ return verticalSizeClass == .regular
+ }
+
+ private var multiselectButtonSpacing: CGFloat {
+ return isCompactLayout ? 16 : 12
+ }
+
+ private var multiselectPadding: CGFloat {
+ return isCompactLayout ? 20 : 16
+ }
+
var body: some View {
+ bodyContent
+ }
+
+ @ViewBuilder
+ private var bodyContent: some View {
Group {
if isLoading {
ProgressView()
.padding()
} else {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- HStack(alignment: .top, spacing: 10) {
- KFImage(URL(string: imageUrl))
- .placeholder {
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.gray.opacity(0.3))
- .frame(width: 150, height: 225)
- .shimmering()
- }
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: 150, height: 225)
- .clipped()
- .cornerRadius(10)
-
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
- .font(.system(size: 17))
- .fontWeight(.bold)
- .onLongPressGesture {
- UIPasteboard.general.string = title
- DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
- }
-
- if !aliases.isEmpty && aliases != title && aliases != "N/A" && aliases != "No Data" {
- Text(aliases)
- .font(.system(size: 13))
- .foregroundColor(.secondary)
- }
-
- Spacer()
-
- if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
- HStack(alignment: .center, spacing: 12) {
- HStack(spacing: 4) {
- Image(systemName: "calendar")
- .resizable()
- .frame(width: 15, height: 15)
- .foregroundColor(.secondary)
-
- Text(airdate)
- .font(.system(size: 12))
- .foregroundColor(.secondary)
- }
- .padding(4)
- }
- }
-
- HStack(alignment: .center, spacing: 12) {
- Button(action: {
- openSafariViewController(with: href)
- }) {
- HStack(spacing: 4) {
- Text(module.metadata.sourceName)
- .font(.system(size: 13))
- .foregroundColor(.primary)
-
- Image(systemName: "safari")
- .resizable()
- .frame(width: 20, height: 20)
- .foregroundColor(.primary)
- }
- .padding(4)
- .background(Capsule().fill(Color.accentColor.opacity(0.4)))
- }
-
- Menu {
- Button(action: {
- showCustomIDAlert()
- }) {
- Label("Set Custom AniList ID", systemImage: "number")
- }
-
- if let customID = customAniListID {
- Button(action: {
- customAniListID = nil
- itemID = nil
- fetchItemID(byTitle: cleanTitle(title)) { result in
- switch result {
- case .success(let id):
- itemID = id
- case .failure(let error):
- Logger.shared.log("Failed to fetch AniList ID: \(error)")
- }
- }
- }) {
- Label("Reset AniList ID", systemImage: "arrow.clockwise")
- }
- }
-
- if let id = itemID ?? customAniListID {
- Button(action: {
- if let url = URL(string: "https://anilist.co/anime/\(id)") {
- openSafariViewController(with: url.absoluteString)
- }
- }) {
- Label("Open in AniList", systemImage: "link")
- }
- }
-
- Divider()
-
- Button(action: {
- Logger.shared.log("Debug Info:\nTitle: \(title)\nHref: \(href)\nModule: \(module.metadata.sourceName)\nAniList ID: \(itemID ?? -1)\nCustom ID: \(customAniListID ?? -1)", type: "Debug")
- DropManager.shared.showDrop(title: "Debug Info Logged", subtitle: "", duration: 1.0, icon: UIImage(systemName: "terminal"))
- }) {
- Label("Log Debug Info", systemImage: "terminal")
- }
- } label: {
- Image(systemName: "ellipsis.circle")
- .resizable()
- .frame(width: 20, height: 20)
- .foregroundColor(.primary)
- }
- }
- }
- }
-
- if !synopsis.isEmpty {
- VStack(alignment: .leading, spacing: 2) {
- HStack(alignment: .center) {
- Text("Synopsis")
- .font(.system(size: 18))
- .fontWeight(.bold)
-
- Spacer()
-
- Button(action: {
- showFullSynopsis.toggle()
- }) {
- Text(showFullSynopsis ? "Less" : "More")
- .font(.system(size: 14))
- }
- }
-
- Text(synopsis)
- .lineLimit(showFullSynopsis ? nil : 4)
- .font(.system(size: 14))
- }
- }
-
- HStack {
- Button(action: {
- playFirstUnwatchedEpisode()
- }) {
- HStack {
- Image(systemName: "play.fill")
- .foregroundColor(.primary)
- Text(startWatchingText)
- .font(.headline)
- .foregroundColor(.primary)
- }
- .padding()
- .frame(maxWidth: .infinity)
- .background(Color.accentColor)
- .cornerRadius(10)
- }
- .disabled(isFetchingEpisode)
- .id(buttonRefreshTrigger)
-
- Button(action: {
- libraryManager.toggleBookmark(
- title: title,
- imageUrl: imageUrl,
- href: href,
- moduleId: module.id.uuidString,
- moduleName: module.metadata.sourceName
- )
- }) {
- Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
- .resizable()
- .frame(width: 20, height: 27)
- .foregroundColor(Color.accentColor)
- }
- }
-
- if !episodeLinks.isEmpty {
- VStack(alignment: .leading, spacing: 10) {
- HStack {
- Text("Episodes")
- .font(.system(size: 18))
- .fontWeight(.bold)
-
- Spacer()
-
- if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize {
- Menu {
- ForEach(generateRanges(), id: \.self) { range in
- Button(action: { selectedRange = range }) {
- Text("\(range.lowerBound + 1)-\(range.upperBound)")
- }
- }
- } label: {
- Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)")
- .font(.system(size: 14))
- .foregroundColor(.accentColor)
- }
- } else if isGroupedBySeasons {
- let seasons = groupedEpisodes()
- if seasons.count > 1 {
- Menu {
- ForEach(0.. 0 ? lastPlayedTime / totalTime : 0
-
- EpisodeCell(
- episodeIndex: selectedSeason,
- episode: ep.href,
- episodeID: ep.number - 1,
- progress: progress,
- itemID: itemID ?? 0,
- onTap: { imageUrl in
- if !isFetchingEpisode {
- selectedEpisodeNumber = ep.number
- selectedEpisodeImage = imageUrl
- fetchStream(href: ep.href)
- AnalyticsManager.shared.sendEvent(
- event: "watch",
- additionalData: ["title": title, "episode": ep.number]
- )
- }
- },
- onMarkAllPrevious: {
- let userDefaults = UserDefaults.standard
- var updates = [String: Double]()
-
- for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
- let href = ep2.href
- updates["lastPlayedTime_\(href)"] = 99999999.0
- updates["totalTime_\(href)"] = 99999999.0
- }
-
- for (key, value) in updates {
- userDefaults.set(value, forKey: key)
- }
-
- userDefaults.synchronize()
-
- refreshTrigger.toggle()
- Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
- }
- )
- .id(refreshTrigger)
- .disabled(isFetchingEpisode)
- }
- } else {
- Text("No episodes available")
- }
- } else {
- ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in
- let ep = episodeLinks[i]
- let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
- let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
- let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
-
- EpisodeCell(
- episodeIndex: i,
- episode: ep.href,
- episodeID: ep.number - 1,
- progress: progress,
- itemID: itemID ?? 0,
- onTap: { imageUrl in
- if !isFetchingEpisode {
- selectedEpisodeNumber = ep.number
- selectedEpisodeImage = imageUrl
- fetchStream(href: ep.href)
- AnalyticsManager.shared.sendEvent(
- event: "watch",
- additionalData: ["title": title, "episode": ep.number]
- )
- }
- },
- onMarkAllPrevious: {
- let userDefaults = UserDefaults.standard
- var updates = [String: Double]()
-
- for idx in 0.. episodeChunkSize {
+ Menu {
+ ForEach(generateRanges(), id: \.self) { range in
+ Button(action: { selectedRange = range }) {
+ Text("\(range.lowerBound + 1)-\(range.upperBound)")
+ }
+ }
+ } label: {
+ Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)")
+ .font(.system(size: 14))
+ .foregroundColor(.accentColor)
+ }
+ } else if isGroupedBySeasons {
+ let seasons = groupedEpisodes()
+ if seasons.count > 1 {
+ Menu {
+ ForEach(0.. 0 ? lastPlayedTime / totalTime : 0
+
+ let defaultBannerImageValue = getBannerImageBasedOnAppearance()
+
+ EpisodeCell(
+ episodeIndex: selectedSeason,
+ episode: ep.href,
+ episodeID: ep.number - 1,
+ progress: progress,
+ itemID: itemID ?? 0,
+ totalEpisodes: episodeLinks.count,
+ defaultBannerImage: defaultBannerImageValue,
+ module: module,
+ parentTitle: title,
+ showPosterURL: imageUrl,
+ isMultiSelectMode: isMultiSelectMode,
+ isSelected: selectedEpisodes.contains(ep.number),
+ onSelectionChanged: { isSelected in
+ if isSelected {
+ selectedEpisodes.insert(ep.number)
+ } else {
+ selectedEpisodes.remove(ep.number)
+ }
+ },
+ onTap: { imageUrl in
+ episodeTapAction(ep: ep, imageUrl: imageUrl)
+ },
+ onMarkAllPrevious: {
+ markAllPreviousEpisodesAsWatched(ep: ep, inSeason: true)
+ }
+ )
+ .disabled(isFetchingEpisode)
+ }
+ } else {
+ Text("No episodes available")
+ }
+ }
+
+ private func getBannerImageBasedOnAppearance() -> 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"
+ }
+
+ private func episodeTapAction(ep: EpisodeLink, imageUrl: String) {
+ if !isFetchingEpisode {
+ selectedEpisodeNumber = ep.number
+ selectedEpisodeImage = imageUrl
+ fetchStream(href: ep.href)
+ AnalyticsManager.shared.sendEvent(
+ event: "watch",
+ additionalData: ["title": title, "episode": ep.number]
+ )
+ }
+ }
+
+ private func markAllPreviousEpisodesAsWatched(ep: EpisodeLink, inSeason: Bool) {
+ let userDefaults = UserDefaults.standard
+ var updates = [String: Double]()
+
+ if inSeason {
+ let seasons = groupedEpisodes()
+ for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
+ let href = ep2.href
+ updates["lastPlayedTime_\(href)"] = 99999999.0
+ updates["totalTime_\(href)"] = 99999999.0
+ }
+
+ for (key, value) in updates {
+ userDefaults.set(value, forKey: key)
+ }
+
+ userDefaults.synchronize()
+ Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
+ } else {
+ // Handle non-season case if needed
+ }
+ }
+
+ @ViewBuilder
+ private var flatEpisodeList: some View {
+ ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in
+ let ep = episodeLinks[i]
+ let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
+ let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
+ let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
+
+ EpisodeCell(
+ episodeIndex: i,
+ episode: ep.href,
+ episodeID: ep.number - 1,
+ progress: progress,
+ itemID: itemID ?? 0,
+ module: module,
+ parentTitle: title,
+ showPosterURL: imageUrl,
+ isMultiSelectMode: isMultiSelectMode,
+ isSelected: selectedEpisodes.contains(ep.number),
+ onSelectionChanged: { isSelected in
+ if isSelected {
+ selectedEpisodes.insert(ep.number)
+ } else {
+ selectedEpisodes.remove(ep.number)
+ }
+ },
+ onTap: { imageUrl in
+ episodeTapAction(ep: ep, imageUrl: imageUrl)
+ },
+ onMarkAllPrevious: {
+ markAllPreviousEpisodesInFlatList(ep: ep, index: i)
+ }
+ )
+ .disabled(isFetchingEpisode)
+ }
+ }
+
+ private func markAllPreviousEpisodesInFlatList(ep: EpisodeLink, index: Int) {
+ let userDefaults = UserDefaults.standard
+ var updates = [String: Double]()
+
+ for idx in 0.. 1 {
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
} else {
- self.playStream(url: streams[0]["streamUrl"] as? String ?? "", fullURL: href, subtitles: streams[0]["subtitle"] as? String ?? "", headers: streams[0]["headers"] as! [String : String])
+ self.playStream(url: streams[0]["streamUrl"] as? String ?? "", fullURL: href, subtitles: result.subtitles?.first, headers: streams[0]["headers"] as! [String : String])
}
}
else if let streams = result.streams, !streams.isEmpty {
@@ -645,7 +1003,6 @@ struct MediaInfoView: View {
} else {
jsController.fetchStreamUrl(episodeUrl: href, softsub: module.metadata.softsub == true, module: module, completion: completion)
}
-
} catch {
self.handleStreamFailure(error: error)
DispatchQueue.main.async {
@@ -716,7 +1073,7 @@ struct MediaInfoView: View {
headers = streams[index]["headers"] as? [String:String] ?? [:]
index += 1
}
-
+
alert.addAction(UIAlertAction(title: title, style: .default) { _ in
self.playStream(url: streamUrl, fullURL: fullURL, subtitles: subtitles,headers: headers)
@@ -946,4 +1303,514 @@ struct MediaInfoView: View {
findTopViewController.findViewController(rootVC).present(alert, animated: true)
}
}
+
+ private func selectEpisodeRange(start: Int, end: Int) {
+ selectedEpisodes.removeAll()
+ for episodeNumber in start...end {
+ selectedEpisodes.insert(episodeNumber)
+ }
+ showRangeInput = false
+ }
+
+ private func selectAllVisibleEpisodes() {
+ if isGroupedBySeasons {
+ let seasons = groupedEpisodes()
+ if !seasons.isEmpty, selectedSeason < seasons.count {
+ for episode in seasons[selectedSeason] {
+ selectedEpisodes.insert(episode.number)
+ }
+ }
+ } else {
+ for i in episodeLinks.indices.filter({ selectedRange.contains($0) }) {
+ selectedEpisodes.insert(episodeLinks[i].number)
+ }
+ }
+ }
+
+ private func startBulkDownload() {
+ guard !selectedEpisodes.isEmpty else { return }
+
+ isBulkDownloading = true
+ bulkDownloadProgress = "Starting downloads..."
+ let episodesToDownload = episodeLinks.filter { selectedEpisodes.contains($0.number) }
+
+ Task {
+ await processBulkDownload(episodes: episodesToDownload)
+ }
+ }
+
+ @MainActor
+ private func processBulkDownload(episodes: [EpisodeLink]) async {
+ let totalCount = episodes.count
+ var completedCount = 0
+ var successCount = 0
+
+ for (index, episode) in episodes.enumerated() {
+ bulkDownloadProgress = "Downloading episode \(episode.number) (\(index + 1)/\(totalCount))"
+
+ let downloadStatus = jsController.isEpisodeDownloadedOrInProgress(
+ showTitle: title,
+ episodeNumber: episode.number,
+ season: 1
+ )
+
+ switch downloadStatus {
+ case .downloaded:
+ Logger.shared.log("Episode \(episode.number) already downloaded, skipping", type: "Info")
+ case .downloading:
+ Logger.shared.log("Episode \(episode.number) already downloading, skipping", type: "Info")
+ case .notDownloaded:
+ let downloadSuccess = await downloadSingleEpisode(episode: episode)
+ if downloadSuccess {
+ successCount += 1
+ }
+ }
+
+ completedCount += 1
+
+ try? await Task.sleep(nanoseconds: 500_000_000)
+ }
+
+ isBulkDownloading = false
+ bulkDownloadProgress = ""
+ isMultiSelectMode = false
+ selectedEpisodes.removeAll()
+
+ DropManager.shared.showDrop(
+ title: "Bulk Download Complete",
+ subtitle: "\(successCount)/\(totalCount) episodes queued for download",
+ duration: 2.0,
+ icon: UIImage(systemName: successCount == totalCount ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
+ )
+ }
+
+ private func downloadSingleEpisode(episode: EpisodeLink) async -> Bool {
+ return await withCheckedContinuation { continuation in
+ Task {
+ do {
+ let jsContent = try moduleManager.getModuleContent(module)
+ jsController.loadScript(jsContent)
+
+ self.tryNextDownloadMethodForBulk(
+ episode: episode,
+ methodIndex: 0,
+ softsub: module.metadata.softsub == true,
+ continuation: continuation
+ )
+ } catch {
+ Logger.shared.log("Error downloading episode \(episode.number): \(error)", type: "Error")
+ continuation.resume(returning: false)
+ }
+ }
+ }
+ }
+
+ private func tryNextDownloadMethodForBulk(
+ episode: EpisodeLink,
+ methodIndex: Int,
+ softsub: Bool,
+ continuation: CheckedContinuation
+ ) {
+ print("[Bulk Download] Trying download method #\(methodIndex+1) for Episode \(episode.number)")
+
+ switch methodIndex {
+ case 0:
+ if module.metadata.asyncJS == true {
+ jsController.fetchStreamUrlJS(episodeUrl: episode.href, softsub: softsub, module: module) { result in
+ self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation)
+ }
+ } else {
+ tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation)
+ }
+
+ case 1:
+ if module.metadata.streamAsyncJS == true {
+ jsController.fetchStreamUrlJSSecond(episodeUrl: episode.href, softsub: softsub, module: module) { result in
+ self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation)
+ }
+ } else {
+ tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation)
+ }
+
+ case 2:
+ jsController.fetchStreamUrl(episodeUrl: episode.href, softsub: softsub, module: module) { result in
+ self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation)
+ }
+
+ default:
+ Logger.shared.log("Failed to find a valid stream for bulk download after trying all methods", type: "Error")
+ continuation.resume(returning: false)
+ }
+ }
+
+ private func handleBulkDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), episode: EpisodeLink, methodIndex: Int, softsub: Bool, continuation: CheckedContinuation) {
+ if let streams = result.streams, !streams.isEmpty, let url = URL(string: streams[0]) {
+ if streams[0] == "[object Promise]" {
+ print("[Bulk Download] Method #\(methodIndex+1) returned a Promise object, trying next method")
+ tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation)
+ return
+ }
+
+ print("[Bulk Download] Method #\(methodIndex+1) returned valid stream URL: \(streams[0])")
+
+ let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) }
+ if let subtitleURL = subtitleURL {
+ print("[Bulk Download] Found subtitle URL: \(subtitleURL.absoluteString)")
+ }
+
+ startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streams[0], subtitleURL: subtitleURL)
+ continuation.resume(returning: true)
+
+ } else if let sources = result.sources, !sources.isEmpty,
+ let streamUrl = sources[0]["streamUrl"] as? String,
+ let url = URL(string: streamUrl) {
+
+ print("[Bulk Download] Method #\(methodIndex+1) returned valid stream URL with headers: \(streamUrl)")
+
+ let subtitleURLString = sources[0]["subtitle"] as? String
+ let subtitleURL = subtitleURLString.flatMap { URL(string: $0) }
+ if let subtitleURL = subtitleURL {
+ print("[Bulk Download] Found subtitle URL: \(subtitleURL.absoluteString)")
+ }
+
+ startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL)
+ continuation.resume(returning: true)
+
+ } else {
+ print("[Bulk Download] Method #\(methodIndex+1) did not return valid streams, trying next method")
+ tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation)
+ }
+ }
+
+ private func startEpisodeDownloadWithProcessedStream(episode: EpisodeLink, url: URL, streamUrl: String, subtitleURL: URL? = nil) {
+ var headers: [String: String] = [:]
+
+ if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") {
+ print("Using module baseUrl: \(module.metadata.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 {
+ 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 {
+ headers = [
+ "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"
+ ]
+ Logger.shared.log("Warning: Missing URL scheme/host for episode \(episode.number), using minimal headers", type: "Warning")
+ }
+ }
+
+ print("Bulk download headers: \(headers)")
+ fetchEpisodeMetadataForDownload(episode: episode) { metadata in
+ let episodeTitle = metadata?.title["en"] ?? metadata?.title.values.first ?? ""
+ let episodeImageUrl = metadata?.imageUrl ?? ""
+
+ let episodeName = episodeTitle.isEmpty ? "Episode \(episode.number)" : episodeTitle
+ let fullEpisodeTitle = "Episode \(episode.number): \(episodeName)"
+
+ let episodeThumbnailURL: URL?
+ if !episodeImageUrl.isEmpty {
+ episodeThumbnailURL = URL(string: episodeImageUrl)
+ } else {
+ episodeThumbnailURL = URL(string: self.getBannerImageBasedOnAppearance())
+ }
+
+ let showPosterImageURL = URL(string: self.imageUrl)
+
+ print("[Bulk Download] Using episode metadata - Title: '\(fullEpisodeTitle)', Image: '\(episodeImageUrl.isEmpty ? "default banner" : episodeImageUrl)'")
+
+ self.jsController.downloadWithStreamTypeSupport(
+ url: url,
+ headers: headers,
+ title: fullEpisodeTitle,
+ imageURL: episodeThumbnailURL,
+ module: self.module,
+ isEpisode: true,
+ showTitle: self.title,
+ season: 1,
+ episode: episode.number,
+ subtitleURL: subtitleURL,
+ showPosterURL: showPosterImageURL,
+ completionHandler: { success, message in
+ if success {
+ Logger.shared.log("Queued download for Episode \(episode.number) with metadata", type: "Download")
+ } else {
+ Logger.shared.log("Failed to queue download for Episode \(episode.number): \(message)", type: "Error")
+ }
+ }
+ )
+ }
+ }
+
+ private func fetchEpisodeMetadataForDownload(episode: EpisodeLink, completion: @escaping (EpisodeMetadataInfo?) -> Void) {
+ guard let anilistId = itemID else {
+ Logger.shared.log("No AniList ID available for episode metadata", type: "Warning")
+ completion(nil)
+ return
+ }
+
+ if MetadataCacheManager.shared.isCachingEnabled {
+ let cacheKey = "anilist_\(anilistId)_episode_\(episode.number)"
+
+ if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
+ let metadata = EpisodeMetadata.fromData(cachedData) {
+
+ print("[Bulk Download] Using cached metadata for episode \(episode.number)")
+ let metadataInfo = EpisodeMetadataInfo(
+ title: metadata.title,
+ imageUrl: metadata.imageUrl,
+ anilistId: metadata.anilistId,
+ episodeNumber: metadata.episodeNumber
+ )
+ completion(metadataInfo)
+ return
+ }
+ }
+
+ fetchEpisodeMetadataFromNetwork(anilistId: anilistId, episodeNumber: episode.number, completion: completion)
+ }
+
+ private func fetchEpisodeMetadataFromNetwork(anilistId: Int, episodeNumber: Int, completion: @escaping (EpisodeMetadataInfo?) -> Void) {
+ guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(anilistId)") else {
+ Logger.shared.log("Invalid URL for anilistId: \(anilistId)", type: "Error")
+ completion(nil)
+ return
+ }
+
+ print("[Bulk Download] Fetching metadata for episode \(episodeNumber) from network")
+
+ URLSession.custom.dataTask(with: url) { data, response, error in
+ if let error = error {
+ Logger.shared.log("Failed to fetch episode metadata: \(error)", type: "Error")
+ completion(nil)
+ return
+ }
+
+ guard let data = data else {
+ Logger.shared.log("No data received for episode metadata", type: "Error")
+ completion(nil)
+ return
+ }
+
+ do {
+ let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
+ guard let json = jsonObject as? [String: Any] else {
+ Logger.shared.log("Invalid JSON format for episode metadata", type: "Error")
+ completion(nil)
+ return
+ }
+
+ guard let episodes = json["episodes"] as? [String: Any] else {
+ Logger.shared.log("Missing 'episodes' object in metadata response", type: "Error")
+ completion(nil)
+ return
+ }
+
+ let episodeKey = "\(episodeNumber)"
+ guard let episodeDetails = episodes[episodeKey] as? [String: Any] else {
+ Logger.shared.log("Episode \(episodeKey) not found in metadata response", type: "Warning")
+ completion(nil)
+ return
+ }
+
+ var title: [String: String] = [:]
+ var image: String = ""
+
+ if let titleData = episodeDetails["title"] as? [String: String], !titleData.isEmpty {
+ title = titleData
+ } else {
+ title = ["en": "Episode \(episodeNumber)"]
+ }
+
+ if let imageUrl = episodeDetails["image"] as? String, !imageUrl.isEmpty {
+ image = imageUrl
+ }
+ if MetadataCacheManager.shared.isCachingEnabled {
+ let metadata = EpisodeMetadata(
+ title: title,
+ imageUrl: image,
+ anilistId: anilistId,
+ episodeNumber: episodeNumber
+ )
+
+ let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
+ if let metadataData = metadata.toData() {
+ MetadataCacheManager.shared.storeMetadata(
+ metadataData,
+ forKey: cacheKey
+ )
+ }
+ }
+
+ let metadataInfo = EpisodeMetadataInfo(
+ title: title,
+ imageUrl: image,
+ anilistId: anilistId,
+ episodeNumber: episodeNumber
+ )
+
+ print("[Bulk Download] Fetched metadata for episode \(episodeNumber): title='\(title["en"] ?? "N/A")', hasImage=\(!image.isEmpty)")
+ completion(metadataInfo)
+
+ } catch {
+ Logger.shared.log("JSON parsing error for episode metadata: \(error.localizedDescription)", type: "Error")
+ completion(nil)
+ }
+ }.resume()
+ }
+}
+
+struct RangeSelectionSheet: View {
+ let totalEpisodes: Int
+ let onSelectionComplete: (Int, Int) -> Void
+
+ @State private var startEpisode: String = "1"
+ @State private var endEpisode: String = ""
+ @State private var showError: Bool = false
+ @State private var errorMessage: String = ""
+
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationView {
+ VStack(spacing: 20) {
+ Text("Select Episode Range")
+ .font(.title2)
+ .fontWeight(.bold)
+ .padding(.top)
+
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ Text("From Episode:")
+ .frame(width: 100, alignment: .leading)
+ TextField("1", text: $startEpisode)
+ .textFieldStyle(RoundedBorderTextFieldStyle())
+ .keyboardType(.numberPad)
+ }
+
+ HStack {
+ Text("To Episode:")
+ .frame(width: 100, alignment: .leading)
+ TextField("\(totalEpisodes)", text: $endEpisode)
+ .textFieldStyle(RoundedBorderTextFieldStyle())
+ .keyboardType(.numberPad)
+ }
+ }
+ .padding(.horizontal)
+
+ if !startEpisode.isEmpty && !endEpisode.isEmpty {
+ let preview = generatePreviewText()
+ if !preview.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Preview:")
+ .font(.headline)
+ Text(preview)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .padding()
+ .background(Color(UIColor.secondarySystemBackground))
+ .cornerRadius(8)
+ }
+ .padding(.horizontal)
+ }
+ }
+
+ Spacer()
+
+ HStack(spacing: 16) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundColor(.secondary)
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(Color(UIColor.secondarySystemBackground))
+ .cornerRadius(10)
+
+ Button("Select") {
+ validateAndSelect()
+ }
+ .foregroundColor(.white)
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(Color.accentColor)
+ .cornerRadius(10)
+ .disabled(!isValidRange())
+ }
+ .padding(.horizontal)
+ .padding(.bottom)
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationBarTitle("Episode Range")
+ .navigationBarItems(trailing: Button("Done") { dismiss() })
+ }
+ .onAppear {
+ endEpisode = "\(totalEpisodes)"
+ }
+ .alert("Invalid Range", isPresented: $showError) {
+ Button("OK") { }
+ } message: {
+ Text(errorMessage)
+ }
+ }
+
+ private func isValidRange() -> Bool {
+ guard let start = Int(startEpisode),
+ let end = Int(endEpisode) else { return false }
+
+ return start >= 1 && end <= totalEpisodes && start <= end
+ }
+
+ private func generatePreviewText() -> String {
+ guard let start = Int(startEpisode),
+ let end = Int(endEpisode),
+ isValidRange() else { return "" }
+
+ let count = end - start + 1
+ return "Will select \(count) episode\(count == 1 ? "" : "s"): Episodes \(start)-\(end)"
+ }
+
+ private func validateAndSelect() {
+ guard let start = Int(startEpisode),
+ let end = Int(endEpisode) else {
+ errorMessage = "Please enter valid episode numbers"
+ showError = true
+ return
+ }
+
+ guard start >= 1 && end <= totalEpisodes else {
+ errorMessage = "Episode numbers must be between 1 and \(totalEpisodes)"
+ showError = true
+ return
+ }
+
+ guard start <= end else {
+ errorMessage = "Start episode must be less than or equal to end episode"
+ showError = true
+ return
+ }
+
+ onSelectionComplete(start, end)
+ dismiss()
+ }
}
diff --git a/Sora/Views/SearchView.swift b/Sora/Views/SearchView.swift
index 856e2a4..f9f8920 100644
--- a/Sora/Views/SearchView.swift
+++ b/Sora/Views/SearchView.swift
@@ -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
@@ -38,14 +38,6 @@ struct SearchView: View {
return moduleManager.modules.first { $0.id.uuidString == id }
}
- private var loadingMessages: [String] = [
- "Searching the depths...",
- "Looking for results...",
- "Fetching data...",
- "Please wait...",
- "Almost there..."
- ]
-
private var columnsCount: Int {
if UIDevice.current.userInterfaceIdiom == .pad {
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
@@ -166,22 +158,43 @@ struct SearchView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
- ForEach(getModuleLanguageGroups(), id: \.self) { language in
- Menu(language) {
- ForEach(getModulesForLanguage(language), id: \.id) { module in
- Button {
- selectedModuleId = module.id.uuidString
- } label: {
- HStack {
- KFImage(URL(string: module.metadata.iconUrl))
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 20, height: 20)
- .cornerRadius(4)
- Text(module.metadata.sourceName)
- if module.id.uuidString == selectedModuleId {
- Image(systemName: "checkmark")
- .foregroundColor(.accentColor)
+ if getModuleLanguageGroups().count == 1 {
+ ForEach(moduleManager.modules, id: \.id) { module in
+ Button {
+ selectedModuleId = module.id.uuidString
+ } label: {
+ HStack {
+ KFImage(URL(string: module.metadata.iconUrl))
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 20, height: 20)
+ .cornerRadius(4)
+ Text(module.metadata.sourceName)
+ if module.id.uuidString == selectedModuleId {
+ Image(systemName: "checkmark")
+ .foregroundColor(.accentColor)
+ }
+ }
+ }
+ }
+ } else {
+ ForEach(getModuleLanguageGroups(), id: \.self) { language in
+ Menu(language) {
+ ForEach(getModulesForLanguage(language), id: \.id) { module in
+ Button {
+ selectedModuleId = module.id.uuidString
+ } label: {
+ HStack {
+ KFImage(URL(string: module.metadata.iconUrl))
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 20, height: 20)
+ .cornerRadius(4)
+ Text(module.metadata.sourceName)
+ if module.id.uuidString == selectedModuleId {
+ Image(systemName: "checkmark")
+ .foregroundColor(.accentColor)
+ }
}
}
}
@@ -213,6 +226,14 @@ struct SearchView: View {
performSearch()
}
}
+ .onChange(of: moduleManager.selectedModuleChanged) { _ in
+ if moduleManager.selectedModuleChanged {
+ if selectedModuleId == nil && !moduleManager.modules.isEmpty {
+ selectedModuleId = moduleManager.modules[0].id.uuidString
+ }
+ moduleManager.selectedModuleChanged = false
+ }
+ }
.onChange(of: searchText) { newValue in
if newValue.isEmpty {
searchItems = []
@@ -324,13 +345,11 @@ struct SearchBar: View {
.background(Color(.systemGray6))
.cornerRadius(8)
.onChange(of: text){newValue in
- debounceTimer?.invalidate()
- // Start a new timer to wait before performing the action
+ debounceTimer?.invalidate()
debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
- // Perform the action after the delay (debouncing)
- onSearchButtonClicked()
- }
- }
+ onSearchButtonClicked()
+ }
+ }
.overlay(
HStack {
Image(systemName: "magnifyingglass")
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift
index 42901d5..e6a5b19 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift
@@ -11,11 +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) {
@@ -39,6 +96,18 @@ struct SettingsViewData: View {
.foregroundColor(.secondary)
}
+ HStack {
+ Button(action: {
+ showRemoveMovPkgAlert = true
+ }) {
+ Text("Remove Downloads")
+ }
+ Spacer()
+ Text("\(formatSize(movPkgSize))")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
Button(action: {
showEraseAppDataAlert = true
}) {
@@ -49,8 +118,83 @@ 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) {
+ Alert(
+ title: Text("Erase App Data"),
+ message: Text("Are you sure you want to erase all app data? This action cannot be undone."),
+ primaryButton: .destructive(Text("Erase")) {
+ eraseAppData()
+ },
+ secondaryButton: .cancel()
+ )
+ }
+ .alert(isPresented: $showRemoveDocumentsAlert) {
+ Alert(
+ title: Text("Remove Documents"),
+ message: Text("Are you sure you want to remove all files in the Documents folder? This will remove all modules."),
+ primaryButton: .destructive(Text("Remove")) {
+ removeAllFilesInDocuments()
+ },
+ secondaryButton: .cancel()
+ )
+ }
+ .alert(isPresented: $showRemoveMovPkgAlert) {
+ Alert(
+ title: Text("Remove Downloads"),
+ message: Text("Are you sure you want to remove all Downloads?"),
+ primaryButton: .destructive(Text("Remove")) {
+ removeMovPkgFiles()
+ },
+ secondaryButton: .cancel()
+ )
+ }
+ }
+
+ // 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() {
@@ -72,6 +216,7 @@ struct SettingsViewData: View {
try FileManager.default.removeItem(at: filePath)
}
Logger.shared.log("Cache cleared successfully!", type: "General")
+ calculateCacheSize()
updateSizes()
}
} catch {
@@ -95,6 +240,24 @@ struct SettingsViewData: View {
}
}
+ func removeMovPkgFiles() {
+ let fileManager = FileManager.default
+ if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
+ do {
+ let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
+ for fileURL in fileURLs {
+ if fileURL.pathExtension == "movpkg" {
+ try fileManager.removeItem(at: fileURL)
+ }
+ }
+ Logger.shared.log("All Downloads files removed", type: "General")
+ updateSizes()
+ } catch {
+ Logger.shared.log("Error removing Downloads files: \(error)", type: "Error")
+ }
+ }
+ }
+
private func calculateDirectorySize(for url: URL) -> Int64 {
let fileManager = FileManager.default
var totalSize: Int64 = 0
@@ -130,6 +293,24 @@ struct SettingsViewData: View {
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
documentsSize = calculateDirectorySize(for: documentsURL)
+ movPkgSize = calculateMovPkgSize(in: documentsURL)
}
}
+
+ private func calculateMovPkgSize(in url: URL) -> Int64 {
+ let fileManager = FileManager.default
+ var totalSize: Int64 = 0
+
+ do {
+ let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
+ for url in contents where url.pathExtension == "movpkg" {
+ let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey])
+ totalSize += Int64(resourceValues.fileSize ?? 0)
+ }
+ } catch {
+ Logger.shared.log("Error calculating MovPkg size: \(error)", type: "Error")
+ }
+
+ return totalSize
+ }
}
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift
new file mode 100644
index 0000000..4ffdc9f
--- /dev/null
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift
@@ -0,0 +1,171 @@
+//
+// SettingsViewDownloads.swift
+// Sora
+//
+// Created by doomsboygaming on 5/22/25
+//
+
+import SwiftUI
+import Drops
+
+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
+ 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: {
+ 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()
+ jsController.updateMaxConcurrentDownloads(maxConcurrentDownloads)
+ }
+ }
+
+ private func calculateTotalStorage() {
+ guard !jsController.savedAssets.isEmpty else {
+ totalStorageSize = 0
+ existingDownloadCount = 0
+ return
+ }
+
+ isCalculating = true
+
+ DownloadedAsset.clearFileSizeCache()
+ DownloadGroup.clearFileSizeCache()
+
+ 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 {
+ jsController.removeAssetFromLibrary(asset)
+ } else {
+ jsController.deleteAsset(asset)
+ }
+ }
+
+ totalStorageSize = 0
+ existingDownloadCount = 0
+
+ NotificationCenter.default.post(name: NSNotification.Name("downloadLibraryChanged"), object: nil)
+
+ 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)
+ }
+}
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift
index 2250607..6769d81 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift
@@ -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)
]
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift
index b75b02b..ee4abf4 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift
@@ -193,7 +193,7 @@ struct SettingsViewModule: View {
self.displayModuleView(url: pasteboardString)
}))
- clipboardAlert.addAction(UIAlertAction(title: "Enter Manually", style: .cancel, handler: { _ in
+ clipboardAlert.addAction(UIAlertAction(title: "Enter Manually", style: .default, handler: { _ in
self.showManualUrlAlert()
}))
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPerformance.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPerformance.swift
new file mode 100644
index 0000000..502298a
--- /dev/null
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPerformance.swift
@@ -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()
+ }
+ }
+}
\ No newline at end of file
diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift
index 6db65f9..3806ae3 100644
--- a/Sora/Views/SettingsView/SettingsView.swift
+++ b/Sora/Views/SettingsView/SettingsView.swift
@@ -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")) {
@@ -89,7 +95,7 @@ struct SettingsView: View {
}
}
}
- Section(footer: Text("Running Sora 0.2.2 - cranci1")) {}
+ Section(footer: Text("Running Sora 0.3.0 - cranci1")) {}
}
.navigationTitle("Settings")
}
diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj
index 39c37c4..8e88296 100644
--- a/Sulfur.xcodeproj/project.pbxproj
+++ b/Sulfur.xcodeproj/project.pbxproj
@@ -11,15 +11,12 @@
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 */; };
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 */; };
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1202D99951700A0140B /* JSController-Streams.swift */; };
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; };
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; };
- 132E351D2D959DDB0007800E /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351C2D959DDB0007800E /* Drops */; };
- 132E35232D959E410007800E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 132E35222D959E410007800E /* Kingfisher */; };
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; };
@@ -35,13 +32,16 @@
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133F55BA2D33B55100E08EEA /* LibraryManager.swift */; };
1359ED142D76F49900C13034 /* finTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1359ED132D76F49900C13034 /* finTopView.swift */; };
135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */; };
+ 13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */; };
+ 13637B8D2DE0ECCC00BDA2FC /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 13637B8C2DE0ECCC00BDA2FC /* Kingfisher */; };
+ 13637B902DE0ECD200BDA2FC /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 13637B8F2DE0ECD200BDA2FC /* Drops */; };
+ 13637B932DE0ECDB00BDA2FC /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 13637B922DE0ECDB00BDA2FC /* MarqueeLabel */; };
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136BBE7F2DB1038000906B5E /* Notification+Name.swift */; };
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; };
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; };
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; };
1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD52D3AB3DB00E97C31 /* Logger.swift */; };
- 13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 13B77E182DA44F8300126FDF /* MarqueeLabel */; };
13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B77E1F2DA457AA00126FDF /* AniListPushUpdates.swift */; };
13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B7F4C02D58FFDD0045714A /* Shimmer.swift */; };
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */; };
@@ -66,6 +66,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 */
@@ -75,7 +91,6 @@
13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; };
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = ""; };
131270162DC13A010093AA9C /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; };
- 131270182DC13A3C0093AA9C /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; };
131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = ""; };
1327FBA62D758CEA00FC6689 /* Analytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = ""; };
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = ""; };
@@ -98,6 +113,7 @@
133F55BA2D33B55100E08EEA /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = ""; };
1359ED132D76F49900C13034 /* finTopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = finTopView.swift; sourceTree = ""; };
135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = ""; };
+ 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; };
136BBE7F2DB1038000906B5E /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = ""; };
138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = ""; };
138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = ""; };
@@ -129,6 +145,22 @@
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; };
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; };
1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = ""; };
+ 7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadata.swift; sourceTree = ""; };
+ 7205AED82DCCEF9500943F3F /* KingfisherManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherManager.swift; sourceTree = ""; };
+ 7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataCacheManager.swift; sourceTree = ""; };
+ 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = ""; };
+ 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = ""; };
+ 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = ""; };
+ 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+M3U8Download.swift"; sourceTree = ""; };
+ 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Downloads.swift"; sourceTree = ""; };
+ 72443C7C2DC8036500A61321 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; };
+ 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = ""; };
+ 7272206D2DD6336100C2A4A2 /* SettingsViewPerformance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPerformance.swift; sourceTree = ""; };
+ 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = ""; };
+ 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = ""; };
+ 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadataManager.swift; sourceTree = ""; };
+ 72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePrefetchManager.swift; sourceTree = ""; };
+ 72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMonitor.swift; sourceTree = ""; };
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; };
/* End PBXFileReference section */
@@ -137,9 +169,9 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */,
- 132E35232D959E410007800E /* Kingfisher in Frameworks */,
- 132E351D2D959DDB0007800E /* Drops in Frameworks */,
+ 13637B8D2DE0ECCC00BDA2FC /* Kingfisher in Frameworks */,
+ 13637B902DE0ECD200BDA2FC /* Drops in Frameworks */,
+ 13637B932DE0ECDB00BDA2FC /* MarqueeLabel in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -173,14 +205,6 @@
path = SkeletonCells;
sourceTree = "";
};
- 131270152DC139CD0093AA9C /* DownloadManager */ = {
- isa = PBXGroup;
- children = (
- 131270162DC13A010093AA9C /* DownloadManager.swift */,
- );
- path = DownloadManager;
- sourceTree = "";
- };
1327FBA52D758CEA00FC6689 /* Analytics */ = {
isa = PBXGroup;
children = (
@@ -208,6 +232,7 @@
133D7C6C2D2BE2500075467E /* Sora */ = {
isa = PBXGroup;
children = (
+ 72AC3A002DD4DAEA00C60B96 /* Managers */,
130C6BF82D53A4C200DC1432 /* Sora.entitlements */,
13DC0C412D2EC9BA00D0F966 /* Info.plist */,
13103E802D589D6C000F0673 /* Tracking Services */,
@@ -232,11 +257,11 @@
133D7C7B2D2BE2630075467E /* Views */ = {
isa = PBXGroup;
children = (
+ 72443C7C2DC8036500A61321 /* DownloadView.swift */,
133D7C7F2D2BE2630075467E /* MediaInfoView */,
1399FAD22D3AB34F00E97C31 /* SettingsView */,
133F55B92D33B53E00E08EEA /* LibraryView */,
133D7C7C2D2BE2630075467E /* SearchView.swift */,
- 131270182DC13A3C0093AA9C /* DownloadView.swift */,
);
path = Views;
sourceTree = "";
@@ -253,6 +278,7 @@
133D7C832D2BE2630075467E /* SettingsSubViews */ = {
isa = PBXGroup;
children = (
+ 7272206D2DD6336100C2A4A2 /* SettingsViewPerformance.swift */,
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */,
1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */,
133D7C842D2BE2630075467E /* SettingsViewModule.swift */,
@@ -260,6 +286,7 @@
135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */,
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */,
13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */,
+ 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */,
);
path = SettingsSubViews;
sourceTree = "";
@@ -267,16 +294,17 @@
133D7C852D2BE2640075467E /* Utils */ = {
isa = PBXGroup;
children = (
- 131270152DC139CD0093AA9C /* DownloadManager */,
- 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */,
- 13103E8C2D58E037000F0673 /* SkeletonCells */,
- 13DC0C442D302C6A00D0F966 /* MediaPlayer */,
- 133D7C862D2BE2640075467E /* Extensions */,
- 1327FBA52D758CEA00FC6689 /* Analytics */,
- 133D7C8A2D2BE2640075467E /* JSLoader */,
- 133D7C882D2BE2640075467E /* Modules */,
- 1399FAD12D3AB33D00E97C31 /* Logger */,
+ 7205AEDA2DCCEF9500943F3F /* Cache */,
13D842532D45266900EBBFA6 /* Drops */,
+ 1399FAD12D3AB33D00E97C31 /* Logger */,
+ 133D7C882D2BE2640075467E /* Modules */,
+ 133D7C8A2D2BE2640075467E /* JSLoader */,
+ 1327FBA52D758CEA00FC6689 /* Analytics */,
+ 133D7C862D2BE2640075467E /* Extensions */,
+ 13DC0C442D302C6A00D0F966 /* MediaPlayer */,
+ 13103E8C2D58E037000F0673 /* SkeletonCells */,
+ 72443C832DC8046500A61321 /* DownloadUtils */,
+ 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */,
);
path = Utils;
sourceTree = "";
@@ -287,6 +315,7 @@
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */,
136BBE7F2DB1038000906B5E /* Notification+Name.swift */,
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */,
+ 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */,
133D7C872D2BE2640075467E /* URLSession.swift */,
1359ED132D76F49900C13034 /* finTopView.swift */,
13CBEFD92D5F7D1200D011EE /* String.swift */,
@@ -299,9 +328,9 @@
133D7C882D2BE2640075467E /* Modules */ = {
isa = PBXGroup;
children = (
- 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */,
13D99CF62D4E73C300250A86 /* ModuleAdditionSettingsView.swift */,
139935652D468C450065CEFF /* ModuleManager.swift */,
+ 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */,
133D7C892D2BE2640075467E /* Modules.swift */,
);
path = Modules;
@@ -310,6 +339,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 */,
@@ -452,6 +486,36 @@
path = Components;
sourceTree = "";
};
+ 7205AEDA2DCCEF9500943F3F /* Cache */ = {
+ isa = PBXGroup;
+ children = (
+ 7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */,
+ 7205AED82DCCEF9500943F3F /* KingfisherManager.swift */,
+ 7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */,
+ );
+ path = Cache;
+ sourceTree = "";
+ };
+ 72443C832DC8046500A61321 /* DownloadUtils */ = {
+ isa = PBXGroup;
+ children = (
+ 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */,
+ 131270162DC13A010093AA9C /* DownloadManager.swift */,
+ 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */,
+ );
+ path = DownloadUtils;
+ sourceTree = "";
+ };
+ 72AC3A002DD4DAEA00C60B96 /* Managers */ = {
+ isa = PBXGroup;
+ children = (
+ 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */,
+ 72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */,
+ 72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */,
+ );
+ path = Managers;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -469,9 +533,9 @@
);
name = Sulfur;
packageProductDependencies = (
- 132E351C2D959DDB0007800E /* Drops */,
- 132E35222D959E410007800E /* Kingfisher */,
- 13B77E182DA44F8300126FDF /* MarqueeLabel */,
+ 13637B8C2DE0ECCC00BDA2FC /* Kingfisher */,
+ 13637B8F2DE0ECD200BDA2FC /* Drops */,
+ 13637B922DE0ECDB00BDA2FC /* MarqueeLabel */,
);
productName = Sora;
productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */;
@@ -501,9 +565,9 @@
);
mainGroup = 133D7C612D2BE2500075467E;
packageReferences = (
- 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */,
- 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */,
- 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */,
+ 13637B8B2DE0ECCC00BDA2FC /* XCRemoteSwiftPackageReference "Kingfisher" */,
+ 13637B8E2DE0ECD200BDA2FC /* XCRemoteSwiftPackageReference "Drops" */,
+ 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */,
);
productRefGroup = 133D7C6B2D2BE2500075467E /* Products */;
projectDirPath = "";
@@ -543,6 +607,9 @@
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */,
13CBEFDA2D5F7D1200D011EE /* String.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 */,
@@ -562,15 +629,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 */,
@@ -583,9 +654,18 @@
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 */,
+ 13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -648,6 +728,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";
@@ -703,6 +785,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";
@@ -717,7 +801,6 @@
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\"";
@@ -741,15 +824,15 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.2.3;
+ MARKETING_VERSION = 0.2.2;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
- "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "";
- SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MACCATALYST = YES;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
+ TARGETED_DEVICE_FAMILY = "1,2,6";
};
name = Debug;
};
@@ -760,7 +843,6 @@
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\"";
@@ -784,15 +866,15 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.2.3;
+ MARKETING_VERSION = 0.2.2;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
- "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "";
- SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MACCATALYST = YES;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
+ TARGETED_DEVICE_FAMILY = "1,2,6";
};
name = Release;
};
@@ -820,15 +902,7 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
- 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */ = {
- isa = XCRemoteSwiftPackageReference;
- repositoryURL = "https://github.com/omaralbeik/Drops.git";
- requirement = {
- branch = main;
- kind = branch;
- };
- };
- 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
+ 13637B8B2DE0ECCC00BDA2FC /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
requirement = {
@@ -836,7 +910,15 @@
version = 7.9.1;
};
};
- 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */ = {
+ 13637B8E2DE0ECD200BDA2FC /* XCRemoteSwiftPackageReference "Drops" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/omaralbeik/Drops.git";
+ requirement = {
+ branch = main;
+ kind = branch;
+ };
+ };
+ 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/cbpowell/MarqueeLabel";
requirement = {
@@ -847,19 +929,19 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
- 132E351C2D959DDB0007800E /* Drops */ = {
+ 13637B8C2DE0ECCC00BDA2FC /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
- package = 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */;
- productName = Drops;
- };
- 132E35222D959E410007800E /* Kingfisher */ = {
- isa = XCSwiftPackageProductDependency;
- package = 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */;
+ package = 13637B8B2DE0ECCC00BDA2FC /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
- 13B77E182DA44F8300126FDF /* MarqueeLabel */ = {
+ 13637B8F2DE0ECD200BDA2FC /* Drops */ = {
isa = XCSwiftPackageProductDependency;
- package = 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */;
+ package = 13637B8E2DE0ECD200BDA2FC /* XCRemoteSwiftPackageReference "Drops" */;
+ productName = Drops;
+ };
+ 13637B922DE0ECDB00BDA2FC /* MarqueeLabel */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */;
productName = MarqueeLabel;
};
/* End XCSwiftPackageProductDependency section */
diff --git a/assets/Sulfur.png b/assets/Sulfur.png
index 4e293e1..67711c8 100644
Binary files a/assets/Sulfur.png and b/assets/Sulfur.png differ
diff --git a/assets/banner1.png b/assets/banner1.png
index 72ae268..433bdf6 100644
Binary files a/assets/banner1.png and b/assets/banner1.png differ
diff --git a/assets/banner2.png b/assets/banner2.png
index a46bc4f..de060a1 100644
Binary files a/assets/banner2.png and b/assets/banner2.png differ
diff --git a/assets/screenshots/ios-1.png b/assets/screenshots/ios-1.png
index e39be58..7012205 100644
Binary files a/assets/screenshots/ios-1.png and b/assets/screenshots/ios-1.png differ
diff --git a/assets/screenshots/ios-2.png b/assets/screenshots/ios-2.png
index 6ff7226..3a85e02 100644
Binary files a/assets/screenshots/ios-2.png and b/assets/screenshots/ios-2.png differ
diff --git a/assets/screenshots/ios-3.png b/assets/screenshots/ios-3.png
index 8d0523a..b7886af 100644
Binary files a/assets/screenshots/ios-3.png and b/assets/screenshots/ios-3.png differ
diff --git a/assets/screenshots/ios-4.png b/assets/screenshots/ios-4.png
index 2ab59cd..38460db 100644
Binary files a/assets/screenshots/ios-4.png and b/assets/screenshots/ios-4.png differ
diff --git a/assets/screenshots/ipad-1.png b/assets/screenshots/ipad-1.png
index a038509..cada8e5 100644
Binary files a/assets/screenshots/ipad-1.png and b/assets/screenshots/ipad-1.png differ
diff --git a/assets/screenshots/ipad-2.png b/assets/screenshots/ipad-2.png
index 168c556..a92b756 100644
Binary files a/assets/screenshots/ipad-2.png and b/assets/screenshots/ipad-2.png differ
diff --git a/assets/screenshots/ipad-3.png b/assets/screenshots/ipad-3.png
index f5fa060..67c47d2 100644
Binary files a/assets/screenshots/ipad-3.png and b/assets/screenshots/ipad-3.png differ
diff --git a/assets/screenshots/ipad-4.png b/assets/screenshots/ipad-4.png
index 17600e7..0367150 100644
Binary files a/assets/screenshots/ipad-4.png and b/assets/screenshots/ipad-4.png differ
diff --git a/assets/screenshots/ipad-5.png b/assets/screenshots/ipad-5.png
index 1a702b0..aa4a98c 100644
Binary files a/assets/screenshots/ipad-5.png and b/assets/screenshots/ipad-5.png differ
diff --git a/macbuild.sh b/macbuild.sh
new file mode 100755
index 0000000..c0c5950
--- /dev/null
+++ b/macbuild.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+
+set -e
+
+cd "$(dirname "$0")"
+
+WORKING_LOCATION="$(pwd)"
+APPLICATION_NAME=Sulfur
+
+if [ ! -d "build" ]; then
+ mkdir build
+fi
+
+cd build
+
+xcodebuild -project "$WORKING_LOCATION/$APPLICATION_NAME.xcodeproj" \
+ -scheme "$APPLICATION_NAME" \
+ -configuration Release \
+ -derivedDataPath "$WORKING_LOCATION/build/DerivedDataApp" \
+ -destination 'platform=macOS,variant=Mac Catalyst' \
+ clean build \
+ CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" CODE_SIGNING_ALLOWED="NO"
+
+DD_APP_PATH="$WORKING_LOCATION/build/DerivedDataApp/Build/Products/Release-maccatalyst/$APPLICATION_NAME.app"
+TARGET_APP="$WORKING_LOCATION/build/$APPLICATION_NAME.app"
+
+cp -r "$DD_APP_PATH" "$TARGET_APP"
+
+codesign --remove "$TARGET_APP"
+if [ -e "$TARGET_APP/_CodeSignature" ]; then
+ rm -rf "$TARGET_APP/_CodeSignature"
+fi
+
+echo "Mac Catalyst build completed: $TARGET_APP"