downloads (#130)

This commit is contained in:
cranci 2025-05-23 20:02:23 +02:00 committed by GitHub
parent 1a4078d2c2
commit e030658947
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 9198 additions and 746 deletions

View file

@ -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

View file

@ -69,6 +69,7 @@ Frameworks:
Misc:
- [50/50](https://github.com/50n50) for the app icon
- Ciro for the episode banner images
## License

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View file

@ -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")

View file

@ -38,7 +38,5 @@
<string>audio</string>
<string>processing</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,646 @@
//
// EpisodeMetadataManager.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import Combine
/// A model representing episode metadata
struct EpisodeMetadataInfo: Codable, Equatable {
let title: [String: String]
let imageUrl: String
let anilistId: Int
let episodeNumber: Int
var cacheKey: String {
return "anilist_\(anilistId)_episode_\(episodeNumber)"
}
}
/// Status of a metadata fetch request
enum MetadataFetchStatus {
case notRequested
case fetching
case fetched(EpisodeMetadataInfo)
case failed(Error)
}
/// Central manager for fetching, caching, and prefetching episode metadata
class EpisodeMetadataManager: ObservableObject {
static let shared = EpisodeMetadataManager()
private init() {
// Initialize any resources here
Logger.shared.log("EpisodeMetadataManager initialized", type: "Info")
}
// Published properties that trigger UI updates
@Published private var metadataCache: [String: MetadataFetchStatus] = [:]
// In-flight requests to prevent duplicate API calls
private var activeRequests: [String: AnyCancellable] = [:]
// Queue for managing concurrent requests
private let fetchQueue = DispatchQueue(label: "com.sora.metadataFetch", qos: .userInitiated, attributes: .concurrent)
// Add retry configuration properties
private let maxRetryAttempts = 3
private let initialBackoffDelay: TimeInterval = 1.0 // in seconds
private var currentRetryAttempts: [String: Int] = [:] // Track retry attempts by cache key
// MARK: - Public Interface
/// Fetch metadata for a single episode
/// - Parameters:
/// - anilistId: The Anilist ID of the anime
/// - episodeNumber: The episode number to fetch
/// - completion: Callback with the result
func fetchMetadata(anilistId: Int, episodeNumber: Int, completion: @escaping (Result<EpisodeMetadataInfo, Error>) -> Void) {
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
// Check if we already have this metadata
if let existingStatus = metadataCache[cacheKey] {
switch existingStatus {
case .fetched(let metadata):
// Return cached data immediately
completion(.success(metadata))
return
case .fetching:
// Already fetching, will be notified via publisher
// Set up a listener for when this request completes
waitForRequest(cacheKey: cacheKey, completion: completion)
return
case .failed:
// Previous attempt failed, try again
break
case .notRequested:
// Should not happen but continue to fetch
break
}
}
// Check persistent cache
if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
let metadata = EpisodeMetadata.fromData(cachedData) {
let metadataInfo = EpisodeMetadataInfo(
title: metadata.title,
imageUrl: metadata.imageUrl,
anilistId: anilistId,
episodeNumber: episodeNumber
)
// Update memory cache
DispatchQueue.main.async {
self.metadataCache[cacheKey] = .fetched(metadataInfo)
}
completion(.success(metadataInfo))
return
}
// Need to fetch from network
DispatchQueue.main.async {
self.metadataCache[cacheKey] = .fetching
}
performFetch(anilistId: anilistId, episodeNumber: episodeNumber, cacheKey: cacheKey, completion: completion)
}
/// Fetch metadata for multiple episodes in batch
/// - Parameters:
/// - anilistId: The Anilist ID of the anime
/// - episodeNumbers: Array of episode numbers to fetch
func batchFetchMetadata(anilistId: Int, episodeNumbers: [Int]) {
// First check which episodes we need to fetch
let episodesToFetch = episodeNumbers.filter { episodeNumber in
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
if let status = metadataCache[cacheKey] {
switch status {
case .fetched, .fetching:
return false
default:
return true
}
}
return true
}
guard !episodesToFetch.isEmpty else {
Logger.shared.log("No new episodes to fetch in batch", type: "Debug")
return
}
// Mark all as fetching
for episodeNumber in episodesToFetch {
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
DispatchQueue.main.async {
self.metadataCache[cacheKey] = .fetching
}
}
// Perform batch fetch
fetchBatchFromNetwork(anilistId: anilistId, episodeNumbers: episodesToFetch)
}
/// Prefetch metadata for a range of episodes
/// - Parameters:
/// - anilistId: The Anilist ID of the anime
/// - startEpisode: The starting episode number
/// - count: How many episodes to prefetch
func prefetchMetadata(anilistId: Int, startEpisode: Int, count: Int = 5) {
let episodeNumbers = Array(startEpisode..<(startEpisode + count))
batchFetchMetadata(anilistId: anilistId, episodeNumbers: episodeNumbers)
}
/// Get metadata for an episode (non-blocking, returns immediately from cache)
/// - Parameters:
/// - anilistId: The Anilist ID of the anime
/// - episodeNumber: The episode number
/// - Returns: The metadata fetch status
func getMetadataStatus(anilistId: Int, episodeNumber: Int) -> MetadataFetchStatus {
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
return metadataCache[cacheKey] ?? .notRequested
}
// MARK: - Private Methods
private func performFetch(anilistId: Int, episodeNumber: Int, cacheKey: String, completion: @escaping (Result<EpisodeMetadataInfo, Error>) -> Void) {
// Check if there's already an active request for this metadata
if activeRequests[cacheKey] != nil {
// Already fetching, wait for it to complete
waitForRequest(cacheKey: cacheKey, completion: completion)
return
}
// Reset retry attempts if this is a new fetch
if currentRetryAttempts[cacheKey] == nil {
currentRetryAttempts[cacheKey] = 0
}
// Create API request
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(anilistId)") else {
let error = NSError(domain: "com.sora.metadata", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
DispatchQueue.main.async {
self.metadataCache[cacheKey] = .failed(error)
}
completion(.failure(error))
return
}
Logger.shared.log("Fetching metadata for episode \(episodeNumber) from network", type: "Debug")
// Create publisher for the request
let publisher = URLSession.custom.dataTaskPublisher(for: url)
.subscribe(on: fetchQueue)
.tryMap { [weak self] data, response -> EpisodeMetadataInfo in
guard let self = self else {
throw NSError(domain: "com.sora.metadata", code: 4,
userInfo: [NSLocalizedDescriptionKey: "Manager instance released"])
}
// Validate response
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NSError(domain: "com.sora.metadata", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
}
// Parse JSON
let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
guard let json = jsonObject as? [String: Any] else {
throw NSError(domain: "com.sora.metadata", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Invalid data format"])
}
// Check for episodes object
guard let episodes = json["episodes"] as? [String: Any] else {
Logger.shared.log("Missing 'episodes' object in response for anilistId: \(anilistId)", type: "Error")
throw NSError(domain: "com.sora.metadata", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Missing episodes object in response"])
}
// Check if episode exists in response
let episodeKey = "\(episodeNumber)"
guard let episodeDetails = episodes[episodeKey] as? [String: Any] else {
Logger.shared.log("Episode \(episodeNumber) not found in response for anilistId: \(anilistId)", type: "Error")
throw NSError(domain: "com.sora.metadata", code: 5,
userInfo: [NSLocalizedDescriptionKey: "Episode \(episodeNumber) not found in response"])
}
// Extract available fields, log if they're missing
var title: [String: String] = [:]
var image: String = ""
var missingFields: [String] = []
// Try to get title
if let titleData = episodeDetails["title"] as? [String: String] {
title = titleData
// Check if we have valid title values
if title.isEmpty || title.values.allSatisfy({ $0.isEmpty }) {
missingFields.append("title (all values empty)")
}
} else {
missingFields.append("title")
// Create default empty title dictionary
title = ["en": "Episode \(episodeNumber)"]
}
// Try to get image
if let imageUrl = episodeDetails["image"] as? String {
image = imageUrl
if imageUrl.isEmpty {
missingFields.append("image (empty string)")
}
} else {
missingFields.append("image")
// Use a default placeholder image
image = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png"
}
// Log missing fields but continue processing
if !missingFields.isEmpty {
Logger.shared.log("Episode \(episodeNumber) for anilistId \(anilistId) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning")
}
// Create metadata object with whatever we have
let metadataInfo = EpisodeMetadataInfo(
title: title,
imageUrl: image,
anilistId: anilistId,
episodeNumber: episodeNumber
)
// Cache the metadata
if MetadataCacheManager.shared.isCachingEnabled {
let metadata = EpisodeMetadata(
title: title,
imageUrl: image,
anilistId: anilistId,
episodeNumber: episodeNumber
)
if let metadataData = metadata.toData() {
MetadataCacheManager.shared.storeMetadata(
metadataData,
forKey: cacheKey
)
Logger.shared.log("Cached metadata for episode \(episodeNumber)", type: "Debug")
}
}
// Reset retry count on success (even with missing fields)
self.currentRetryAttempts.removeValue(forKey: cacheKey)
return metadataInfo
}
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
// Handle completion
guard let self = self else { return }
switch result {
case .finished:
break
case .failure(let error):
// Handle retry logic
var shouldRetry = false
let currentAttempt = self.currentRetryAttempts[cacheKey] ?? 0
// Check if we should retry based on the error and attempt count
if currentAttempt < self.maxRetryAttempts {
// Increment attempt counter
let nextAttempt = currentAttempt + 1
self.currentRetryAttempts[cacheKey] = nextAttempt
// Calculate backoff delay using exponential backoff
let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(currentAttempt))
Logger.shared.log("Metadata fetch failed, retrying (attempt \(nextAttempt)/\(self.maxRetryAttempts)) in \(backoffDelay) seconds", type: "Debug")
// Schedule retry after backoff delay
DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) {
// Remove the current request before retrying
self.activeRequests.removeValue(forKey: cacheKey)
self.performFetch(anilistId: anilistId, episodeNumber: episodeNumber, cacheKey: cacheKey, completion: completion)
}
shouldRetry = true
} else {
// Max retries reached
Logger.shared.log("Metadata fetch failed after \(self.maxRetryAttempts) attempts: \(error.localizedDescription)", type: "Error")
self.currentRetryAttempts.removeValue(forKey: cacheKey)
}
if !shouldRetry {
// Update cache with error
self.metadataCache[cacheKey] = .failed(error)
completion(.failure(error))
// Remove from active requests
self.activeRequests.removeValue(forKey: cacheKey)
}
}
}, receiveValue: { [weak self] metadataInfo in
// Update cache with result
self?.metadataCache[cacheKey] = .fetched(metadataInfo)
completion(.success(metadataInfo))
// Remove from active requests
self?.activeRequests.removeValue(forKey: cacheKey)
})
// Store publisher in active requests
activeRequests[cacheKey] = publisher
}
private func fetchBatchFromNetwork(anilistId: Int, episodeNumbers: [Int]) {
// This API returns all episodes for a show in one call, so we only need one request
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(anilistId)") else {
Logger.shared.log("Invalid URL for batch fetch", type: "Error")
return
}
Logger.shared.log("Batch fetching \(episodeNumbers.count) episodes from network", type: "Debug")
let batchCacheKey = "batch_\(anilistId)_\(episodeNumbers.map { String($0) }.joined(separator: "_"))"
// Reset retry attempts if this is a new fetch
if currentRetryAttempts[batchCacheKey] == nil {
currentRetryAttempts[batchCacheKey] = 0
}
// Create publisher for the request
let publisher = URLSession.custom.dataTaskPublisher(for: url)
.subscribe(on: fetchQueue)
.tryMap { [weak self] data, response -> [Int: EpisodeMetadataInfo] in
guard let self = self else {
throw NSError(domain: "com.sora.metadata", code: 4,
userInfo: [NSLocalizedDescriptionKey: "Manager instance released"])
}
// Validate response
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NSError(domain: "com.sora.metadata", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
}
// Parse JSON
let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
guard let json = jsonObject as? [String: Any] else {
throw NSError(domain: "com.sora.metadata", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Invalid data format"])
}
guard let episodes = json["episodes"] as? [String: Any] else {
Logger.shared.log("Missing 'episodes' object in response for anilistId: \(anilistId)", type: "Error")
throw NSError(domain: "com.sora.metadata", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Missing episodes object in response"])
}
// Check if we have at least one requested episode
let hasAnyRequestedEpisode = episodeNumbers.contains { episodeNumber in
return episodes["\(episodeNumber)"] != nil
}
if !hasAnyRequestedEpisode {
Logger.shared.log("None of the requested episodes were found for anilistId: \(anilistId)", type: "Error")
throw NSError(domain: "com.sora.metadata", code: 5,
userInfo: [NSLocalizedDescriptionKey: "None of the requested episodes were found"])
}
// Process each requested episode
var results: [Int: EpisodeMetadataInfo] = [:]
var missingEpisodes: [Int] = []
var episodesWithMissingFields: [String] = []
for episodeNumber in episodeNumbers {
let episodeKey = "\(episodeNumber)"
// Check if this episode exists in the response
if let episodeDetails = episodes[episodeKey] as? [String: Any] {
var title: [String: String] = [:]
var image: String = ""
var missingFields: [String] = []
// Try to get title
if let titleData = episodeDetails["title"] as? [String: String] {
title = titleData
// Check if we have valid title values
if title.isEmpty || title.values.allSatisfy({ $0.isEmpty }) {
missingFields.append("title (all values empty)")
}
} else {
missingFields.append("title")
// Create default empty title dictionary
title = ["en": "Episode \(episodeNumber)"]
}
// Try to get image
if let imageUrl = episodeDetails["image"] as? String {
image = imageUrl
if imageUrl.isEmpty {
missingFields.append("image (empty string)")
}
} else {
missingFields.append("image")
// Use a default placeholder image
image = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png"
}
// Log if we're missing any fields
if !missingFields.isEmpty {
episodesWithMissingFields.append("Episode \(episodeNumber): missing \(missingFields.joined(separator: ", "))")
}
// Create metadata object with whatever we have
let metadataInfo = EpisodeMetadataInfo(
title: title,
imageUrl: image,
anilistId: anilistId,
episodeNumber: episodeNumber
)
results[episodeNumber] = metadataInfo
// Cache the metadata
if MetadataCacheManager.shared.isCachingEnabled {
let metadata = EpisodeMetadata(
title: title,
imageUrl: image,
anilistId: anilistId,
episodeNumber: episodeNumber
)
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
if let metadataData = metadata.toData() {
MetadataCacheManager.shared.storeMetadata(
metadataData,
forKey: cacheKey
)
}
}
} else {
missingEpisodes.append(episodeNumber)
}
}
// Log information about missing episodes
if !missingEpisodes.isEmpty {
Logger.shared.log("Episodes not found in response: \(missingEpisodes.map { String($0) }.joined(separator: ", "))", type: "Warning")
}
// Log information about episodes with missing fields
if !episodesWithMissingFields.isEmpty {
Logger.shared.log("Episodes with missing fields: \(episodesWithMissingFields.joined(separator: "; "))", type: "Warning")
}
// If we didn't get data for all requested episodes but got some, consider it a partial success
if results.count < episodeNumbers.count && results.count > 0 {
Logger.shared.log("Partial data received: \(results.count)/\(episodeNumbers.count) episodes", type: "Warning")
}
// If we didn't get any valid results, throw an error to trigger retry
if results.isEmpty {
throw NSError(domain: "com.sora.metadata", code: 7,
userInfo: [NSLocalizedDescriptionKey: "No valid episode data found in response"])
}
// Reset retry count on success (even partial)
self.currentRetryAttempts.removeValue(forKey: batchCacheKey)
return results
}
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
// Handle completion
guard let self = self else { return }
switch result {
case .finished:
break
case .failure(let error):
// Handle retry logic
var shouldRetry = false
let currentAttempt = self.currentRetryAttempts[batchCacheKey] ?? 0
// Check if we should retry based on the error and attempt count
if currentAttempt < self.maxRetryAttempts {
// Increment attempt counter
let nextAttempt = currentAttempt + 1
self.currentRetryAttempts[batchCacheKey] = nextAttempt
// Calculate backoff delay using exponential backoff
let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(currentAttempt))
Logger.shared.log("Batch fetch failed, retrying (attempt \(nextAttempt)/\(self.maxRetryAttempts)) in \(backoffDelay) seconds", type: "Debug")
// Schedule retry after backoff delay
DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) {
// Remove the current request before retrying
self.activeRequests.removeValue(forKey: batchCacheKey)
self.fetchBatchFromNetwork(anilistId: anilistId, episodeNumbers: episodeNumbers)
}
shouldRetry = true
} else {
// Max retries reached
Logger.shared.log("Batch fetch failed after \(self.maxRetryAttempts) attempts: \(error.localizedDescription)", type: "Error")
self.currentRetryAttempts.removeValue(forKey: batchCacheKey)
// Update all requested episodes with error
for episodeNumber in episodeNumbers {
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
self.metadataCache[cacheKey] = .failed(error)
}
}
if !shouldRetry {
// Remove from active requests
self.activeRequests.removeValue(forKey: batchCacheKey)
}
}
}, receiveValue: { [weak self] results in
// Update cache with results
for (episodeNumber, metadataInfo) in results {
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
self?.metadataCache[cacheKey] = .fetched(metadataInfo)
}
// Log the results
Logger.shared.log("Batch fetch completed with \(results.count) episodes", type: "Debug")
// Remove from active requests
self?.activeRequests.removeValue(forKey: batchCacheKey)
})
// Store publisher in active requests
activeRequests[batchCacheKey] = publisher
}
private func waitForRequest(cacheKey: String, completion: @escaping (Result<EpisodeMetadataInfo, Error>) -> Void) {
// Set up a timer to check the cache periodically
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
if let status = self.metadataCache[cacheKey] {
switch status {
case .fetched(let metadata):
// Request completed successfully
timer.invalidate()
completion(.success(metadata))
case .failed(let error):
// Request failed
timer.invalidate()
completion(.failure(error))
case .fetching, .notRequested:
// Still in progress
break
}
}
}
// Ensure timer fires even when scrolling
RunLoop.current.add(timer, forMode: .common)
}
}
// Extension to EpisodeMetadata for integration with the new manager
extension EpisodeMetadata {
func toData() -> Data? {
// Convert to EpisodeMetadataInfo first
let info = EpisodeMetadataInfo(
title: self.title,
imageUrl: self.imageUrl,
anilistId: self.anilistId,
episodeNumber: self.episodeNumber
)
// Then encode to Data
return try? JSONEncoder().encode(info)
}
static func fromData(_ data: Data) -> EpisodeMetadata? {
guard let info = try? JSONDecoder().decode(EpisodeMetadataInfo.self, from: data) else {
return nil
}
return EpisodeMetadata(
title: info.title,
imageUrl: info.imageUrl,
anilistId: info.anilistId,
episodeNumber: info.episodeNumber
)
}
}

View file

@ -0,0 +1,134 @@
//
// ImagePrefetchManager.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import Kingfisher
import UIKit
/// Manager for image prefetching, caching, and optimization
class ImagePrefetchManager {
static let shared = ImagePrefetchManager()
// Prefetcher for batch prefetching images
private let prefetcher = ImagePrefetcher(
urls: [],
options: [
.processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))),
.scaleFactor(UIScreen.main.scale),
.cacheOriginalImage
]
)
// Keep track of what's already prefetched to avoid duplication
private var prefetchedURLs = Set<URL>()
private let prefetchQueue = DispatchQueue(label: "com.sora.imagePrefetch", qos: .utility)
init() {
// Set up KingfisherManager for optimal image loading
ImageCache.default.memoryStorage.config.totalCostLimit = 300 * 1024 * 1024 // 300MB
ImageCache.default.diskStorage.config.sizeLimit = 1000 * 1024 * 1024 // 1GB
ImageDownloader.default.downloadTimeout = 15.0 // 15 seconds
}
/// Prefetch a batch of images
func prefetchImages(_ urls: [String]) {
prefetchQueue.async { [weak self] in
guard let self = self else { return }
// Filter out already prefetched URLs and invalid URLs
let urlObjects = urls.compactMap { URL(string: $0) }
.filter { !self.prefetchedURLs.contains($0) }
guard !urlObjects.isEmpty else { return }
// Create a new prefetcher with the URLs and start it
let newPrefetcher = ImagePrefetcher(
urls: urlObjects,
options: [
.processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))),
.scaleFactor(UIScreen.main.scale),
.cacheOriginalImage
]
)
newPrefetcher.start()
// Track prefetched URLs
urlObjects.forEach { self.prefetchedURLs.insert($0) }
}
}
/// Prefetch a single image
func prefetchImage(_ url: String) {
guard let urlObject = URL(string: url),
!prefetchedURLs.contains(urlObject) else {
return
}
prefetchQueue.async { [weak self] in
guard let self = self else { return }
// Create a new prefetcher with the URL and start it
let newPrefetcher = ImagePrefetcher(
urls: [urlObject],
options: [
.processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))),
.scaleFactor(UIScreen.main.scale),
.cacheOriginalImage
]
)
newPrefetcher.start()
// Track prefetched URL
self.prefetchedURLs.insert(urlObject)
}
}
/// Prefetch episode images for a batch of episodes
func prefetchEpisodeImages(anilistId: Int, startEpisode: Int, count: Int) {
prefetchQueue.async { [weak self] in
guard let self = self else { return }
// Get metadata for episodes in the range
for episodeNumber in startEpisode...(startEpisode + count) where episodeNumber > 0 {
EpisodeMetadataManager.shared.fetchMetadata(anilistId: anilistId, episodeNumber: episodeNumber) { result in
switch result {
case .success(let metadata):
self.prefetchImage(metadata.imageUrl)
case .failure:
break
}
}
}
}
}
/// Clear prefetch queue and stop any ongoing prefetch operations
func cancelPrefetching() {
prefetcher.stop()
}
}
// MARK: - KFImage Extension
extension KFImage {
/// Load an image with optimal settings for episode thumbnails
static func optimizedEpisodeThumbnail(url: URL?) -> KFImage {
return KFImage(url)
.setProcessor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56)))
.memoryCacheExpiration(.seconds(300))
.cacheOriginalImage()
.fade(duration: 0.25)
.onProgress { _, _ in
// Track progress if needed
}
.onSuccess { _ in
// Success logger removed to reduce logs
}
.onFailure { error in
Logger.shared.log("Failed to load image: \(error)", type: "Error")
}
}
}

View file

@ -0,0 +1,510 @@
//
// PerformanceMonitor.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import SwiftUI
import Kingfisher
import QuartzCore
/// Performance metrics tracking system with advanced jitter detection
class PerformanceMonitor: ObservableObject {
static let shared = PerformanceMonitor()
// Published properties to allow UI observation
@Published private(set) var networkRequestCount: Int = 0
@Published private(set) var cacheHitCount: Int = 0
@Published private(set) var cacheMissCount: Int = 0
@Published private(set) var averageLoadTime: TimeInterval = 0
@Published private(set) var memoryUsage: UInt64 = 0
@Published private(set) var diskUsage: UInt64 = 0
@Published private(set) var isEnabled: Bool = false
// Advanced performance metrics for jitter detection
@Published private(set) var currentFPS: Double = 60.0
@Published private(set) var mainThreadBlocks: Int = 0
@Published private(set) var memorySpikes: Int = 0
@Published private(set) var cpuUsage: Double = 0.0
@Published private(set) var jitterEvents: Int = 0
// Internal tracking properties
private var loadTimes: [TimeInterval] = []
private var startTimes: [String: Date] = [:]
private var memoryTimer: Timer?
private var logTimer: Timer?
// Advanced monitoring properties
private var displayLink: CADisplayLink?
private var frameCount: Int = 0
private var lastFrameTime: CFTimeInterval = 0
private var frameTimes: [CFTimeInterval] = []
private var lastMemoryUsage: UInt64 = 0
private var mainThreadOperations: [String: CFTimeInterval] = [:]
private var cpuTimer: Timer?
// Thresholds for performance issues
private let mainThreadBlockingThreshold: TimeInterval = 0.016 // 16ms for 60fps
private let memorySpikeTreshold: UInt64 = 50 * 1024 * 1024 // 50MB spike
private let fpsThreshold: Double = 50.0 // Below 50fps is considered poor
private init() {
// Default is off unless explicitly enabled
isEnabled = UserDefaults.standard.bool(forKey: "enablePerformanceMonitoring")
// Setup memory monitoring if enabled
if isEnabled {
startMonitoring()
}
}
// MARK: - Public Methods
/// Enable or disable the performance monitoring
func setEnabled(_ enabled: Bool) {
isEnabled = enabled
UserDefaults.standard.set(enabled, forKey: "enablePerformanceMonitoring")
if enabled {
startMonitoring()
} else {
stopMonitoring()
}
}
/// Reset all tracked metrics
func resetMetrics() {
networkRequestCount = 0
cacheHitCount = 0
cacheMissCount = 0
averageLoadTime = 0
loadTimes = []
startTimes = [:]
// Reset advanced metrics
mainThreadBlocks = 0
memorySpikes = 0
jitterEvents = 0
frameTimes = []
frameCount = 0
mainThreadOperations = [:]
updateMemoryUsage()
Logger.shared.log("Performance metrics reset", type: "Debug")
}
/// Track a network request starting
func trackRequestStart(identifier: String) {
guard isEnabled else { return }
networkRequestCount += 1
startTimes[identifier] = Date()
}
/// Track a network request completing
func trackRequestEnd(identifier: String) {
guard isEnabled, let startTime = startTimes[identifier] else { return }
let endTime = Date()
let duration = endTime.timeIntervalSince(startTime)
loadTimes.append(duration)
// Update average load time
if !loadTimes.isEmpty {
averageLoadTime = loadTimes.reduce(0, +) / Double(loadTimes.count)
}
// Remove start time to avoid memory leaks
startTimes.removeValue(forKey: identifier)
}
/// Track a cache hit
func trackCacheHit() {
guard isEnabled else { return }
cacheHitCount += 1
}
/// Track a cache miss
func trackCacheMiss() {
guard isEnabled else { return }
cacheMissCount += 1
}
// MARK: - Advanced Performance Monitoring
/// Track the start of a main thread operation
func trackMainThreadOperationStart(operation: String) {
guard isEnabled else { return }
mainThreadOperations[operation] = CACurrentMediaTime()
}
/// Track the end of a main thread operation and detect blocking
func trackMainThreadOperationEnd(operation: String) {
guard isEnabled, let startTime = mainThreadOperations[operation] else { return }
let endTime = CACurrentMediaTime()
let duration = endTime - startTime
if duration > mainThreadBlockingThreshold {
mainThreadBlocks += 1
jitterEvents += 1
let durationMs = Int(duration * 1000)
Logger.shared.log("🚨 Main thread blocked for \(durationMs)ms during: \(operation)", type: "Performance")
}
mainThreadOperations.removeValue(forKey: operation)
}
/// Track memory spikes during downloads
func checkMemorySpike() {
guard isEnabled else { return }
let currentMemory = getAppMemoryUsage()
if lastMemoryUsage > 0 {
let spike = currentMemory > lastMemoryUsage ? currentMemory - lastMemoryUsage : 0
if spike > memorySpikeTreshold {
memorySpikes += 1
jitterEvents += 1
let spikeSize = Double(spike) / (1024 * 1024)
Logger.shared.log("🚨 Memory spike detected: +\(String(format: "%.1f", spikeSize))MB", type: "Performance")
}
}
lastMemoryUsage = currentMemory
memoryUsage = currentMemory
}
/// Start frame rate monitoring
private func startFrameRateMonitoring() {
guard displayLink == nil else { return }
displayLink = CADisplayLink(target: self, selector: #selector(frameCallback))
displayLink?.add(to: .main, forMode: .common)
frameCount = 0
lastFrameTime = CACurrentMediaTime()
frameTimes = []
}
/// Stop frame rate monitoring
private func stopFrameRateMonitoring() {
displayLink?.invalidate()
displayLink = nil
}
/// Frame callback for FPS monitoring
@objc private func frameCallback() {
let currentTime = CACurrentMediaTime()
if lastFrameTime > 0 {
let frameDuration = currentTime - lastFrameTime
frameTimes.append(frameDuration)
// Keep only last 60 frames for rolling average
if frameTimes.count > 60 {
frameTimes.removeFirst()
}
// Calculate current FPS
if !frameTimes.isEmpty {
let averageFrameTime = frameTimes.reduce(0, +) / Double(frameTimes.count)
currentFPS = 1.0 / averageFrameTime
// Detect FPS drops
if currentFPS < fpsThreshold {
jitterEvents += 1
Logger.shared.log("🚨 FPS drop detected: \(String(format: "%.1f", currentFPS))fps", type: "Performance")
}
}
}
lastFrameTime = currentTime
frameCount += 1
}
/// Get current CPU usage
private func getCPUUsage() -> Double {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
if kerr == KERN_SUCCESS {
// This is a simplified CPU usage calculation
// For more accurate results, we'd need to track over time
return Double(info.user_time.seconds + info.system_time.seconds)
} else {
return 0.0
}
}
/// Get the current cache hit rate
var cacheHitRate: Double {
let total = cacheHitCount + cacheMissCount
guard total > 0 else { return 0 }
return Double(cacheHitCount) / Double(total)
}
/// Log current performance metrics
func logMetrics() {
guard isEnabled else { return }
checkMemorySpike()
let hitRate = String(format: "%.1f%%", cacheHitRate * 100)
let avgLoad = String(format: "%.2f", averageLoadTime)
let memory = String(format: "%.1f MB", Double(memoryUsage) / (1024 * 1024))
let disk = String(format: "%.1f MB", Double(diskUsage) / (1024 * 1024))
let fps = String(format: "%.1f", currentFPS)
let cpu = String(format: "%.1f%%", cpuUsage)
let metrics = """
📊 Performance Metrics Report:
Network & Cache:
- Network Requests: \(networkRequestCount)
- Cache Hit Rate: \(hitRate) (\(cacheHitCount)/\(cacheHitCount + cacheMissCount))
- Average Load Time: \(avgLoad)s
System Resources:
- Memory Usage: \(memory)
- Disk Usage: \(disk)
- CPU Usage: \(cpu)
Performance Issues:
- Current FPS: \(fps)
- Main Thread Blocks: \(mainThreadBlocks)
- Memory Spikes: \(memorySpikes)
- Total Jitter Events: \(jitterEvents)
"""
Logger.shared.log(metrics, type: "Performance")
// Alert if performance is poor
if jitterEvents > 0 {
Logger.shared.log("⚠️ Performance issues detected! Check logs above for details.", type: "Warning")
}
}
// MARK: - Private Methods
private func startMonitoring() {
// Setup timer to update memory usage periodically and check for spikes
memoryTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
self?.checkMemorySpike()
}
// Setup timer to log metrics periodically
logTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.logMetrics()
}
// Setup CPU monitoring timer
cpuTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
self?.cpuUsage = self?.getCPUUsage() ?? 0.0
}
// Make sure timers run even when scrolling
RunLoop.current.add(memoryTimer!, forMode: .common)
RunLoop.current.add(logTimer!, forMode: .common)
RunLoop.current.add(cpuTimer!, forMode: .common)
// Start frame rate monitoring
startFrameRateMonitoring()
Logger.shared.log("Advanced performance monitoring started - tracking FPS, main thread blocks, memory spikes", type: "Debug")
}
private func stopMonitoring() {
memoryTimer?.invalidate()
memoryTimer = nil
logTimer?.invalidate()
logTimer = nil
cpuTimer?.invalidate()
cpuTimer = nil
stopFrameRateMonitoring()
Logger.shared.log("Performance monitoring stopped", type: "Debug")
}
private func updateMemoryUsage() {
memoryUsage = getAppMemoryUsage()
diskUsage = getCacheDiskUsage()
}
private func getAppMemoryUsage() -> UInt64 {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
if kerr == KERN_SUCCESS {
return info.resident_size
} else {
return 0
}
}
private func getCacheDiskUsage() -> UInt64 {
// Try to get Kingfisher's disk cache size
let diskCache = ImageCache.default.diskStorage
do {
let size = try diskCache.totalSize()
return UInt64(size)
} catch {
Logger.shared.log("Failed to get disk cache size: \(error)", type: "Error")
return 0
}
}
}
// MARK: - Extensions to integrate with managers
extension EpisodeMetadataManager {
/// Integrate performance tracking
func trackFetchStart(anilistId: Int, episodeNumber: Int) {
let identifier = "metadata_\(anilistId)_\(episodeNumber)"
PerformanceMonitor.shared.trackRequestStart(identifier: identifier)
}
func trackFetchEnd(anilistId: Int, episodeNumber: Int) {
let identifier = "metadata_\(anilistId)_\(episodeNumber)"
PerformanceMonitor.shared.trackRequestEnd(identifier: identifier)
}
func trackCacheHit() {
PerformanceMonitor.shared.trackCacheHit()
}
func trackCacheMiss() {
PerformanceMonitor.shared.trackCacheMiss()
}
}
extension ImagePrefetchManager {
/// Integrate performance tracking
func trackImageLoadStart(url: String) {
let identifier = "image_\(url.hashValue)"
PerformanceMonitor.shared.trackRequestStart(identifier: identifier)
}
func trackImageLoadEnd(url: String) {
let identifier = "image_\(url.hashValue)"
PerformanceMonitor.shared.trackRequestEnd(identifier: identifier)
}
func trackImageCacheHit() {
PerformanceMonitor.shared.trackCacheHit()
}
func trackImageCacheMiss() {
PerformanceMonitor.shared.trackCacheMiss()
}
}
// MARK: - Debug View
struct PerformanceMetricsView: View {
@ObservedObject private var monitor = PerformanceMonitor.shared
@State private var isExpanded = false
var body: some View {
VStack {
HStack {
Text("Performance Metrics")
.font(.headline)
Spacer()
Button(action: {
isExpanded.toggle()
}) {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
}
}
.padding(.horizontal)
if isExpanded {
VStack(alignment: .leading, spacing: 4) {
Text("Network Requests: \(monitor.networkRequestCount)")
Text("Cache Hit Rate: \(Int(monitor.cacheHitRate * 100))%")
Text("Avg Load Time: \(String(format: "%.2f", monitor.averageLoadTime))s")
Text("Memory: \(String(format: "%.1f MB", Double(monitor.memoryUsage) / (1024 * 1024)))")
Divider()
// Advanced metrics
Text("FPS: \(String(format: "%.1f", monitor.currentFPS))")
.foregroundColor(monitor.currentFPS < 50 ? .red : .primary)
Text("Main Thread Blocks: \(monitor.mainThreadBlocks)")
.foregroundColor(monitor.mainThreadBlocks > 0 ? .red : .primary)
Text("Memory Spikes: \(monitor.memorySpikes)")
.foregroundColor(monitor.memorySpikes > 0 ? .orange : .primary)
Text("Jitter Events: \(monitor.jitterEvents)")
.foregroundColor(monitor.jitterEvents > 0 ? .red : .primary)
HStack {
Button(action: {
monitor.resetMetrics()
}) {
Text("Reset")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(4)
}
Button(action: {
monitor.logMetrics()
}) {
Text("Log")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.green)
.foregroundColor(.white)
.cornerRadius(4)
}
Toggle("", isOn: Binding(
get: { monitor.isEnabled },
set: { monitor.setEnabled($0) }
))
.labelsHidden()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.bottom, 8)
.background(Color.secondary.opacity(0.1))
.cornerRadius(8)
.padding(.horizontal)
}
}
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.05))
.cornerRadius(8)
.padding(8)
}
}

View file

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

View file

@ -6,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"
}
}

View file

@ -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()
}

View file

@ -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 }

View file

@ -0,0 +1,45 @@
//
// EpisodeMetadata.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
/// Represents metadata for an episode, used for caching
struct EpisodeMetadata: Codable {
/// Title of the episode
let title: [String: String]
/// Image URL for the episode
let imageUrl: String
/// AniList ID of the show
let anilistId: Int
/// Episode number
let episodeNumber: Int
/// When this metadata was cached
let cacheDate: Date
/// Unique cache key for this episode metadata
var cacheKey: String {
return "anilist_\(anilistId)_episode_\(episodeNumber)"
}
/// Initialize with the basic required data
/// - Parameters:
/// - title: Dictionary of titles by language code
/// - imageUrl: URL of the episode thumbnail image
/// - anilistId: ID of the show in AniList
/// - episodeNumber: Number of the episode
init(title: [String: String], imageUrl: String, anilistId: Int, episodeNumber: Int) {
self.title = title
self.imageUrl = imageUrl
self.anilistId = anilistId
self.episodeNumber = episodeNumber
self.cacheDate = Date()
}
}

View file

@ -0,0 +1,95 @@
//
// KingfisherManager.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import Kingfisher
import SwiftUI
/// Manages Kingfisher image caching configuration
class KingfisherCacheManager {
static let shared = KingfisherCacheManager()
/// Maximum disk cache size (default 500MB)
private let maxDiskCacheSize: UInt = 500 * 1024 * 1024
/// Maximum cache age (default 7 days)
private let maxCacheAgeInDays: TimeInterval = 7
/// UserDefaults keys
private let imageCachingEnabledKey = "imageCachingEnabled"
/// Whether image caching is enabled
var isCachingEnabled: Bool {
get {
// Default to true if not set
UserDefaults.standard.object(forKey: imageCachingEnabledKey) == nil ?
true : UserDefaults.standard.bool(forKey: imageCachingEnabledKey)
}
set {
UserDefaults.standard.set(newValue, forKey: imageCachingEnabledKey)
configureKingfisher()
}
}
private init() {
configureKingfisher()
}
/// Configure Kingfisher with appropriate caching settings
func configureKingfisher() {
let cache = ImageCache.default
// Set disk cache size limit and expiration
cache.diskStorage.config.sizeLimit = isCachingEnabled ? maxDiskCacheSize : 0
cache.diskStorage.config.expiration = isCachingEnabled ?
.days(Int(maxCacheAgeInDays)) : .seconds(1) // 1 second means effectively disabled
// Set memory cache size
cache.memoryStorage.config.totalCostLimit = isCachingEnabled ?
30 * 1024 * 1024 : 0 // 30MB memory cache when enabled
// Configure clean interval
cache.memoryStorage.config.cleanInterval = 60 // Clean memory every 60 seconds
// Configure retry strategy
KingfisherManager.shared.downloader.downloadTimeout = 15.0 // 15 second timeout
Logger.shared.log("Configured Kingfisher cache. Enabled: \(isCachingEnabled)", type: "Debug")
}
/// Clear all cached images
func clearCache(completion: (() -> Void)? = nil) {
KingfisherManager.shared.cache.clearMemoryCache()
KingfisherManager.shared.cache.clearDiskCache {
Logger.shared.log("Cleared Kingfisher image cache", type: "General")
completion?()
}
}
/// Calculate current cache size
/// - Parameter completion: Closure to call with cache size in bytes
func calculateCacheSize(completion: @escaping (UInt) -> Void) {
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
switch result {
case .success(let size):
completion(size)
case .failure(let error):
Logger.shared.log("Failed to calculate image cache size: \(error)", type: "Error")
completion(0)
}
}
}
/// Convert cache size to user-friendly string
/// - Parameter sizeInBytes: Size in bytes
/// - Returns: Formatted string (e.g., "5.2 MB")
static func formatCacheSize(_ sizeInBytes: UInt) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: Int64(sizeInBytes))
}
}

View file

@ -0,0 +1,276 @@
//
// MetadataCacheManager.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import SwiftUI
/// A class to manage episode metadata caching, both in-memory and on disk
class MetadataCacheManager {
static let shared = MetadataCacheManager()
// In-memory cache
private let memoryCache = NSCache<NSString, NSData>()
// File manager for disk operations
private let fileManager = FileManager.default
// Cache directory URL
private var cacheDirectory: URL
// Cache expiration - 7 days by default
private let maxCacheAge: TimeInterval = 7 * 24 * 60 * 60
// UserDefaults keys
private let metadataCachingEnabledKey = "metadataCachingEnabled"
private let memoryOnlyModeKey = "metadataMemoryOnlyCache"
private let lastCacheCleanupKey = "lastMetadataCacheCleanup"
// Analytics counters
private(set) var cacheHits: Int = 0
private(set) var cacheMisses: Int = 0
// MARK: - Public properties
/// Whether metadata caching is enabled (persisted in UserDefaults)
var isCachingEnabled: Bool {
get {
// Default to true if not set
UserDefaults.standard.object(forKey: metadataCachingEnabledKey) == nil ?
true : UserDefaults.standard.bool(forKey: metadataCachingEnabledKey)
}
set {
UserDefaults.standard.set(newValue, forKey: metadataCachingEnabledKey)
}
}
/// Whether to use memory-only mode (no disk caching)
var isMemoryOnlyMode: Bool {
get {
UserDefaults.standard.bool(forKey: memoryOnlyModeKey)
}
set {
UserDefaults.standard.set(newValue, forKey: memoryOnlyModeKey)
}
}
// MARK: - Initialization
private init() {
// Set up cache directory
do {
let cachesDirectory = try fileManager.url(
for: .cachesDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
cacheDirectory = cachesDirectory.appendingPathComponent("EpisodeMetadata", isDirectory: true)
// Create the directory if it doesn't exist
if !fileManager.fileExists(atPath: cacheDirectory.path) {
try fileManager.createDirectory(at: cacheDirectory,
withIntermediateDirectories: true,
attributes: nil)
}
// Set up memory cache
memoryCache.name = "EpisodeMetadataCache"
memoryCache.countLimit = 100 // Limit number of items in memory
// Clean up old files if needed
cleanupOldCacheFilesIfNeeded()
} catch {
Logger.shared.log("Failed to set up metadata cache directory: \(error)", type: "Error")
// Fallback to temporary directory
cacheDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("EpisodeMetadata")
}
}
// MARK: - Public Methods
/// Store metadata in the cache
/// - Parameters:
/// - data: The metadata to cache
/// - key: The cache key (usually anilist_id + episode_number)
func storeMetadata(_ data: Data, forKey key: String) {
guard isCachingEnabled else { return }
let keyString = key as NSString
// Always store in memory cache
memoryCache.setObject(data as NSData, forKey: keyString)
// Store on disk if not in memory-only mode
if !isMemoryOnlyMode {
let fileURL = cacheDirectory.appendingPathComponent(key)
DispatchQueue.global(qos: .background).async { [weak self] in
do {
try data.write(to: fileURL)
// Add timestamp as a file attribute instead of using extended attributes
let attributes: [FileAttributeKey: Any] = [
.creationDate: Date()
]
try self?.fileManager.setAttributes(attributes, ofItemAtPath: fileURL.path)
Logger.shared.log("Metadata cached for key: \(key)", type: "Debug")
} catch {
Logger.shared.log("Failed to write metadata to disk: \(error)", type: "Error")
}
}
}
}
/// Retrieve metadata from cache
/// - Parameter key: The cache key
/// - Returns: The cached metadata if available and not expired, nil otherwise
func getMetadata(forKey key: String) -> Data? {
guard isCachingEnabled else {
return nil
}
let keyString = key as NSString
// Try memory cache first
if let cachedData = memoryCache.object(forKey: keyString) as Data? {
return cachedData
}
// If not in memory and not in memory-only mode, try disk
if !isMemoryOnlyMode {
let fileURL = cacheDirectory.appendingPathComponent(key)
do {
// Check if file exists
if fileManager.fileExists(atPath: fileURL.path) {
// Check if the file is not expired
if !isFileExpired(at: fileURL) {
let data = try Data(contentsOf: fileURL)
// Store in memory cache for faster access next time
memoryCache.setObject(data as NSData, forKey: keyString)
return data
} else {
// File is expired, remove it
try fileManager.removeItem(at: fileURL)
}
}
} catch {
Logger.shared.log("Error accessing disk cache: \(error)", type: "Error")
}
}
return nil
}
/// Clear all cached metadata
func clearAllCache() {
// Clear memory cache
memoryCache.removeAllObjects()
// Clear disk cache
if !isMemoryOnlyMode {
do {
let fileURLs = try fileManager.contentsOfDirectory(at: cacheDirectory,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles)
for fileURL in fileURLs {
try fileManager.removeItem(at: fileURL)
}
Logger.shared.log("Cleared all metadata cache", type: "General")
} catch {
Logger.shared.log("Failed to clear disk cache: \(error)", type: "Error")
}
}
// Reset analytics
cacheHits = 0
cacheMisses = 0
}
/// Clear expired cache entries
func clearExpiredCache() {
guard !isMemoryOnlyMode else { return }
do {
let fileURLs = try fileManager.contentsOfDirectory(at: cacheDirectory,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles)
var removedCount = 0
for fileURL in fileURLs {
if isFileExpired(at: fileURL) {
try fileManager.removeItem(at: fileURL)
removedCount += 1
}
}
if removedCount > 0 {
Logger.shared.log("Cleared \(removedCount) expired metadata cache items", type: "General")
}
} catch {
Logger.shared.log("Failed to clear expired cache: \(error)", type: "Error")
}
}
/// Get the total size of the cache on disk
/// - Returns: Size in bytes
func getCacheSize() -> Int64 {
guard !isMemoryOnlyMode else { return 0 }
do {
let fileURLs = try fileManager.contentsOfDirectory(at: cacheDirectory,
includingPropertiesForKeys: [.fileSizeKey],
options: .skipsHiddenFiles)
return fileURLs.reduce(0) { result, url in
do {
let attributes = try url.resourceValues(forKeys: [.fileSizeKey])
return result + Int64(attributes.fileSize ?? 0)
} catch {
return result
}
}
} catch {
Logger.shared.log("Failed to calculate cache size: \(error)", type: "Error")
return 0
}
}
// MARK: - Private Helper Methods
private func isFileExpired(at url: URL) -> Bool {
do {
let attributes = try fileManager.attributesOfItem(atPath: url.path)
if let creationDate = attributes[.creationDate] as? Date {
return Date().timeIntervalSince(creationDate) > maxCacheAge
}
return true // If can't determine age, consider it expired
} catch {
return true // If error reading attributes, consider it expired
}
}
private func cleanupOldCacheFilesIfNeeded() {
// Only run cleanup once a day
let lastCleanupTime = UserDefaults.standard.double(forKey: lastCacheCleanupKey)
let dayInSeconds: TimeInterval = 24 * 60 * 60
if Date().timeIntervalSince1970 - lastCleanupTime > dayInSeconds {
DispatchQueue.global(qos: .background).async { [weak self] in
self?.clearExpiredCache()
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: self?.lastCacheCleanupKey ?? "")
}
}
}
}

View file

@ -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
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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")
}

View file

@ -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":

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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)")
}
}
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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))
}

View file

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

View file

@ -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)

View file

@ -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 {

View file

@ -131,7 +131,7 @@ struct ModuleAdditionSettingsView: View {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Cancel")
.foregroundColor(.accentColor)
.foregroundColor((Color.accentColor))
.padding(.top, 10)
}
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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))

View file

@ -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
}
}
}
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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
}
}

View file

@ -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)
}
}

View file

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

View file

@ -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()
}))

View file

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

View file

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

View file

@ -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 = "<group>"; };
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = "<group>"; };
131270162DC13A010093AA9C /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
131270182DC13A3C0093AA9C /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = "<group>"; };
131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = "<group>"; };
1327FBA62D758CEA00FC6689 /* Analytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = "<group>"; };
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = "<group>"; };
@ -98,6 +113,7 @@
133F55BA2D33B55100E08EEA /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = "<group>"; };
1359ED132D76F49900C13034 /* finTopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = finTopView.swift; sourceTree = "<group>"; };
135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = "<group>"; };
13637B892DE0EA1100BDA2FC /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = "<group>"; };
136BBE7F2DB1038000906B5E /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = "<group>"; };
138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = "<group>"; };
138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = "<group>"; };
@ -129,6 +145,22 @@
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = "<group>"; };
7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadata.swift; sourceTree = "<group>"; };
7205AED82DCCEF9500943F3F /* KingfisherManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherManager.swift; sourceTree = "<group>"; };
7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataCacheManager.swift; sourceTree = "<group>"; };
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = "<group>"; };
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = "<group>"; };
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = "<group>"; };
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+M3U8Download.swift"; sourceTree = "<group>"; };
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Downloads.swift"; sourceTree = "<group>"; };
72443C7C2DC8036500A61321 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = "<group>"; };
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = "<group>"; };
7272206D2DD6336100C2A4A2 /* SettingsViewPerformance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPerformance.swift; sourceTree = "<group>"; };
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = "<group>"; };
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = "<group>"; };
72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadataManager.swift; sourceTree = "<group>"; };
72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePrefetchManager.swift; sourceTree = "<group>"; };
72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMonitor.swift; sourceTree = "<group>"; };
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -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 = "<group>";
};
131270152DC139CD0093AA9C /* DownloadManager */ = {
isa = PBXGroup;
children = (
131270162DC13A010093AA9C /* DownloadManager.swift */,
);
path = DownloadManager;
sourceTree = "<group>";
};
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 = "<group>";
@ -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 = "<group>";
@ -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 = "<group>";
@ -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 = "<group>";
};
7205AEDA2DCCEF9500943F3F /* Cache */ = {
isa = PBXGroup;
children = (
7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */,
7205AED82DCCEF9500943F3F /* KingfisherManager.swift */,
7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */,
);
path = Cache;
sourceTree = "<group>";
};
72443C832DC8046500A61321 /* DownloadUtils */ = {
isa = PBXGroup;
children = (
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */,
131270162DC13A010093AA9C /* DownloadManager.swift */,
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */,
);
path = DownloadUtils;
sourceTree = "<group>";
};
72AC3A002DD4DAEA00C60B96 /* Managers */ = {
isa = PBXGroup;
children = (
72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */,
72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */,
72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */,
);
path = Managers;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -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 */

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 MiB

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 969 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 KiB

After

Width:  |  Height:  |  Size: 723 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

34
macbuild.sh Executable file
View file

@ -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"