downloads (#130)
32
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ Frameworks:
|
|||
|
||||
Misc:
|
||||
- [50/50](https://github.com/50n50) for the app icon
|
||||
- Ciro for the episode banner images
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 77 KiB |
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -38,7 +38,5 @@
|
|||
<string>audio</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UISupportsDocumentBrowser</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
646
Sora/Managers/EpisodeMetadataManager.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
134
Sora/Managers/ImagePrefetchManager.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
510
Sora/Managers/PerformanceMonitor.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
45
Sora/Utils/Cache/EpisodeMetadata.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
95
Sora/Utils/Cache/KingfisherManager.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
276
Sora/Utils/Cache/MetadataCacheManager.swift
Normal 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 ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
95
Sora/Utils/DownloadUtils/DownloadManager.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
637
Sora/Utils/DownloadUtils/DownloadModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
337
Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
33
Sora/Utils/Extensions/UserDefaults.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
391
Sora/Utils/JSLoader/JSController+M3U8Download.swift
Normal 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
|
||||
}
|
||||
273
Sora/Utils/JSLoader/JSController+MP4Download.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
1555
Sora/Utils/JSLoader/JSController-Downloads.swift
Normal file
166
Sora/Utils/JSLoader/JSController-HeaderManager.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
Sora/Utils/JSLoader/JSController-StreamTypeDownload.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ struct ModuleAdditionSettingsView: View {
|
|||
self.presentationMode.wrappedValue.dismiss()
|
||||
}) {
|
||||
Text("Cancel")
|
||||
.foregroundColor(.accentColor)
|
||||
.foregroundColor((Color.accentColor))
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.5 MiB After Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 969 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 932 KiB After Width: | Height: | Size: 723 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.2 MiB |
34
macbuild.sh
Executable 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"
|
||||