From 4b836d63310292a6c5b6eecb7730534f50a3ef71 Mon Sep 17 00:00:00 2001 From: scigward <162128369+scigward@users.noreply.github.com> Date: Sat, 23 Aug 2025 16:53:07 +0300 Subject: [PATCH] Add filler episodes support using Jikan API + Episode Intro & Outro timestamp download (#235) * Update EpisodeCell.swift * test * Change the filler API to @cufiy API * Quick fix * Quick fix 2 * my bad * Test jikan API filler implementation * Maybe works now * Update AnilistMatchView.swift * Update EpisodeCell.swift * Update EpisodeCell.swift * Update AnilistMatchView.swift * Update AnilistMatchView.swift * Update EpisodeCell.swift * Create buildlogs.txt * Maybe work now * please work * d * Fix * I understand it now * Quick fix 3 * Hopefully works now. * Quick fix * Hope * Maybe this will work * Maybe fix * My bad * Please work * Fix * Improve filler logic * Deepseek fault this time * I don't know if this will work or not * Fix * Revert to "fix" * Update DownloadView.swift * Maybe this works * Probably fix build issues * Rewrote & removed extra code * Probably fix filler fetching issue * experimental OP & ED timestamp download with downloads * Maybe fix build issues * Hopefully builds fine now * : p * Please work * revert to "Probably fix filler fetching" * Test * hhh * Test * maybe fix build issues * Please * please x2 * test * it should build now I guess * I'm sure it will build now (please) * I'm going insane * maybe fix * Maybe fix x2 * I'm going insane x2 * SOS * Revert to "im going insane" * fix * Hmm * Possibily works now * I'm going to sleep * I'm fr going to sleep after this * fr fr this time * fix * fix x2 * fix x3 * fix * fix * quick fix * Update ContentView.swift * Delete buildlogs.txt * Update EpisodeCell.swift * Update EpisodeCell.swift * Update AnilistMatchView.swift * Update --- .../CustomPlayer/CustomPlayer.swift | 49 ++ .../DownloadUtils/DownloadModels.swift | 6 +- .../Downloads/JSController+Downloader.swift | 56 +- .../Downloads/JSController-Downloads.swift | 516 +++++++++++++----- .../JSController-StreamTypeDownload.swift | 13 +- Sora/Views/DownloadView.swift | 47 +- .../EpisodeCell/EpisodeCell.swift | 65 ++- .../Matching/AnilistMatchView.swift | 18 +- Sora/Views/MediaInfoView/MediaInfoView.swift | 231 +++++++- 9 files changed, 834 insertions(+), 167 deletions(-) diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index 6a57dc0..14edb42 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -300,6 +300,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } asset = AVURLAsset(url: url) + // Try to load OP/ED skip sidecar for local files + self.loadLocalSkipSidecar(for: url) } else { Logger.shared.log("Loading remote URL: \(url.absoluteString)", type: "Debug") var request = URLRequest(url: url) @@ -2648,6 +2650,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } asset = AVURLAsset(url: url) + // Try to load OP/ED skip sidecar for local files + self.loadLocalSkipSidecar(for: url) } else { Logger.shared.log("Switching to remote URL: \(url.absoluteString)", type: "Debug") var request = URLRequest(url: url) @@ -3864,3 +3868,48 @@ class GradientBlurButton: UIButton { super.removeFromSuperview() } } + + + /// Load OP/ED skip data from a simple sidecar JSON saved next to the local video (if present) +extension CustomMediaPlayerViewController { + + private struct SkipSidecar: Decodable { + struct Interval: Decodable { + let start_time: Double + let end_time: Double + } + struct Result: Decodable { + let interval: Interval + let skip_type: String + } + let results: [Result] + } + + func loadLocalSkipSidecar(for fileURL: URL) { + let sidecarURL = fileURL.deletingPathExtension().appendingPathExtension("skip.json") + do { + let data = try Data(contentsOf: sidecarURL) + let model = try JSONDecoder().decode(SkipSidecar.self, from: data) + for r in model.results { + let range = CMTimeRange( + start: CMTime(seconds: r.interval.start_time, preferredTimescale: 600), + end: CMTime(seconds: r.interval.end_time, preferredTimescale: 600) + ) + switch r.skip_type.lowercased() { + case "op": + self.skipIntervals.op = range + case "ed": + self.skipIntervals.ed = range + default: + break + } + } + if self.duration > 0 { + self.updateSegments() + self.updateSkipButtonsVisibility() + } + } catch { + print("[Player] No local skip sidecar found or failed to load: \(error.localizedDescription)") + } + } +} diff --git a/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift b/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift index 685a943..894d176 100644 --- a/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift +++ b/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift @@ -413,6 +413,8 @@ struct AssetMetadata: Codable { let showPosterURL: URL? // Main show poster URL (distinct from episode-specific images) let episodeTitle: String? let seasonNumber: Int? + /// Indicates whether this episode is a filler (derived from metadata at download time) + let isFiller: Bool? init( title: String, @@ -425,7 +427,8 @@ struct AssetMetadata: Codable { episode: Int? = nil, showPosterURL: URL? = nil, episodeTitle: String? = nil, - seasonNumber: Int? = nil + seasonNumber: Int? = nil, + isFiller: Bool? = nil ) { self.title = title self.overview = overview @@ -438,6 +441,7 @@ struct AssetMetadata: Codable { self.showPosterURL = showPosterURL self.episodeTitle = episodeTitle self.seasonNumber = seasonNumber + self.isFiller = isFiller } } diff --git a/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift index 810a917..2639d00 100644 --- a/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift +++ b/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift @@ -20,10 +20,13 @@ struct DownloadRequest { let episode: Int? let subtitleURL: URL? let showPosterURL: URL? + let aniListID: Int? + let malID: Int? + let isFiller: Bool? init(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) { + episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = nil, aniListID: Int? = nil, malID: Int? = nil, isFiller: Bool? = nil) { self.url = url self.headers = headers self.title = title @@ -34,6 +37,9 @@ struct DownloadRequest { self.episode = episode self.subtitleURL = subtitleURL self.showPosterURL = showPosterURL + self.aniListID = aniListID + self.malID = malID + self.isFiller = isFiller } } @@ -55,12 +61,15 @@ extension JSController { imageURL: URL? = nil, isEpisode: Bool = false, showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = nil, + aniListID: Int? = nil, malID: Int? = nil, isFiller: Bool? = nil, completionHandler: ((Bool, String) -> Void)? = nil) { + let request = DownloadRequest( url: url, headers: headers, title: title, imageURL: imageURL, isEpisode: isEpisode, showTitle: showTitle, season: season, - episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL + episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL, + aniListID: aniListID, malID: malID, isFiller: isFiller ) logDownloadStart(request: request) @@ -93,11 +102,19 @@ extension JSController { if let qualityURL = URL(string: selectedQuality.url) { let qualityRequest = DownloadRequest( - url: qualityURL, headers: request.headers, title: request.title, - imageURL: request.imageURL, isEpisode: request.isEpisode, - showTitle: request.showTitle, season: request.season, - episode: request.episode, subtitleURL: request.subtitleURL, - showPosterURL: request.showPosterURL + url: qualityURL, + headers: request.headers, + title: request.title, + imageURL: request.imageURL, + isEpisode: request.isEpisode, + showTitle: request.showTitle, + season: request.season, + episode: request.episode, + subtitleURL: request.subtitleURL, + showPosterURL: request.showPosterURL, + aniListID: request.aniListID, + malID: request.malID, + isFiller: request.isFiller ) self.downloadWithOriginalMethod(request: qualityRequest, completionHandler: completionHandler) } else { @@ -122,15 +139,17 @@ extension JSController { 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, showPosterURL: URL? = nil, - completionHandler: ((Bool, String) -> Void)? = nil) { + imageURL: URL? = nil, isEpisode: Bool = false, showTitle: String? = nil, + season: Int? = nil, episode: Int? = nil, subtitleURL: URL? = nil, + showPosterURL: URL? = nil, aniListID: Int? = nil, malID: Int? = nil, isFiller: Bool? = nil, + completionHandler: ((Bool, String) -> Void)? = nil) { + let request = DownloadRequest( url: url, headers: headers, title: title, imageURL: imageURL, isEpisode: isEpisode, showTitle: showTitle, season: season, - episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL + episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL, + aniListID: aniListID, malID: malID, isFiller: isFiller ) downloadMP4(request: request, completionHandler: completionHandler) @@ -360,7 +379,10 @@ extension JSController { showTitle: request.showTitle, season: request.season, episode: request.episode, - showPosterURL: request.showPosterURL ?? request.imageURL + showPosterURL: request.showPosterURL ?? request.imageURL, + episodeTitle: nil, + seasonNumber: nil, + isFiller: request.isFiller ) } @@ -381,7 +403,10 @@ extension JSController { subtitleURL: request.subtitleURL, asset: asset, headers: request.headers, - module: nil + module: nil, + aniListID: request.aniListID, + malID: request.malID, + isFiller: request.isFiller ) } @@ -408,6 +433,9 @@ extension JSController { episode: request.episode, subtitleURL: request.subtitleURL, showPosterURL: request.showPosterURL, + aniListID: request.aniListID, + malID: request.malID, + isFiller: request.isFiller, completionHandler: completionHandler ) } diff --git a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift index 024d44a..b24ba77 100644 --- a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift +++ b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift @@ -44,25 +44,25 @@ extension JSController { func initializeDownloadSession() { #if targetEnvironment(simulator) - Logger.shared.log("Download Sessions are not available on Simulator", type: "Error") + Logger.shared.log("Download Sessions are not available on Simulator", type: "Download") #else Task { let sessionIdentifier = "hls-downloader-\(UUID().uuidString)" - + let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier) - + configuration.allowsCellularAccess = true configuration.shouldUseExtendedBackgroundIdleMode = true configuration.waitsForConnectivity = true - + await MainActor.run { self.downloadURLSession = AVAssetDownloadURLSession( configuration: configuration, assetDownloadDelegate: self, delegateQueue: .main ) - - print("Download session initialized with ID: \(sessionIdentifier)") + + Logger.shared.log("Download session initialized with ID: \(sessionIdentifier)", type: "Download") } } #endif @@ -73,7 +73,7 @@ extension JSController { /// Sets up JavaScript download function if needed func setupDownloadFunction() { // No JavaScript-side setup needed for now - print("Download function setup completed") + Logger.shared.log("Download function setup completed", type: "Download") } /// Helper function to post download notifications with proper naming @@ -114,6 +114,9 @@ extension JSController { subtitleURL: URL? = nil, showPosterURL: URL? = nil, module: ScrapingModule? = nil, + aniListID: Int? = nil, + malID: Int? = nil, + isFiller: Bool? = nil, completionHandler: ((Bool, String) -> Void)? = nil ) { // If a module is provided, use the stream type aware download @@ -131,6 +134,9 @@ extension JSController { episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL, + aniListID: aniListID, + malID: malID, + isFiller: isFiller, completionHandler: completionHandler ) return @@ -156,7 +162,8 @@ extension JSController { showTitle: animeTitle, season: season, episode: episode, - showPosterURL: showPosterURL // Main show poster + showPosterURL: showPosterURL, // Main show poster + isFiller: isFiller ) // Create the download ID now so we can use it for notifications @@ -177,7 +184,10 @@ extension JSController { subtitleURL: subtitleURL, asset: asset, headers: headers, - module: module // Pass the module to store it for queue processing + module: module, + aniListID: aniListID, + malID: malID, + isFiller: isFiller ) // Add to the download queue @@ -211,7 +221,7 @@ extension JSController { // Check if download session is ready before processing queue guard downloadURLSession != nil else { - print("Download session not ready, deferring queue processing...") + Logger.shared.log("Download session not ready, deferring queue processing...", type: "Download") isProcessingQueue = false // Retry after a delay to allow session initialization DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in @@ -262,11 +272,11 @@ extension JSController { /// Start a previously queued download private func startQueuedDownload(_ queuedDownload: JSActiveDownload) { - print("Starting queued download: \(queuedDownload.title ?? queuedDownload.originalURL.lastPathComponent)") + Logger.shared.log("Starting queued download: \(queuedDownload.title ?? queuedDownload.originalURL.lastPathComponent)", type: "Download") // If we have a module, use the same method as manual downloads (this fixes the bug!) if let module = queuedDownload.module { - print("Using downloadWithStreamTypeSupport for queued download (same as manual downloads)") + Logger.shared.log("Using downloadWithStreamTypeSupport for queued download (same as manual downloads)", type: "Download") // Use the exact same method that manual downloads use downloadWithStreamTypeSupport( @@ -283,9 +293,9 @@ extension JSController { showPosterURL: queuedDownload.metadata?.showPosterURL, completionHandler: { success, message in if success { - print("Queued download started successfully via downloadWithStreamTypeSupport") + Logger.shared.log("Queued download started successfully via downloadWithStreamTypeSupport", type: "Download") } else { - print("Queued download failed: \(message)") + Logger.shared.log("Queued download failed: \(message)", type: "Download") } } ) @@ -293,15 +303,15 @@ extension JSController { } // Legacy fallback for downloads without module (should rarely be used now) - print("Using legacy download method for queued download (no module available)") + Logger.shared.log("Using legacy download method for queued download (no module available)", type: "Download") guard let asset = queuedDownload.asset else { - print("Missing asset for queued download") + Logger.shared.log("Missing asset for queued download", type: "Download") return } guard let downloadSession = downloadURLSession else { - print("Download session not yet initialized, retrying in background...") + Logger.shared.log("Download session not yet initialized, retrying in background...", type: "Download") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.startQueuedDownload(queuedDownload) } @@ -314,7 +324,7 @@ extension JSController { assetArtworkData: nil, options: nil ) else { - print("Failed to create download task for queued download") + Logger.shared.log("Failed to create download task for queued download", type: "Download") return } @@ -333,7 +343,10 @@ extension JSController { subtitleURL: queuedDownload.subtitleURL, asset: asset, headers: queuedDownload.headers, - module: queuedDownload.module + module: queuedDownload.module, + aniListID: queuedDownload.aniListID, + malID: queuedDownload.malID, + isFiller: queuedDownload.isFiller ) // Add to active downloads @@ -342,7 +355,7 @@ extension JSController { // Start the download task.resume() - print("Queued download started: \(download.title ?? download.originalURL.lastPathComponent)") + Logger.shared.log("Queued download started: \(download.title ?? download.originalURL.lastPathComponent)", type: "Download") // Save the download state saveDownloadState() @@ -403,8 +416,7 @@ extension JSController { saveDownloadState() - print("Cleaned up download task") - + Logger.shared.log("Cleaned up download task", type: "Download") // Start processing the queue again if we have pending downloads if !downloadQueue.isEmpty && !isProcessingQueue { processDownloadQueue() @@ -455,11 +467,11 @@ extension JSController { /// - subtitleURL: The URL of the subtitle file to download /// - assetID: The ID of the asset this subtitle is associated with func downloadSubtitle(subtitleURL: URL, assetID: String) { - print("Downloading subtitle from: \(subtitleURL.absoluteString) for asset ID: \(assetID)") + Logger.shared.log("Downloading subtitle from: \(subtitleURL.absoluteString) for asset ID: \(assetID)", type: "Download") // Check if this asset belongs to a cancelled download - if so, don't download subtitle if let assetUUID = UUID(uuidString: assetID), cancelledDownloadIDs.contains(assetUUID) { - print("Skipping subtitle download for cancelled download: \(assetID)") + Logger.shared.log("Skipping subtitle download for cancelled download: \(assetID)", type: "Download") return } @@ -479,34 +491,34 @@ extension JSController { request.addValue(referer, forHTTPHeaderField: "Origin") } - print("Subtitle download request headers: \(request.allHTTPHeaderFields ?? [:])") + Logger.shared.log("Subtitle download request headers: \(request.allHTTPHeaderFields ?? [:])", type: "Download") // Create a task to download the subtitle file let task = session.downloadTask(with: request) { [weak self] (tempURL, response, error) in guard let self = self else { - print("Self reference lost during subtitle download") + Logger.shared.log("Self reference lost during subtitle download", type: "Download") return } if let error = error { - print("Subtitle download error: \(error.localizedDescription)") + Logger.shared.log("Subtitle download error: \(error.localizedDescription)", type: "Download") return } guard let tempURL = tempURL else { - print("No temporary URL received for subtitle download") + Logger.shared.log("No temporary URL received for subtitle download", type: "Download") return } guard let downloadDir = self.getPersistentDownloadDirectory() else { - print("Failed to get persistent download directory for subtitle") + Logger.shared.log("Failed to get persistent download directory for subtitle", type: "Download") return } // Log response details for debugging if let httpResponse = response as? HTTPURLResponse { - print("Subtitle download HTTP status: \(httpResponse.statusCode)") - print("Subtitle download content type: \(httpResponse.mimeType ?? "unknown")") + Logger.shared.log("Subtitle download HTTP status: \(httpResponse.statusCode)", type: "Download") + Logger.shared.log("Subtitle download content type: \(httpResponse.mimeType ?? "unknown")", type: "Download") } // Try to read content to validate it's actually a subtitle file @@ -515,19 +527,19 @@ extension JSController { let subtitleContent = String(data: subtitleData, encoding: .utf8) ?? "" if subtitleContent.isEmpty { - print("Warning: Subtitle file appears to be empty") + Logger.shared.log("Warning: Subtitle file appears to be empty", type: "Download") } else { - print("Subtitle file contains \(subtitleData.count) bytes of data") + Logger.shared.log("Subtitle file contains \(subtitleData.count) bytes of data", type: "Download") if subtitleContent.hasPrefix("WEBVTT") { - print("Valid WebVTT subtitle detected") + Logger.shared.log("Valid WebVTT subtitle detected", type: "Download") } else if subtitleContent.contains(" --> ") { - print("Subtitle file contains timing markers") + Logger.shared.log("Subtitle file contains timing markers", type: "Download") } else { - print("Warning: Subtitle content doesn't appear to be in a recognized format") + Logger.shared.log("Warning: Subtitle content doesn't appear to be in a recognized format", type: "Download") } } } catch { - print("Error reading subtitle content for validation: \(error.localizedDescription)") + Logger.shared.log("Error reading subtitle content for validation: \(error.localizedDescription)", type: "Download") } // Determine file extension based on the content type or URL @@ -554,18 +566,16 @@ extension JSController { // If file already exists, remove it if FileManager.default.fileExists(atPath: localURL.path) { try FileManager.default.removeItem(at: localURL) - print("Removed existing subtitle file at \(localURL.path)") + Logger.shared.log("Removed existing subtitle file at \(localURL.path)", type: "Download") } // Move the downloaded file to the persistent location try FileManager.default.moveItem(at: tempURL, to: localURL) // Update the asset with the subtitle URL - self.updateAssetWithSubtitle(assetID: assetID, - subtitleURL: subtitleURL, - localSubtitleURL: localURL) + self.updateAssetWithSubtitle(assetID: assetID, subtitleURL: subtitleURL, localSubtitleURL: localURL) - print("Subtitle downloaded successfully: \(localURL.path)") + Logger.shared.log("Subtitle downloaded successfully: \(localURL.path)", type: "Download") // Show success notification DispatchQueue.main.async { @@ -591,12 +601,12 @@ extension JSController { } } } catch { - print("Error moving subtitle file: \(error.localizedDescription)") + Logger.shared.log("Error moving subtitle file: \(error.localizedDescription)", type: "Download") } } task.resume() - print("Subtitle download task started") + Logger.shared.log("Subtitle download task started", type: "Download") } /// Updates an asset with subtitle information after subtitle download completes @@ -617,7 +627,7 @@ extension JSController { localURL: existingAsset.localURL, type: existingAsset.type, metadata: existingAsset.metadata, - subtitleURL: subtitleURL, + subtitleURL: existingAsset.subtitleURL, localSubtitleURL: localSubtitleURL ) @@ -658,7 +668,7 @@ extension JSController { do { if !fileManager.fileExists(atPath: persistentDir.path) { try fileManager.createDirectory(at: persistentDir, withIntermediateDirectories: true) - print("Created persistent download directory at \(persistentDir.path)") + Logger.shared.log("Created persistent download directory at \(persistentDir.path)", type: "Download") } // Find any video files (.movpkg, .mp4) in the Documents directory @@ -666,7 +676,7 @@ extension JSController { let videoFiles = files.filter { ["movpkg", "mp4"].contains($0.pathExtension.lowercased()) } if !videoFiles.isEmpty { - print("Found \(videoFiles.count) video files in Documents directory to migrate") + Logger.shared.log("Found \(videoFiles.count) video files in Documents directory to migrate", type: "Download") // Migrate each file for fileURL in videoFiles { @@ -679,18 +689,18 @@ extension JSController { let uniqueID = UUID().uuidString let newDestinationURL = persistentDir.appendingPathComponent("\(filename)-\(uniqueID)") try fileManager.copyItem(at: fileURL, to: newDestinationURL) - print("Migrated file with unique name: \(filename) → \(newDestinationURL.lastPathComponent)") + Logger.shared.log("Migrated file with unique name: \(filename) → \(newDestinationURL.lastPathComponent)", type: "Download") } else { // Move the file to the persistent directory try fileManager.copyItem(at: fileURL, to: destinationURL) - print("Migrated file: \(filename)") + Logger.shared.log("Migrated file: \(filename)", type: "Download") } } } else { - print("No video files found in Documents directory for migration") + Logger.shared.log("No video files found in Documents directory for migration", type: "Download") } } catch { - print("Error during migration: \(error.localizedDescription)") + Logger.shared.log("Error during migration: \(error.localizedDescription)", type: "Download") } } @@ -707,12 +717,12 @@ extension JSController { // Check if the video file exists at the stored path if !fileManager.fileExists(atPath: asset.localURL.path) { - print("Asset file not found at saved path: \(asset.localURL.path)") + Logger.shared.log("Asset file not found at saved path: \(asset.localURL.path)", type: "Download") // Try to find the file in the persistent directory if let persistentURL = findAssetInPersistentStorage(assetName: asset.name) { // Update the asset with the new video URL - print("Found asset in persistent storage: \(persistentURL.path)") + Logger.shared.log("Found asset in persistent storage: \(persistentURL.path)", type: "Download") updatedAsset = DownloadedAsset( id: asset.id, name: asset.name, @@ -727,7 +737,7 @@ extension JSController { needsUpdate = true } else { // If we can't find the video file, mark it for removal - print("Asset not found in persistent storage. Marking for removal: \(asset.name)") + Logger.shared.log("Asset not found in persistent storage. Marking for removal: \(asset.name)", type: "Download") assetsToRemove.append(asset.id) updatedAssets = true continue // Skip subtitle validation for assets being removed @@ -737,11 +747,11 @@ extension JSController { // Check if the subtitle file exists (if one is expected) if let localSubtitleURL = updatedAsset.localSubtitleURL { if !fileManager.fileExists(atPath: localSubtitleURL.path) { - print("Subtitle file not found at saved path: \(localSubtitleURL.path)") + Logger.shared.log("Subtitle file not found at saved path: \(localSubtitleURL.path)", type: "Download") // Try to find the subtitle file in the persistent directory if let foundSubtitleURL = findSubtitleInPersistentStorage(assetID: updatedAsset.id.uuidString) { - print("Found subtitle file in persistent storage: \(foundSubtitleURL.path)") + Logger.shared.log("Found subtitle file in persistent storage: \(foundSubtitleURL.path)", type: "Download") updatedAsset = DownloadedAsset( id: updatedAsset.id, name: updatedAsset.name, @@ -756,7 +766,7 @@ extension JSController { needsUpdate = true } else { // Subtitle file is missing - remove the subtitle reference but keep the video - print("Subtitle file not found in persistent storage for asset: \(updatedAsset.name)") + Logger.shared.log("Subtitle file not found in persistent storage for asset: \(updatedAsset.name)", type: "Download") updatedAsset = DownloadedAsset( id: updatedAsset.id, name: updatedAsset.name, @@ -777,7 +787,7 @@ extension JSController { if needsUpdate { savedAssets[index] = updatedAsset updatedAssets = true - print("Updated asset paths for: \(updatedAsset.name)") + Logger.shared.log("Updated asset paths for: \(updatedAsset.name)", type: "Download") } } @@ -785,7 +795,7 @@ extension JSController { if !assetsToRemove.isEmpty { let countBefore = savedAssets.count savedAssets.removeAll { assetsToRemove.contains($0.id) } - print("Removed \(countBefore - savedAssets.count) missing assets from the library") + Logger.shared.log("Removed \(countBefore - savedAssets.count) missing assets from the library", type: "Download") // Notify observers of the change (library cleanup requires cache clearing) postDownloadNotification(.cleanup) @@ -794,7 +804,7 @@ extension JSController { // Save the updated asset information if changes were made if updatedAssets { saveAssets() - print("Asset validation complete. Updated \(updatedAssets ? "some" : "no") asset paths.") + Logger.shared.log("Asset validation complete. Updated \(updatedAssets ? "some" : "no") asset paths.", type: "Download") } } @@ -831,7 +841,7 @@ extension JSController { } } } catch { - print("Error searching for asset in persistent storage: \(error.localizedDescription)") + Logger.shared.log("Error searching for asset in persistent storage: \(error.localizedDescription)", type: "Download") } return nil @@ -845,7 +855,7 @@ extension JSController { // Get Application Support directory guard let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - print("Cannot access Application Support directory for subtitle search") + Logger.shared.log("Cannot access Application Support directory for subtitle search", type: "Download") return nil } @@ -854,7 +864,7 @@ extension JSController { // Check if directory exists guard fileManager.fileExists(atPath: downloadDir.path) else { - print("Download directory does not exist for subtitle search") + Logger.shared.log("Download directory does not exist for subtitle search", type: "Download") return nil } @@ -873,14 +883,14 @@ extension JSController { // Check if this is a subtitle file with the correct naming pattern if subtitleExtensions.contains(fileExtension) && filename.hasPrefix("subtitle-\(assetID).") { - print("Found subtitle file for asset \(assetID): \(filename)") + Logger.shared.log("Found subtitle file for asset \(assetID): \(filename)", type: "Download") return file } } - print("No subtitle file found for asset ID: \(assetID)") + Logger.shared.log("No subtitle file found for asset ID: \(assetID)", type: "Download") } catch { - print("Error searching for subtitle in persistent storage: \(error.localizedDescription)") + Logger.shared.log("Error searching for subtitle in persistent storage: \(error.localizedDescription)", type: "Download") } return nil @@ -889,7 +899,7 @@ extension JSController { /// Save assets to UserDefaults func saveAssets() { DownloadPersistence.save(savedAssets) - print("Saved \(savedAssets.count) assets to persistence") + Logger.shared.log("Saved \(savedAssets.count) assets to persistence", type: "Download") } /// Save the current state of downloads @@ -905,7 +915,7 @@ extension JSController { } UserDefaults.standard.set(downloadInfo, forKey: "activeDownloads") - print("Saved download state with \(downloadInfo.count) active downloads") + Logger.shared.log("Saved download state with \(downloadInfo.count) active downloads", type: "Download") } /// Delete an asset @@ -942,7 +952,7 @@ extension JSController { func removeAssetFromLibrary(_ asset: DownloadedAsset) { // Only remove the entry from savedAssets DownloadPersistence.delete(id: asset.id) - print("Removed asset from library (file preserved): \(asset.name)") + Logger.shared.log("Removed asset from library (file preserved): \(asset.name)", type: "Download") // Notify observers that the library changed (cache clearing needed) postDownloadNotification(.libraryChange) @@ -954,7 +964,7 @@ extension JSController { // Get Application Support directory guard let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - print("Cannot access Application Support directory") + Logger.shared.log("Cannot access Application Support directory", type: "Download") return nil } @@ -964,18 +974,18 @@ extension JSController { do { if !fileManager.fileExists(atPath: downloadDir.path) { try fileManager.createDirectory(at: downloadDir, withIntermediateDirectories: true) - print("Created persistent download directory at \(downloadDir.path)") + Logger.shared.log("Created persistent download directory at \(downloadDir.path)", type: "Download") } return downloadDir } catch { - print("Error creating download directory: \(error.localizedDescription)") + Logger.shared.log("Error creating download directory: \(error.localizedDescription)", type: "Download") return nil } } /// Checks if an asset file exists before attempting to play it /// - Parameter asset: The asset to verify - /// - Returns: True if the file exists, false otherwise + /// - Returns: true if the file exists, false otherwise func verifyAssetFileExists(_ asset: DownloadedAsset) -> Bool { let fileExists = FileManager.default.fileExists(atPath: asset.localURL.path) @@ -1100,7 +1110,7 @@ extension JSController { // Notify of the cancellation postDownloadNotification(.statusChange) - print("Cancelled queued download: \(downloadID)") + Logger.shared.log("Cancelled queued download: \(downloadID)", type: "Download") } /// Cancel an active download that is currently in progress @@ -1123,25 +1133,25 @@ extension JSController { // Show notification DropManager.shared.info("Download cancelled: \(downloadTitle)") - print("Cancelled active download: \(downloadTitle)") + Logger.shared.log("Cancelled active download: \(downloadTitle)", type: "Download") } } /// Pause an MP4 download func pauseMP4Download(_ downloadID: UUID) { guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { - print("MP4 Download not found for pausing: \(downloadID)") + Logger.shared.log("MP4 Download not found for pausing: \(downloadID)", type: "Download") return } let download = activeDownloads[index] guard let urlTask = download.urlSessionTask else { - print("No URL session task found for MP4 download: \(downloadID)") + Logger.shared.log("No URL session task found for MP4 download: \(downloadID)", type: "Download") return } urlTask.suspend() - print("Paused MP4 download: \(download.title ?? download.originalURL.lastPathComponent)") + Logger.shared.log("Paused MP4 download: \(download.title ?? download.originalURL.lastPathComponent)", type: "Download") // Notify UI of status change postDownloadNotification(.statusChange) @@ -1150,18 +1160,18 @@ extension JSController { /// Resume an MP4 download func resumeMP4Download(_ downloadID: UUID) { guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { - print("MP4 Download not found for resuming: \(downloadID)") + Logger.shared.log("MP4 Download not found for resuming: \(downloadID)", type: "Download") return } let download = activeDownloads[index] guard let urlTask = download.urlSessionTask else { - print("No URL session task found for MP4 download: \(downloadID)") + Logger.shared.log("No URL session task found for MP4 download: \(downloadID)", type: "Download") return } urlTask.resume() - print("Resumed MP4 download: \(download.title ?? download.originalURL.lastPathComponent)") + Logger.shared.log("Resumed MP4 download: \(download.title ?? download.originalURL.lastPathComponent)", type: "Download") // Notify UI of status change postDownloadNotification(.statusChange) @@ -1175,13 +1185,13 @@ extension JSController: AVAssetDownloadDelegate { func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { guard let downloadID = activeDownloadMap[assetDownloadTask], let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { - print("Download task finished but couldn't find associated download") + Logger.shared.log("Download task finished but couldn't find associated download", type: "Download") return } // Check if this download was cancelled - if so, don't process completion if cancelledDownloadIDs.contains(downloadID) { - print("Ignoring completion for cancelled download: \(downloadID)") + Logger.shared.log("Ignoring completion for cancelled download: \(downloadID)", type: "Download") // Delete any temporary files that may have been created try? FileManager.default.removeItem(at: location) return @@ -1191,7 +1201,7 @@ extension JSController: AVAssetDownloadDelegate { // Move the downloaded file to Application Support directory for persistence guard let persistentURL = moveToApplicationSupportDirectory(from: location, filename: download.title ?? download.originalURL.lastPathComponent, originalURL: download.originalURL) else { - print("Failed to move downloaded file to persistent storage") + Logger.shared.log("Failed to move downloaded file to persistent storage", type: "Download") return } @@ -1214,6 +1224,61 @@ extension JSController: AVAssetDownloadDelegate { } // If there's a subtitle URL, download it now that the video is saved + // Also fetch OP/ED skip timestamps in parallel and save simple sidecar JSON next to the video + + if download.metadata?.episode != nil && download.type == .episode { + // Ensure we have MAL ID just like the streaming path (CustomPlayer) + if download.malID == nil, let aid = download.aniListID { + AniListMutation().fetchMalID(animeId: aid) { [weak self] result in + switch result { + case .success(let mal): + // Replace the download entry with a new instance carrying MAL ID + if let idx = self?.activeDownloads.firstIndex(where: { $0.id == download.id }) { + let cur = self?.activeDownloads[idx] ?? download + let updated = JSActiveDownload( + id: cur.id, + originalURL: cur.originalURL, + progress: cur.progress, + task: cur.task, + urlSessionTask: cur.urlSessionTask, + queueStatus: cur.queueStatus, + type: cur.type, + metadata: cur.metadata, + title: cur.title, + imageURL: cur.imageURL, + subtitleURL: cur.subtitleURL, + asset: cur.asset, + headers: cur.headers, + module: cur.module, + aniListID: cur.aniListID, + malID: mal, + isFiller: cur.isFiller + ) + self?.activeDownloads[idx] = updated + self?.fetchSkipTimestampsFor(request: updated, persistentURL: persistentURL) { ok in + if ok { + Logger.shared.log("[SkipSidecar] Saved OP/ED sidecar for episode \(updated.metadata?.episode ?? -1) at: \(persistentURL.path)", type: "Download") + } else { + Logger.shared.log("[SkipSidecar] Failed to save sidecar for episode \(updated.metadata?.episode ?? -1)", type: "Download") + } + } + } + case .failure(let error): + Logger.shared.log("Unable to fetch MAL ID: \(error)", type: "Error") + Logger.shared.log("[SkipSidecar] Missing MAL ID for AniSkip request", type: "Download") + } + } + } else { + fetchSkipTimestampsFor(request: download, persistentURL: persistentURL) { ok in + if ok { + Logger.shared.log("[SkipSidecar] Saved OP/ED sidecar for episode \(download.metadata?.episode ?? -1) at: \(persistentURL.path)", type: "Download") + } else { + Logger.shared.log("[SkipSidecar] Failed to save sidecar for episode \(download.metadata?.episode ?? -1)", type: "Download") + } + } + } + } + if let subtitleURL = download.subtitleURL { downloadSubtitle(subtitleURL: subtitleURL, assetID: newAsset.id.uuidString) } else { @@ -1230,11 +1295,11 @@ extension JSController: AVAssetDownloadDelegate { ]) } } - + // Clean up the download task cleanupDownloadTask(assetDownloadTask) - print("Download completed and moved to persistent storage: \(newAsset.name)") + Logger.shared.log("Download completed and moved to persistent storage: \(newAsset.name)", type: "Download") } /// Moves a downloaded file to Application Support directory to preserve it across app updates @@ -1248,7 +1313,7 @@ extension JSController: AVAssetDownloadDelegate { // Get Application Support directory guard let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - print("Cannot access Application Support directory") + Logger.shared.log("Cannot access Application Support directory", type: "Download") return nil } @@ -1258,7 +1323,7 @@ extension JSController: AVAssetDownloadDelegate { do { if !fileManager.fileExists(atPath: downloadDir.path) { try fileManager.createDirectory(at: downloadDir, withIntermediateDirectories: true) - print("Created persistent download directory at \(downloadDir.path)") + Logger.shared.log("Created persistent download directory at \(downloadDir.path)", type: "Download") } // Generate unique filename with UUID to avoid conflicts @@ -1276,34 +1341,34 @@ extension JSController: AVAssetDownloadDelegate { if originalURLString.contains(".m3u8") || originalURLString.contains("/hls/") || originalURLString.contains("m3u8") { // This was an HLS stream, keep as .movpkg fileExtension = "movpkg" - print("Using .movpkg extension for HLS download: \(safeFilename)") + Logger.shared.log("Using .movpkg extension for HLS download: \(safeFilename)", type: "Download") } else if originalPathExtension == "mp4" || originalURLString.contains(".mp4") || originalURLString.contains("download") { // This was a direct MP4 download, use .mp4 extension regardless of what AVAssetDownloadTask created fileExtension = "mp4" - print("Using .mp4 extension for direct MP4 download: \(safeFilename)") + Logger.shared.log("Using .mp4 extension for direct MP4 download: \(safeFilename)", type: "Download") } else { // Fallback: check the downloaded file extension let sourceExtension = location.pathExtension.lowercased() if sourceExtension == "movpkg" && originalURLString.contains("m3u8") { fileExtension = "movpkg" - print("Using .movpkg extension for HLS stream: \(safeFilename)") + Logger.shared.log("Using .movpkg extension for HLS stream: \(safeFilename)", type: "Download") } else { fileExtension = "mp4" - print("Using .mp4 extension as fallback: \(safeFilename)") + Logger.shared.log("Using .mp4 extension as fallback: \(safeFilename)", type: "Download") } } - print("Final destination will be: \(safeFilename)-\(uniqueID).\(fileExtension)") + Logger.shared.log("Final destination will be: \(safeFilename)-\(uniqueID).\(fileExtension)", type: "Download") let destinationURL = downloadDir.appendingPathComponent("\(safeFilename)-\(uniqueID).\(fileExtension)") // Move the file to the persistent location try fileManager.moveItem(at: location, to: destinationURL) - print("Successfully moved download to persistent storage: \(destinationURL.path)") + Logger.shared.log("Successfully moved download to persistent storage: \(destinationURL.path)", type: "Download") return destinationURL } catch { - print("Error moving download to persistent storage: \(error.localizedDescription)") + Logger.shared.log("Error moving download to persistent storage: \(error.localizedDescription)", type: "Download") return nil } } @@ -1312,37 +1377,37 @@ extension JSController: AVAssetDownloadDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let error = error { // Enhanced error logging - print("Download error: \(error.localizedDescription)") + Logger.shared.log("Download error: \(error.localizedDescription)", type: "Download") // Extract and log the underlying error details let nsError = error as NSError - print("Error domain: \(nsError.domain), code: \(nsError.code)") + Logger.shared.log("Error domain: \(nsError.domain), code: \(nsError.code)", type: "Download") if let underlyingError = nsError.userInfo["NSUnderlyingError"] as? NSError { - print("Underlying error: \(underlyingError)") + Logger.shared.log("Underlying error: \(underlyingError)", type: "Download") } for (key, value) in nsError.userInfo { - print("Error info - \(key): \(value)") + Logger.shared.log("Error info - \(key): \(value)", type: "Download") } // Check if there's a system network error if let urlError = error as? URLError { - print("URLError code: \(urlError.code.rawValue)") + Logger.shared.log("URLError code: \(urlError.code.rawValue)", type: "Download") // Handle cancellation specifically if urlError.code == .cancelled { - print("Download was cancelled by user") + Logger.shared.log("Download was cancelled by user", type: "Download") handleDownloadCancellation(task) return } else if urlError.code == .notConnectedToInternet || urlError.code == .networkConnectionLost { - print("Network error: \(urlError.localizedDescription)") + Logger.shared.log("Network error: \(urlError.localizedDescription)", type: "Download") DispatchQueue.main.async { DropManager.shared.error("Network error: \(urlError.localizedDescription)") } } else if urlError.code == .userAuthenticationRequired || urlError.code == .userCancelledAuthentication { - print("Authentication error: \(urlError.localizedDescription)") + Logger.shared.log("Authentication error: \(urlError.localizedDescription)", type: "Download") DispatchQueue.main.async { DropManager.shared.error("Authentication error: Check headers") @@ -1350,8 +1415,7 @@ extension JSController: AVAssetDownloadDelegate { } } else if error.localizedDescription.contains("403") { // Specific handling for 403 Forbidden errors - print("403 Forbidden error - Server rejected the request") - + Logger.shared.log("403 Forbidden error - Server rejected the request", type: "Download") DispatchQueue.main.async { DropManager.shared.error("Access denied (403): The server refused access to this content") } @@ -1368,7 +1432,7 @@ extension JSController: AVAssetDownloadDelegate { /// Handle download cancellation - clean up without treating as completion private func handleDownloadCancellation(_ task: URLSessionTask) { guard let downloadID = activeDownloadMap[task] else { - print("Cancelled download task not found in active downloads") + Logger.shared.log("Cancelled download task not found in active downloads", type: "Download") cleanupDownloadTask(task) return } @@ -1395,7 +1459,7 @@ extension JSController: AVAssetDownloadDelegate { // Notify observers of cancellation (no cache clearing needed) postDownloadNotification(.statusChange) - print("Successfully handled cancellation for: \(downloadTitle)") + Logger.shared.log("Successfully handled cancellation for: \(downloadTitle)", type: "Download") } /// Delete any partially downloaded assets for a cancelled download @@ -1410,15 +1474,15 @@ extension JSController: AVAssetDownloadDelegate { return wasRecentlyAdded }) { let assetToDelete = savedAssets[savedAssetIndex] - print("Removing cancelled download from saved assets: \(assetToDelete.name)") + Logger.shared.log("Removing cancelled download from saved assets: \(assetToDelete.name)", type: "Download") // Delete the actual file if it exists if FileManager.default.fileExists(atPath: assetToDelete.localURL.path) { do { try FileManager.default.removeItem(at: assetToDelete.localURL) - print("Deleted partially downloaded file: \(assetToDelete.localURL.path)") + Logger.shared.log("Deleted partially downloaded file: \(assetToDelete.localURL.path)", type: "Download") } catch { - print("Error deleting partially downloaded file: \(error.localizedDescription)") + Logger.shared.log("Error deleting partially downloaded file: \(error.localizedDescription)", type: "Download") } } @@ -1440,7 +1504,7 @@ extension JSController: AVAssetDownloadDelegate { // Do a quick check to see if task is still registered guard let downloadID = activeDownloadMap[assetDownloadTask] else { - print("Received progress for unknown download task") + Logger.shared.log("Received progress for unknown download task", type: "Download") return } @@ -1472,14 +1536,14 @@ extension JSController: URLSessionTaskDelegate { /// Called when a redirect is received func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { // Log information about the redirect - print("==== REDIRECT DETECTED ====") - print("Redirecting to: \(request.url?.absoluteString ?? "unknown")") - print("Redirect status code: \(response.statusCode)") + Logger.shared.log("==== REDIRECT DETECTED ====", type: "Download") + Logger.shared.log("Redirecting to: \(request.url?.absoluteString ?? "unknown")", type: "Download") + Logger.shared.log("Redirect status code: \(response.statusCode)", type: "Download") // Don't try to access originalRequest for AVAssetDownloadTask if !(task is AVAssetDownloadTask), let originalRequest = task.originalRequest { - print("Original URL: \(originalRequest.url?.absoluteString ?? "unknown")") - print("Original Headers: \(originalRequest.allHTTPHeaderFields ?? [:])") + Logger.shared.log("Original URL: \(originalRequest.url?.absoluteString ?? "unknown")", type: "Download") + Logger.shared.log("Original Headers: \(originalRequest.allHTTPHeaderFields ?? [:])", type: "Download") // Create a modified request that preserves ALL original headers var modifiedRequest = request @@ -1488,40 +1552,40 @@ extension JSController: URLSessionTaskDelegate { for (key, value) in originalRequest.allHTTPHeaderFields ?? [:] { // Only add if not already present in the redirect request if modifiedRequest.value(forHTTPHeaderField: key) == nil { - print("Adding missing header: \(key): \(value)") + Logger.shared.log("Adding missing header: \(key): \(value)", type: "Download") modifiedRequest.addValue(value, forHTTPHeaderField: key) } } - print("Final redirect headers: \(modifiedRequest.allHTTPHeaderFields ?? [:])") + Logger.shared.log("Final redirect headers: \(modifiedRequest.allHTTPHeaderFields ?? [:])", type: "Download") // Allow the redirect with our modified request completionHandler(modifiedRequest) } else { // For AVAssetDownloadTask, just accept the redirect as is - print("Accepting redirect for AVAssetDownloadTask without header modification") + Logger.shared.log("Accepting redirect for AVAssetDownloadTask without header modification", type: "Download") completionHandler(request) } } /// Handle authentication challenges func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - print("==== AUTH CHALLENGE ====") - print("Authentication method: \(challenge.protectionSpace.authenticationMethod)") - print("Host: \(challenge.protectionSpace.host)") + Logger.shared.log("==== AUTH CHALLENGE ====", type: "Download") + Logger.shared.log("Authentication method: \(challenge.protectionSpace.authenticationMethod)", type: "Download") + Logger.shared.log("Host: \(challenge.protectionSpace.host)", type: "Download") if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { // Handle SSL/TLS certificate validation if let serverTrust = challenge.protectionSpace.serverTrust { let credential = URLCredential(trust: serverTrust) - print("Accepting server trust for host: \(challenge.protectionSpace.host)") + Logger.shared.log("Accepting server trust for host: \(challenge.protectionSpace.host)", type: "Download") completionHandler(.useCredential, credential) return } } // Default to performing authentication without credentials - print("Using default handling for authentication challenge") + Logger.shared.log("Using default handling for authentication challenge", type: "Download") completionHandler(.performDefaultHandling, nil) } } @@ -1543,6 +1607,9 @@ struct JSActiveDownload: Identifiable, Equatable { var asset: AVURLAsset? var headers: [String: String] var module: ScrapingModule? // Add module property to store ScrapingModule + let aniListID: Int? + let malID: Int? + let isFiller: Bool? // Computed property to get the current task state var taskState: URLSessionTask.State { @@ -1586,7 +1653,10 @@ struct JSActiveDownload: Identifiable, Equatable { subtitleURL: URL? = nil, asset: AVURLAsset? = nil, headers: [String: String] = [:], - module: ScrapingModule? = nil // Add module parameter to initializer + module: ScrapingModule? = nil, + aniListID: Int? = nil, + malID: Int? = nil, + isFiller: Bool? = nil ) { self.id = id self.originalURL = originalURL @@ -1602,6 +1672,9 @@ struct JSActiveDownload: Identifiable, Equatable { self.asset = asset self.headers = headers self.module = module // Store the module + self.aniListID = aniListID + self.malID = malID + self.isFiller = isFiller } } @@ -1646,4 +1719,195 @@ enum DownloadQueueStatus: Equatable { case downloading /// Download has been completed case completed -} +} + +// MARK: - AniSkip Sidecar (OP/ED) Fetch +extension JSController { + /// Fetches OP & ED skip timestamps (AniSkip) and writes a minimal sidecar JSON next to the persisted video. + /// Uses MAL ID only (AniList is not used). + func fetchSkipTimestampsFor(request: JSActiveDownload, + persistentURL: URL, + completion: @escaping (Bool) -> Void) { + // Attempt to obtain the MAL ID. If it's not present on the request but an AniList ID is, + // use AniListMutation to fetch it. This mirrors the logic used by CustomMediaPlayer. + func proceed(with malID: Int) { + // Ensure the episode number is available before making the AniSkip request + guard let episodeNumber = request.metadata?.episode else { + Logger.shared.log("[SkipSidecar] Missing episode number for AniSkip request", type: "Download") + completion(false) + return + } + + // Build URL and include separate query items for each type. The AniSkip API expects + // repeated `types` parameters, not a comma-separated list. Using URLComponents ensures + // proper encoding of the query items. + var components = URLComponents() + components.scheme = "https" + components.host = "api.aniskip.com" + components.path = "/v2/skip-times/\(malID)/\(episodeNumber)" + components.queryItems = [ + URLQueryItem(name: "types", value: "op"), + URLQueryItem(name: "types", value: "ed"), + URLQueryItem(name: "episodeLength", value: "0") + ] + guard let url = components.url else { + Logger.shared.log("[SkipSidecar] Failed to construct AniSkip URL", type: "Download") + completion(false) + return + } + // Log the exact URL being fetched to aid debugging + Logger.shared.log("[SkipSidecar] Fetching AniSkip: \(url.absoluteString)", type: "Download") + + // Perform the request and capture the response object so we can log status codes + URLSession.shared.dataTask(with: url) { data, response, error in + if let e = error { + Logger.shared.log("[SkipSidecar] AniSkip (MAL) fetch error: \(e.localizedDescription)", type: "Download") + completion(false) + return + } + if let http = response as? HTTPURLResponse { + Logger.shared.log("[SkipSidecar] AniSkip response status: \(http.statusCode)", type: "Download") + } + guard let data = data else { + Logger.shared.log("[SkipSidecar] AniSkip returned empty body", type: "Download") + completion(false) + return + } + + // Flexible decoder: supports both camelCase (skipType, startTime) and snake_case (skip_type, start_time) + struct AniSkipV2Response: Decodable { + struct Result: Decodable { + struct Interval: Decodable { + let startTime: Double + let endTime: Double + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + if let start = try? c.decode(Double.self, forKey: .startTime), + let end = try? c.decode(Double.self, forKey: .endTime) { + startTime = start + endTime = end + } else { + startTime = try c.decode(Double.self, forKey: .start_time) + endTime = try c.decode(Double.self, forKey: .end_time) + } + } + private enum CodingKeys: String, CodingKey { + case startTime + case endTime + case start_time + case end_time + } + } + let skipType: String + let interval: Interval + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + if let st = try? c.decode(String.self, forKey: .skipType) { + skipType = st + } else { + skipType = try c.decode(String.self, forKey: .skip_type) + } + interval = try c.decode(Interval.self, forKey: .interval) + } + private enum CodingKeys: String, CodingKey { + case skipType + case skip_type + case interval + } + } + let found: Bool + let results: [Result]? + } + + var opRange: (Double, Double)? = nil + var edRange: (Double, Double)? = nil + + if let resp = try? JSONDecoder().decode(AniSkipV2Response.self, from: data), resp.found, let arr = resp.results { + for item in arr { + switch item.skipType.lowercased() { + case "op": opRange = (item.interval.startTime, item.interval.endTime) + case "ed": edRange = (item.interval.startTime, item.interval.endTime) + default: break + } + } + } else { + // Log a small preview of the response to help debugging + let preview = String(data: data, encoding: .utf8) ?? "" + Logger.shared.log("[SkipSidecar] AniSkip decode failed or not found. Body: \(preview.prefix(200))", type: "Download") + } + + // If no ranges were found, gracefully return without writing a sidecar + if opRange == nil && edRange == nil { + completion(false) + return + } + + // Sidecar path: next to the persisted video file + let dir = persistentURL.deletingLastPathComponent() + let baseName = persistentURL.deletingPathExtension().lastPathComponent + let sidecar = dir.appendingPathComponent(baseName + ".skip.json") + + // Build the sidecar payload in the format expected by CustomMediaPlayer. + // The player expects a top-level "results" array where each entry + // contains a snake_case "skip_type" and an "interval" with + // "start_time" and "end_time" keys. Extra top-level metadata + // fields (e.g., source, malId) are ignored by the decoder. + var payload: [String: Any] = [ + "source": "aniskip", + "idType": "mal", + "malId": malID, + "episode": episodeNumber, + "createdAt": ISO8601DateFormatter().string(from: Date()) + ] + var resultsArray: [[String: Any]] = [] + if let op = opRange { + resultsArray.append([ + "skip_type": "op", + "interval": ["start_time": op.0, "end_time": op.1] + ]) + } + if let ed = edRange { + resultsArray.append([ + "skip_type": "ed", + "interval": ["start_time": ed.0, "end_time": ed.1] + ]) + } + payload["results"] = resultsArray + + do { + let json = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]) + try json.write(to: sidecar, options: .atomic) + Logger.shared.log("[SkipSidecar] Wrote sidecar at: \(sidecar.path)", type: "Download") + completion(true) + } catch { + Logger.shared.log("[SkipSidecar] Sidecar write error: \(error.localizedDescription)", type: "Download") + completion(false) + } + }.resume() + } + + if let existingMalID = request.malID { + // Already have the MAL ID; proceed directly + proceed(with: existingMalID) + return + } + // Attempt to fetch MAL ID using AniList ID if available + if let aniListId = request.aniListID { + AniListMutation().fetchMalID(animeId: aniListId) { result in + switch result { + case .success(let mal): + // Save the fetched MAL ID to the download object if possible (JSActiveDownload is a struct so we cannot mutate here) + // but we can proceed using the fetched value. It is logged by CustomMediaPlayer too. + proceed(with: mal) + case .failure(let error): + Logger.shared.log("Unable to fetch MAL ID: \(error)", type: "Error") + completion(false) + } + } + return + } + // No MAL ID or AniList ID available; cannot proceed + Logger.shared.log("[SkipSidecar] No MAL ID or AniList ID available for AniSkip request", type: "Download") + completion(false) + } +} diff --git a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-StreamTypeDownload.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-StreamTypeDownload.swift index bb48d8f..e8ede8b 100644 --- a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-StreamTypeDownload.swift +++ b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-StreamTypeDownload.swift @@ -36,12 +36,15 @@ extension JSController { episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = nil, + aniListID: Int? = nil, + malID: Int? = nil, + isFiller: Bool? = nil, completionHandler: ((Bool, String) -> Void)? = nil ) { let streamType = module.metadata.streamType.lowercased() if streamType == "hls" || streamType == "m3u8" || url.absoluteString.contains(".m3u8") { - Logger.shared.log("Using HLS download method") + Logger.shared.log("Using HLS download method", type: "Download") downloadWithM3U8Support( url: url, headers: headers, @@ -53,10 +56,13 @@ extension JSController { episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL, + aniListID: aniListID, + malID: malID, + isFiller: isFiller, completionHandler: completionHandler ) }else { - Logger.shared.log("Using MP4 download method") + Logger.shared.log("Using MP4 download method", type: "Download") downloadMP4( url: url, headers: headers, @@ -68,6 +74,9 @@ extension JSController { episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL, + aniListID: aniListID, + malID: malID, + isFiller: isFiller, completionHandler: completionHandler ) } diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index 4f073e8..f7c912e 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -250,7 +250,29 @@ struct DownloadView: View { episodeNumber: asset.metadata?.episode ?? 0, episodeTitle: asset.metadata?.episodeTitle ?? "", seasonNumber: asset.metadata?.seasonNumber ?? 1, - onWatchNext: {}, + onWatchNext: { + let showTitle = asset.metadata?.showTitle ?? asset.name + let seasonNumber = asset.metadata?.seasonNumber + let currentEp = asset.metadata?.episode ?? 0 + let next = jsController.savedAssets + .filter { a in + let aTitle = a.metadata?.showTitle ?? a.name + let sameTitle = (aTitle == showTitle) + let sameSeason = (seasonNumber == nil) || (a.metadata?.seasonNumber == seasonNumber) + return sameTitle && sameSeason && (a.metadata?.episode ?? 0) > currentEp + } + .sorted { (a, b) in + let ae = a.metadata?.episode ?? 0 + let be = b.metadata?.episode ?? 0 + return ae < be + } + .first + if let next = next { + DispatchQueue.main.async { + self.playAsset(next) + } + } + }, subtitlesURL: asset.localSubtitleURL?.absoluteString, aniListID: 0, totalEpisodes: asset.metadata?.episode ?? 0, @@ -989,6 +1011,7 @@ struct EnhancedShowEpisodesView: View { @EnvironmentObject var jsController: JSController @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss + private var fillerBadgeOpacity: Double { colorScheme == .dark ? 0.18 : 0.12 } @State private var episodeSortOption: EpisodeSortOption = .episodeOrder @State private var showFullSynopsis = false @@ -1308,6 +1331,8 @@ struct EnhancedEpisodeRow: View { } } + @Environment(\.colorScheme) private var colorScheme + private var fillerBadgeOpacity: Double { colorScheme == .dark ? 0.18 : 0.12 } var body: some View { ZStack { actionButtonsBackground @@ -1368,8 +1393,22 @@ struct EnhancedEpisodeRow: View { .clipShape(RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading) { - Text("Episode \(asset.metadata?.episode ?? 0)") - .font(.system(size: 15)) + HStack(spacing: 8) { + Text("Episode \(asset.metadata?.episode ?? 0)") + .font(.system(size: 15)) + if asset.metadata?.isFiller == true { + Text("Filler") + .font(.system(size: 12, weight: .semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.red.opacity(fillerBadgeOpacity), in: Capsule()) + .overlay( + Capsule() + .stroke(Color.red.opacity(0.24), lineWidth: 0.6) + ) + .foregroundColor(.red) + } + } if let title = asset.metadata?.title { Text(title) .font(.system(size: 13)) @@ -1526,4 +1565,4 @@ struct SearchableStyleModifier: ViewModifier { ) ) } -} +} \ No newline at end of file diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 0bca941..bd31c65 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -13,6 +13,7 @@ struct EpisodeCell: View { let episodeIndex: Int let episode: String let episodeID: Int + let malID: Int? let progress: Double let itemID: Int let totalEpisodes: Int? @@ -23,6 +24,9 @@ struct EpisodeCell: View { let tmdbID: Int? let seasonNumber: Int? + //receives the set of filler episode numbers (from MediaInfoView) + let fillerEpisodes: Set? + let isMultiSelectMode: Bool let isSelected: Bool let onSelectionChanged: ((Bool) -> Void)? @@ -45,6 +49,7 @@ struct EpisodeCell: View { @State private var dragState: DragState = .inactive @State private var retryAttempts: Int = 0 + private var malIDFromParent: Int? { malID } private let maxRetryAttempts: Int = 3 private let initialBackoffDelay: TimeInterval = 1.0 @@ -53,10 +58,14 @@ struct EpisodeCell: View { @Environment(\.colorScheme) private var colorScheme @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system + // Filler state (derived from passed-in fillerEpisodes) + @State private var isFiller: Bool = false + init( episodeIndex: Int, episode: String, episodeID: Int, + malID: Int? = nil, progress: Double, itemID: Int, totalEpisodes: Int? = nil, @@ -70,11 +79,14 @@ struct EpisodeCell: View { onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void, tmdbID: Int? = nil, - seasonNumber: Int? = nil + seasonNumber: Int? = nil, + fillerEpisodes: Set? = nil ) { + self.episodeIndex = episodeIndex self.episode = episode self.episodeID = episodeID + self.malID = malID self.progress = progress self.itemID = itemID self.totalEpisodes = totalEpisodes @@ -88,7 +100,7 @@ struct EpisodeCell: View { self.onMarkAllPrevious = onMarkAllPrevious self.tmdbID = tmdbID self.seasonNumber = seasonNumber - + self.fillerEpisodes = fillerEpisodes let isLightMode = (UserDefaults.standard.string(forKey: "selectedAppearance") == "light") || ((UserDefaults.standard.string(forKey: "selectedAppearance") == "system") && @@ -107,8 +119,14 @@ struct EpisodeCell: View { episodeCellContent } - .onAppear { setupOnAppear() } - .onDisappear { activeDownloadTask = nil } + .onAppear { + setupOnAppear() + // set filler state based on passed-in set (if available) + let epNum = episodeID + 1 + if let set = fillerEpisodes { + self.isFiller = set.contains(epNum) + } + } .onChange(of: progress) { _ in updateProgress() } .onChange(of: itemID) { _ in handleItemIDChange() } .onChange(of: tmdbID) { _ in @@ -116,6 +134,14 @@ struct EpisodeCell: View { retryAttempts = 0 fetchEpisodeDetails() } + .onChange(of: fillerEpisodes) { newValue in + let epNum = episodeID + 1 + if let set = newValue { + self.isFiller = set.contains(epNum) + } else { + self.isFiller = false + } + } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { updateDownloadStatus() @@ -221,8 +247,27 @@ private extension EpisodeCell { var episodeInfo: some View { VStack(alignment: .leading) { - Text("Episode \(episodeID + 1)") - .font(.system(size: 15)) + HStack(spacing: 8) { + Text("Episode \(episodeID + 1)") + .font(.system(size: 15)) + .foregroundColor(.primary) + + if isFiller { + Text("Filler") + .font(.system(size: 12, weight: .semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color.red.opacity(colorScheme == .dark ? 0.20 : 0.10)) + ) + .overlay( + Capsule() + .stroke(Color.red.opacity(0.24), lineWidth: 0.6) + ) + .foregroundColor(.red) + } + } if !episodeTitle.isEmpty { Text(episodeTitle) @@ -658,7 +703,10 @@ private extension EpisodeCell { season: 1, episode: episodeID + 1, subtitleURL: subtitleURL, - showPosterURL: showPosterImageURL + showPosterURL: showPosterImageURL, + aniListID: itemID, + malID: malIDFromParent, + isFiller: isFiller ) { success, message in if success { Logger.shared.log("Started download for Episode \(self.episodeID + 1): \(self.episode)", type: "Download") @@ -932,6 +980,7 @@ private extension EpisodeCell { } }.resume() } + func handleFetchFailure(error: Error) { Logger.shared.log("Episode details fetch error: \(error.localizedDescription)", type: "Error") @@ -1025,5 +1074,3 @@ private struct AsyncImageView: View { .cornerRadius(8) } } - - diff --git a/Sora/Views/MediaInfoView/Matching/AnilistMatchView.swift b/Sora/Views/MediaInfoView/Matching/AnilistMatchView.swift index 35e996a..5fd2ebc 100644 --- a/Sora/Views/MediaInfoView/Matching/AnilistMatchView.swift +++ b/Sora/Views/MediaInfoView/Matching/AnilistMatchView.swift @@ -9,7 +9,7 @@ import SwiftUI struct AnilistMatchPopupView: View { let seriesTitle: String - let onSelect: (Int, String) -> Void + let onSelect: (Int, String, Int?) -> Void // id, title, malId @State private var results: [[String: Any]] = [] @State private var isLoading = true @@ -54,7 +54,9 @@ struct AnilistMatchPopupView: View { Button(action: { if let id = result["id"] as? Int { let title = result["title"] as? String ?? seriesTitle - onSelect(id, title) + let malId = result["mal_id"] as? Int + Logger.shared.log("Selected AniList ID: \(id), MAL ID: \(malId?.description ?? "nil")", type: "AnilistMatch") + onSelect(id, title, malId) dismiss() } }) { @@ -86,6 +88,11 @@ struct AnilistMatchPopupView: View { .font(.caption) .foregroundStyle(.secondary) } + if let malId = result["mal_id"] as? Int { + Text("MAL ID: \(malId)") + .font(.caption2) + .foregroundStyle(.secondary) + } } Spacer() @@ -153,7 +160,8 @@ struct AnilistMatchPopupView: View { Button("Cancel", role: .cancel) { } Button("Save") { if let idInt = Int(manualIDText.trimmingCharacters(in: .whitespaces)) { - onSelect(idInt, seriesTitle) + Logger.shared.log("Manual AniList ID: \(idInt), MAL ID: nil", type: "AnilistMatch") + onSelect(idInt, seriesTitle, nil) dismiss() } } @@ -170,6 +178,7 @@ struct AnilistMatchPopupView: View { Page(page: 1, perPage: 6) { media(search: "\(seriesTitle)", type: ANIME) { id + idMal title { romaji english @@ -204,6 +213,7 @@ struct AnilistMatchPopupView: View { let cover = (media["coverImage"] as? [String: Any])?["large"] as? String return [ "id": media["id"] ?? 0, + "mal_id": media["idMal"] as? Int ?? 0, "title": titleInfo?["romaji"] ?? "Unknown", "title_english": titleInfo?["english"] as Any, "cover": cover as Any @@ -212,4 +222,4 @@ struct AnilistMatchPopupView: View { } }.resume() } -} +} \ No newline at end of file diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 53f2275..eff8b26 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -33,6 +33,17 @@ struct MediaInfoView: View { @State private var tmdbID: Int? @State private var tmdbType: TMDBFetcher.MediaType? = nil @State private var currentFetchTask: Task? = nil + + // Jikan filler set for this media (passed down to EpisodeCell) + @State private var jikanFillerSet: Set? = nil + + // Static/shared Jikan cache & progress guards (one cache for the app to avoid duplicate/expensive fetches) + private static var jikanCache: [Int: (fetchedAt: Date, episodes: [JikanEpisode])] = [:] + private static let jikanCacheQueue = DispatchQueue(label: "sora.jikan.cache.queue", attributes: .concurrent) + private static let jikanCacheTTL: TimeInterval = 60 * 60 * 24 * 7 // 1 week + private static var inProgressMALIDs: Set = [] + private static let inProgressQueue = DispatchQueue(label: "sora.jikan.inprogress.queue") + @State private var isLoading: Bool = true @State private var showFullSynopsis: Bool = false @@ -61,6 +72,7 @@ struct MediaInfoView: View { @State private var isModuleSelectorPresented = false @State private var isMatchingPresented = false @State private var matchedTitle: String? = nil + @State private var matchedMalID: Int? = nil @State private var showSettingsMenu = false @State private var customAniListID: Int? @State private var showStreamLoadingView: Bool = false @@ -187,6 +199,7 @@ struct MediaInfoView: View { .ignoresSafeArea(.container, edges: .top) .onAppear { setupViewOnAppear() + NotificationCenter.default.post(name: .hideTabBar, object: nil) UserDefaults.standard.set(true, forKey: "isMediaInfoActive") } @@ -205,6 +218,14 @@ struct MediaInfoView: View { .onChange(of: selectedChapterRange) { newValue in UserDefaults.standard.set(newValue.lowerBound, forKey: selectedChapterRangeKey) } + .onChange(of: itemID) { newValue in + guard newValue != nil else { return } + fetchJikanFillerInfoIfNeeded() + } + .onChange(of: matchedMalID) { newValue in + guard newValue != nil else { return } + fetchJikanFillerInfoIfNeeded() + } .onDisappear { currentFetchTask?.cancel() activeFetchID = nil @@ -551,7 +572,7 @@ struct MediaInfoView: View { let seasons = groupedEpisodes() if seasons.count > 1 { Menu { - ForEach(0.. Void) { + let query = """ + query { + Media(id: \(anilistID)) { + idMal + } + } + """ + guard let url = URL(string: "https://graphql.anilist.co") else { + completion(nil) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["query": query]) + + URLSession.shared.dataTask(with: request) { data, _, _ in + var malID: Int? = nil + if let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataDict = json["data"] as? [String: Any], + let media = dataDict["Media"] as? [String: Any], + let idMal = media["idMal"] as? Int { + malID = idMal + } + DispatchQueue.main.async { + completion(malID) + } + }.resume() +} private func fetchTMDBPosterImageAndSet() { guard let tmdbID = tmdbID, let tmdbType = tmdbType else { return } let apiType = tmdbType.rawValue @@ -2434,4 +2500,155 @@ struct MediaInfoView: View { } return "" } -} + + // MARK: - Updated Jikan Filler Implementation + private struct JikanResponse: Decodable { + let data: [JikanEpisode] + } + + private struct JikanEpisode: Decodable { + let mal_id: Int + let filler: Bool + } + + private func fetchJikanFillerInfoIfNeeded() { + guard jikanFillerSet == nil else { return } + fetchJikanFillerInfo() + } + + private func fetchJikanFillerInfo() { + guard let malID = matchedMalID ?? itemID else { + Logger.shared.log("MAL ID not available for filler info", type: "Debug") + return + } + + // Check cache first + var cachedEpisodes: [JikanEpisode]? = nil + Self.jikanCacheQueue.sync { + if let entry = Self.jikanCache[malID], Date().timeIntervalSince(entry.fetchedAt) < Self.jikanCacheTTL { + cachedEpisodes = entry.episodes + } + } + + if let episodes = cachedEpisodes { + Logger.shared.log("Using cached filler info for MAL ID: \(malID)", type: "Debug") + updateFillerSet(episodes: episodes) + return + } + + // Prevent duplicate requests + var shouldFetch = false + Self.inProgressQueue.sync { + if !Self.inProgressMALIDs.contains(malID) { + Self.inProgressMALIDs.insert(malID) + shouldFetch = true + } + } + + if !shouldFetch { + Logger.shared.log("Fetch already in progress for MAL ID: \(malID)", type: "Debug") + return + } + + Logger.shared.log("Fetching filler info for MAL ID: \(malID)", type: "Debug") + + // Fetch all pages + fetchAllJikanPages(malID: malID) { episodes in + // Update cache + if let episodes = episodes { + Logger.shared.log("Successfully fetched filler info for MAL ID: \(malID)", type: "Debug") + Self.jikanCacheQueue.async(flags: .barrier) { + Self.jikanCache[malID] = (Date(), episodes) + } + + // Update UI + DispatchQueue.main.async { + self.updateFillerSet(episodes: episodes) + } + } else { + Logger.shared.log("Failed to fetch filler info for MAL ID: \(malID)", type: "Error") + } + + // Remove from in-progress set + Self.inProgressQueue.async { + Self.inProgressMALIDs.remove(malID) + } + } + } + + private func fetchAllJikanPages(malID: Int, completion: @escaping ([JikanEpisode]?) -> Void) { + var allEpisodes: [JikanEpisode] = [] + var currentPage = 1 + let perPage = 100 + var nextAllowedTime = DispatchTime.now() + + func fetchPage() { + // Throttle to <= 3 req/sec (Jikan limit) + let now = DispatchTime.now() + let delay: Double + if now < nextAllowedTime { + let diff = Double(nextAllowedTime.uptimeNanoseconds - now.uptimeNanoseconds) / 1_000_000_000 + delay = max(diff, 0) + } else { + delay = 0 + } + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { + nextAllowedTime = DispatchTime.now() + .milliseconds(350) + + let url = URL(string: "https://api.jikan.moe/v4/anime/\(malID)/episodes?page=\(currentPage)&limit=\(perPage)")! + URLSession.shared.dataTask(with: url) { data, response, error in + // Handle transient errors and Jikan rate-limits with minimal backoff/retry. + let http = response as? HTTPURLResponse + let status = http?.statusCode ?? 0 + + // Simple per-page retry counter stored via associated closure capture + struct RetryCounter { static var attempts: [Int: Int] = [:] } + let key = currentPage + let attempts = RetryCounter.attempts[key] ?? 0 + + let shouldRetry: Bool = (error != nil) || (status == 429) || (status >= 500) + if shouldRetry && attempts < 5 { + let retryAfterSeconds: Double = { + if status == 429, let ra = http?.value(forHTTPHeaderField: "Retry-After"), let v = Double(ra) { return min(v, 5.0) } + return min(pow(1.5, Double(attempts)) , 5.0) + }() + RetryCounter.attempts[key] = attempts + 1 + Logger.shared.log("Jikan page \(currentPage) retry \(attempts+1) after \(retryAfterSeconds)s (status=\(status), error=\(error?.localizedDescription ?? "nil"))", type: "Debug") + DispatchQueue.global().asyncAfter(deadline: .now() + retryAfterSeconds) { + fetchPage() + } + return + } + + guard let data = data, error == nil, (200..<300).contains(status) || status == 0 else { + Logger.shared.log("Jikan API request failed for page \(currentPage): status=\(status), error=\(error?.localizedDescription ?? "Unknown")", type: "Error") + completion(nil) + return + } + + do { + let response = try JSONDecoder().decode(JikanResponse.self, from: data) + allEpisodes.append(contentsOf: response.data) + if response.data.count == perPage { + currentPage += 1 + fetchPage() + } else { + completion(allEpisodes) + } + } catch { + Logger.shared.log("Failed to parse Jikan response: \(error)", type: "Error") + completion(nil) + } + }.resume() + + } + } + fetchPage() + } + + private func updateFillerSet(episodes: [JikanEpisode]) { + let fillerNumbers = Set(episodes.filter { $0.filler }.map { $0.mal_id }) + self.jikanFillerSet = fillerNumbers + Logger.shared.log("Updated filler set with \(fillerNumbers.count) filler episodes", type: "Debug") + } +} \ No newline at end of file