From 334deec48456dbc0d7b0e6ecb0cefa239042db7c Mon Sep 17 00:00:00 2001 From: scigward Date: Tue, 19 Aug 2025 22:51:59 +0300 Subject: [PATCH] Test --- .../CustomPlayer/CustomPlayer.swift | 56 +++++++++++++++ .../DownloadUtils/DownloadModels.swift | 11 ++- .../Downloads/JSController+Downloader.swift | 15 ++-- .../Downloads/JSController-Downloads.swift | 72 ++++++++++++++++++- .../EpisodeCell/EpisodeCell.swift | 2 +- 5 files changed, 142 insertions(+), 14 deletions(-) diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index 6a57dc0..c83f79c 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -368,6 +368,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele setupPipIfSupported() setupTimeBatteryIndicator() setupTopRowLayout() + self.loadLocalSkipTimestampsIfAvailable() + updateSkipButtonsVisibility() if !isSkip85Visible { @@ -399,6 +401,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self?.malID = mal self?.fetchSkipTimes(type: "op") self?.fetchSkipTimes(type: "ed") + self?.loadLocalSkipTimestampsIfAvailable() case .failure(let error): Logger.shared.log("Unable to fetch MAL ID: \(error)",type:"Error") } @@ -3863,4 +3866,57 @@ class GradientBlurButton: UIButton { cleanupVisualEffects() super.removeFromSuperview() } + +private func loadLocalSkipTimestampsIfAvailable() { + // Try from subtitle file path first + var candidateURLs: [URL] = [] + if let sub = subtitlesURL, !sub.isEmpty, let u = URL(string: sub) { + candidateURLs.append(u) + } + if let u = URL(string: streamURL) { + candidateURLs.append(u) + } + for u in candidateURLs { + // Ensure file URL + let fileURL: URL + if u.isFileURL { + fileURL = u + } else if let url = URL(string: u.absoluteString), url.isFileURL { + fileURL = url + } else { + continue + } + let base = fileURL.deletingPathExtension() + let candidates = [ + base.appendingPathExtension("skip.json"), + base.deletingLastPathComponent().appendingPathComponent(base.lastPathComponent + ".skip.json") + ] + for jsonURL in candidates { + if FileManager.default.fileExists(atPath: jsonURL.path), + let data = try? Data(contentsOf: jsonURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let op = json["op"] as? [String: Any], + let s = op["start"] as? Double, let e = op["end"] as? Double { + self.skipIntervals.op = CMTimeRange( + start: CMTime(seconds: s, preferredTimescale: 600), + end: CMTime(seconds: e, preferredTimescale: 600) + ) + } + if let ed = json["ed"] as? [String: Any], + let s = ed["start"] as? Double, let e = ed["end"] as? Double { + self.skipIntervals.ed = CMTimeRange( + start: CMTime(seconds: s, preferredTimescale: 600), + end: CMTime(seconds: e, preferredTimescale: 600) + ) + } + if self.duration > 0 { + self.updateSegments() + } + self.updateSkipButtonsVisibility() + return + } + } + } +} + } diff --git a/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift b/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift index 685a943..74f3f9d 100644 --- a/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift +++ b/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift @@ -414,7 +414,9 @@ struct AssetMetadata: Codable { let episodeTitle: String? let seasonNumber: Int? - init( + + let anilistId: Int? +init( title: String, overview: String? = nil, posterURL: URL? = nil, @@ -425,7 +427,8 @@ struct AssetMetadata: Codable { episode: Int? = nil, showPosterURL: URL? = nil, episodeTitle: String? = nil, - seasonNumber: Int? = nil + seasonNumber: Int? = nil, + anilistId: Int? = nil ) { self.title = title self.overview = overview @@ -438,7 +441,9 @@ struct AssetMetadata: Codable { self.showPosterURL = showPosterURL self.episodeTitle = episodeTitle self.seasonNumber = seasonNumber - } + + self.anilistId = anilistId +} } // MARK: - New Group Model diff --git a/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift index 810a917..07c4fd4 100644 --- a/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift +++ b/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift @@ -14,7 +14,9 @@ struct DownloadRequest { let headers: [String: String] let title: String? let imageURL: URL? - let isEpisode: Bool + + let aniListID: Int? +let isEpisode: Bool let showTitle: String? let season: Int? let episode: Int? @@ -57,10 +59,11 @@ extension JSController { subtitleURL: URL? = nil, showPosterURL: URL? = nil, completionHandler: ((Bool, String) -> Void)? = nil) { + let pendingAni = UserDefaults.standard.object(forKey: "PendingAniListIDForDownload") as? Int 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: pendingAni ) logDownloadStart(request: request) @@ -92,13 +95,7 @@ extension JSController { self.logM3U8QualitySelected(quality: selectedQuality) 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 - ) + 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, aniListID: request.aniListID) self.downloadWithOriginalMethod(request: qualityRequest, completionHandler: completionHandler) } else { self.logM3U8InvalidURL() diff --git a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift index 024d44a..19a0b38 100644 --- a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift +++ b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift @@ -114,11 +114,13 @@ extension JSController { subtitleURL: URL? = nil, showPosterURL: URL? = nil, module: ScrapingModule? = nil, + aniListID: Int? = nil, completionHandler: ((Bool, String) -> Void)? = nil ) { // If a module is provided, use the stream type aware download if let module = module { // Use the stream type aware download method + if let anilist = aniListID { UserDefaults.standard.set(anilist, forKey: "PendingAniListIDForDownload") } else { UserDefaults.standard.removeObject(forKey: "PendingAniListIDForDownload") } downloadWithStreamTypeSupport( url: url, headers: headers, @@ -1214,7 +1216,10 @@ extension JSController: AVAssetDownloadDelegate { } // If there's a subtitle URL, download it now that the video is saved - if let subtitleURL = download.subtitleURL { + + // Save OP/ED skip timestamps JSON in parallel + saveSkipTimestampsJSON(for: persistentURL, anilistId: newAsset.metadata?.anilistId, episodeNumber: newAsset.metadata?.episode) +if let subtitleURL = download.subtitleURL { downloadSubtitle(subtitleURL: subtitleURL, assetID: newAsset.id.uuidString) } else { // No subtitle URL, so we can consider the download complete @@ -1647,3 +1652,68 @@ enum DownloadQueueStatus: Equatable { /// Download has been completed case completed } + + +// MARK: - AniSkip Timestamps Saving +private func saveSkipTimestampsJSON(for videoURL: URL, anilistId: Int?, episodeNumber: Int?) { + // Determine destination JSON path next to the video file + let base = videoURL.deletingPathExtension() + let jsonURL = base.appendingPathExtension("skip.json") + + func writeJSON(op: (Double,Double)?, ed: (Double,Double)?, mal: Int?) { + var dict: [String: Any] = [ + "source": "aniskip", + "createdAt": ISO8601DateFormatter().string(from: Date()) + ] + if let mal = mal { dict["malId"] = mal } + if let aid = anilistId { dict["anilistId"] = aid } + if let ep = episodeNumber { dict["episode"] = ep } + if let op = op { + dict["op"] = ["start": op.0, "end": op.1] + } + if let ed = ed { + dict["ed"] = ["start": ed.0, "end": ed.1] + } + do { + let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted]) + try data.write(to: jsonURL, options: .atomic) + } catch { + print("Failed to write skip JSON: \(error.localizedDescription)") + } + } + + guard let anilistId = anilistId, anilistId > 0, let ep = episodeNumber else { + // No IDs; nothing to fetch + return + } + // Map AniList -> MAL then fetch AniSkip for both OP and ED + AniListMutation().fetchMalID(animeId: anilistId) { result in + switch result { + case .success(let mal): + let group = DispatchGroup() + var opInterval: (Double,Double)? = nil + var edInterval: (Double,Double)? = nil + + func fetch(type: String, assign: @escaping ((Double,Double))->Void) { + guard let url = URL(string: "https://api.aniskip.com/v2/skip-times/\(mal)/\(ep)?types=\(type)&episodeLength=0") else { return } + group.enter() + URLSession.shared.dataTask(with: url) { data, _, _ in + defer { group.leave() } + guard let data = data, + let resp = try? JSONDecoder().decode(AniSkipResponse.self, from: data), + resp.found, let interval = resp.results.first?.interval else { return } + assign((interval.startTime, interval.endTime)) + }.resume() + } + + fetch(type: "op") { opInterval = $0 } + fetch(type: "ed") { edInterval = $0 } + + group.notify(queue: .global(qos: .utility)) { + writeJSON(op: opInterval, ed: edInterval, mal: mal) + } + case .failure(let e): + print("Failed to map AniList to MAL for skip JSON: \(e.localizedDescription)") + } + } +} \ No newline at end of file diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 8e4fc9f..5788c17 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -691,7 +691,7 @@ private extension EpisodeCell { let fullEpisodeTitle = episodeTitle.isEmpty ? baseTitle : "\(baseTitle): \(episodeTitle)" let animeTitle = parentTitle.isEmpty ? "Unknown Anime" : parentTitle - jsController.downloadWithStreamTypeSupport( + jsController.startDownload( url: url, headers: headers, title: fullEpisodeTitle,