diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index 6a57dc0..dc75734 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -48,7 +48,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele var isPlaying = true var currentTimeVal: Double = 0.0 var duration: Double = 0.0 - var isVideoLoaded = false + + let localSkipSegments: [(String, Double, Double)]? +var isVideoLoaded = false private var isHoldPauseEnabled: Bool { UserDefaults.standard.bool(forKey: "holdForPauseEnabled") @@ -267,7 +269,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele subtitlesURL: String?, aniListID: Int, totalEpisodes: Int, - episodeImageUrl: String,headers:[String:String]?) { + episodeImageUrl: String, localSkipSegments: [(String, Double, Double)]? = nil, headers:[String:String]?) { self.module = module self.streamURL = urlString @@ -276,6 +278,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.episodeNumber = episodeNumber self.episodeImageUrl = episodeImageUrl self.episodeTitle = episodeTitle + self.localSkipSegments = localSkipSegments self.seasonNumber = seasonNumber self.onWatchNext = onWatchNext self.subtitlesURL = subtitlesURL @@ -393,7 +396,19 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele view.bringSubviewToFront(subtitleStackView) subtitleStackView.isHidden = !SubtitleSettingsManager.shared.settings.enabled - AniListMutation().fetchMalID(animeId: aniListID) { [weak self] result in + + if let segs = localSkipSegments { + for s in segs { + if s.0 == "op" { + self.skipIntervals.op = CMTimeRange(start: CMTime(seconds: s.1, preferredTimescale: 600), end: CMTime(seconds: s.2, preferredTimescale: 600)) + } else if s.0 == "ed" { + self.skipIntervals.ed = CMTimeRange(start: CMTime(seconds: s.1, preferredTimescale: 600), end: CMTime(seconds: s.2, preferredTimescale: 600)) + } + } + self.updateSegments() + } +if localSkipSegments == nil { + AniListMutation().fetchMalID(animeId: aniListID) { [weak self] result in switch result { case .success(let mal): self?.malID = mal @@ -403,6 +418,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele Logger.shared.log("Unable to fetch MAL ID: \(error)",type:"Error") } } + } for control in controlsToHide { originalHiddenStates[control] = control.isHidden @@ -3863,4 +3879,4 @@ class GradientBlurButton: UIButton { cleanupVisualEffects() super.removeFromSuperview() } -} +} \ No newline at end of file diff --git a/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift index 810a917..b0227d0 100644 --- a/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift +++ b/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift @@ -14,6 +14,7 @@ struct DownloadRequest { let headers: [String: String] let title: String? let imageURL: URL? + let aniListID: Int? let isEpisode: Bool let showTitle: String? let season: Int? @@ -52,13 +53,13 @@ struct QualityOption { extension JSController { func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil, - imageURL: URL? = nil, isEpisode: Bool = false, + imageURL: URL? = nil, aniListID: Int? = nil, isEpisode: Bool = false, showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = nil, completionHandler: ((Bool, String) -> Void)? = nil) { let request = DownloadRequest( - url: url, headers: headers, title: title, imageURL: imageURL, + url: url, headers: headers, title: title, imageURL: imageURL, aniListID: aniListID, isEpisode: isEpisode, showTitle: showTitle, season: season, episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL ) @@ -94,7 +95,7 @@ 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, + imageURL: request.imageURL, aniListID: request.aniListID, isEpisode: request.isEpisode, showTitle: request.showTitle, season: request.season, episode: request.episode, subtitleURL: request.subtitleURL, showPosterURL: request.showPosterURL @@ -122,7 +123,7 @@ extension JSController { func downloadMP4(url: URL, headers: [String: String], title: String? = nil, - imageURL: URL? = nil, isEpisode: Bool = false, + imageURL: URL? = nil, aniListID: Int? = nil, isEpisode: Bool = false, showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = nil, completionHandler: ((Bool, String) -> Void)? = nil) { @@ -399,6 +400,7 @@ extension JSController { private func downloadWithOriginalMethod(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { self.startDownload( url: request.url, + aniListID: request.aniListID, headers: request.headers, title: request.title, imageURL: request.imageURL, @@ -531,4 +533,4 @@ extension JSController { private func logQualitySelectionResult(quality: QualityOption, preference: String) { Logger.shared.log("Quality selected: \(quality.name) (preference: \(preference))", type: "Download") } -} +} \ No newline at end of file diff --git a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift index 024d44a..bcce9ff 100644 --- a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift +++ b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift @@ -104,6 +104,7 @@ extension JSController { /// - completionHandler: Optional callback for download status func startDownload( url: URL, + aniListID: Int? = nil, headers: [String: String] = [:], title: String? = nil, imageURL: URL? = nil, @@ -121,6 +122,7 @@ extension JSController { // Use the stream type aware download method downloadWithStreamTypeSupport( url: url, + aniListID: aniListID, headers: headers, title: title, imageURL: imageURL, @@ -177,7 +179,8 @@ extension JSController { subtitleURL: subtitleURL, asset: asset, headers: headers, - module: module // Pass the module to store it for queue processing + module: module, // Pass the module to store it for queue processing + aniListID: aniListID ) // Add to the download queue @@ -271,6 +274,7 @@ extension JSController { // Use the exact same method that manual downloads use downloadWithStreamTypeSupport( url: queuedDownload.originalURL, + aniListID: queuedDownload.aniListID, headers: queuedDownload.headers, title: queuedDownload.title, imageURL: queuedDownload.imageURL, @@ -929,6 +933,13 @@ extension JSController { } } DownloadPersistence.delete(id: asset.id) + + // Remove AniSkip sidecar if present + if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + let dir = appSupport.appendingPathComponent("SoraDownloads", isDirectory: true) + let sidecar = dir.appendingPathComponent("aniskip-\(asset.id.uuidString).json") + try? FileManager.default.removeItem(at: sidecar) + } DispatchQueue.main.async { [weak self] in self?.savedAssets = DownloadPersistence.load() self?.objectWillChange.send() @@ -1208,7 +1219,13 @@ extension JSController: AVAssetDownloadDelegate { // Add to saved assets and save DownloadPersistence.upsert(newAsset) - DispatchQueue.main.async { [weak self] in + + // Fetch and save AniSkip OP/ED markers as a sidecar (non-blocking, optional) + if let isEp = download.metadata?.episode, isEp > 0 { + let epNumber = isEp + fetchAndSaveAniSkipSidecar(aniListID: aniListID, episode: epNumber, assetID: newAsset.id.uuidString) + } +DispatchQueue.main.async { [weak self] in self?.savedAssets = DownloadPersistence.load() self?.objectWillChange.send() } @@ -1543,6 +1560,7 @@ struct JSActiveDownload: Identifiable, Equatable { var asset: AVURLAsset? var headers: [String: String] var module: ScrapingModule? // Add module property to store ScrapingModule + var aniListID: Int? = nil // Computed property to get the current task state var taskState: URLSessionTask.State { @@ -1586,7 +1604,8 @@ 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 // Add module parameter to initializer ) { self.id = id self.originalURL = originalURL @@ -1646,4 +1665,58 @@ enum DownloadQueueStatus: Equatable { case downloading /// Download has been completed case completed -} +} + + // MARK: - AniSkip Sidecar + private func fetchAndSaveAniSkipSidecar(aniListID: Int?, episode: Int, assetID: String) { + guard let ani = aniListID else { return } + AniListMutation().fetchMalID(animeId: ani) { result in + switch result { + case .success(let mal): + let types = ["op", "ed"] + var segments: [(String, Double, Double)] = [] + let group = DispatchGroup() + for t in types { + group.enter() + if let url = URL(string: "https://api.aniskip.com/v2/skip-times/\(mal)/\(episode)?types=\(t)&episodeLength=0") { + URLSession.shared.dataTask(with: url) { data, _, _ in + defer { group.leave() } + guard let d = data, + let resp = try? JSONDecoder().decode(AniSkipResponse.self, from: d), + resp.found, + let interval = resp.results.first?.interval else { return } + segments.append((t, interval.startTime, interval.endTime)) + }.resume() + } else { + group.leave() + } + } + group.notify(queue: .global()) { + guard !segments.isEmpty else { return } + let payload: [String: Any] = [ + "provider": "aniskip", + "malId": mal, + "episode": episode, + "segments": segments.map { ["type": $0.0, "start": $0.1, "end": $0.2] }, + "fetchedAt": ISO8601DateFormatter().string(from: Date()), + "v": 1 + ] + do { + if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + let dir = appSupport.appendingPathComponent("SoraDownloads", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let file = dir.appendingPathComponent("aniskip-\(assetID).json") + let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]) + try data.write(to: file, options: .atomic) + Logger.shared.log("Saved AniSkip sidecar: \(file.lastPathComponent)", type: "Download") + } + } catch { + Logger.shared.log("Failed to save AniSkip sidecar: \(error)", type: "Error") + } + } + case .failure: + break + } + } + } + diff --git a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-StreamTypeDownload.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-StreamTypeDownload.swift index bb48d8f..6d3ad7d 100644 --- a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-StreamTypeDownload.swift +++ b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-StreamTypeDownload.swift @@ -24,26 +24,14 @@ extension JSController { /// - 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 - ) { + func downloadWithStreamTypeSupport(url: URL, headers: [String: String], title: String? = nil, imageURL: URL? = nil, module: ScrapingModule, isEpisode: Bool = false, aniListID: Int? = nil, showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = 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") downloadWithM3U8Support( url: url, + aniListID: aniListID, headers: headers, title: title, imageURL: imageURL, @@ -59,6 +47,7 @@ extension JSController { Logger.shared.log("Using MP4 download method") downloadMP4( url: url, + aniListID: aniListID, headers: headers, title: title, imageURL: imageURL, @@ -72,4 +61,4 @@ extension JSController { ) } } -} +} \ No newline at end of file diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index 31ae0c4..38aaaab 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -6,6 +6,7 @@ // import AVKit +import Foundation import NukeUI import SwiftUI @@ -242,7 +243,24 @@ struct DownloadView: View { metadataUrl: "" ) - let customPlayer = CustomMediaPlayerViewController( + + var localSkipSegments: [(String, Double, Double)]? = nil + if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + let dir = appSupport.appendingPathComponent("SoraDownloads", isDirectory: true) + let sidecar = dir.appendingPathComponent("aniskip-\(asset.id.uuidString).json") + if let data = try? Data(contentsOf: sidecar), + let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let segs = obj["segments"] as? [[String: Any]] { + localSkipSegments = segs.compactMap { dict in + if let t = dict["type"] as? String, + let st = dict["start"] as? Double, + let en = dict["end"] as? Double { return (t, st, en) } + return nil + } + if localSkipSegments?.isEmpty == true { localSkipSegments = nil } + } + } +let customPlayer = CustomMediaPlayerViewController( module: dummyModule, urlString: asset.localURL.absoluteString, fullUrl: asset.originalURL.absoluteString, @@ -277,6 +295,7 @@ struct DownloadView: View { aniListID: 0, totalEpisodes: asset.metadata?.episode ?? 0, episodeImageUrl: asset.metadata?.posterURL?.absoluteString ?? "", + localSkipSegments: localSkipSegments, headers: nil ) @@ -1548,4 +1567,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 8e4fc9f..31af3b2 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -691,13 +691,7 @@ private extension EpisodeCell { let fullEpisodeTitle = episodeTitle.isEmpty ? baseTitle : "\(baseTitle): \(episodeTitle)" let animeTitle = parentTitle.isEmpty ? "Unknown Anime" : parentTitle - jsController.downloadWithStreamTypeSupport( - url: url, - headers: headers, - title: fullEpisodeTitle, - imageURL: episodeThumbnailURL, - module: module, - isEpisode: true, + jsController.downloadWithStreamTypeSupport(url: url, headers: headers, title: fullEpisodeTitle, imageURL: episodeThumbnailURL, module: module, isEpisode: true, aniListID: itemID, showTitle: animeTitle, season: 1, episode: episodeID + 1, @@ -976,8 +970,6 @@ private extension EpisodeCell { } }.resume() } - - // Removed Jikan fetching from EpisodeCell. All filler/Jikan handling is now in MediaInfoView and passed in via `fillerEpisodes`. func handleFetchFailure(error: Error) { Logger.shared.log("Episode details fetch error: \(error.localizedDescription)", type: "Error") diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index c2920aa..de51568 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -2228,6 +2228,7 @@ struct MediaInfoView: View { self.jsController.downloadWithStreamTypeSupport( url: url, + aniListID: self.itemID, headers: headers, title: episodeTitle, imageURL: episodeThumbnailURL,