diff --git a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift index fc9a959..23155c5 100644 --- a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift +++ b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift @@ -978,7 +978,7 @@ extension JSController { /// 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) @@ -1673,151 +1673,129 @@ enum DownloadQueueStatus: Equatable { // MARK: - Skip Sidecar (OP/ED) Fetch /// Fetches OP & ED skip timestamps (AniSkip) and writes a minimal sidecar JSON next to the persisted video. /// Uses MAL id for fillers when available; falls back to AniList otherwise. - private func fetchSkipTimestampsFor(request: JSActiveDownload, persistentURL: URL, completion: @escaping (Bool)->Void) -{ - // Determine preferred ID - let epNumber = request.metadata?.episode ?? 0 - let useMAL = (request.isFiller == true) && (request.malID != nil) - let idType = useMAL ? "mal" : "anilist" - guard let seriesID = useMAL ? request.malID : request.aniListID else { - print("[SkipSidecar] Missing series ID for AniSkip (MAL/AniList)") + private +/// Fetch OP/ED skip timestamps for a finished download using **MAL** only. +/// - Parameters: +/// - request: The active download metadata (must contain `malID` and `metadata?.episode`) +/// - persistentURL: Final URL where the video was persisted +/// - completion: Called with `true` on success, `false` on failure +func fetchSkipTimestampsFor(request: JSActiveDownload, persistentURL: URL, completion: @escaping (Bool) -> Void) { + // Validate MAL ID and Episode + guard let malID = request.malID else { + Logger.shared.log("[SkipSidecar] No MAL ID on request → skipping OP/ED fetching", type: "Download") + completion(false) + return + } + guard let episodeNumber = request.metadata?.episode else { + Logger.shared.log("[SkipSidecar] Missing episode number on request → skipping OP/ED fetching", type: "Download") + completion(false) + return + } + + // Log that we are about to fetch + Logger.shared.log("[SkipSidecar] Starting fetch for MAL=\(malID) episode=\(episodeNumber)", type: "Download") + + // Build AniSkip v2 endpoint (MAL provider only) + // Example: https://api.aniskip.com/v2/skip-times/mal/{malID}/{episode}?types=op,ed + guard let url = URL(string: "https://api.aniskip.com/v2/skip-times/mal/\(malID)/\(episodeNumber)?types=op,ed") else { + Logger.shared.log("[SkipSidecar] Failed to build AniSkip URL for malID=\(malID) ep=\(episodeNumber)", type: "Error") + completion(false) + return + } + + var req = URLRequest(url: url) + req.timeoutInterval = 15 + + let task = URLSession.shared.dataTask(with: req) { data, response, error in + // Handle networking errors + if let error = error { + Logger.shared.log("[SkipSidecar] Network error while fetching skip-times: \(error.localizedDescription)", type: "Error") completion(false) return } - // Single AniSkip v1 call for both OP/ED - let url = URL(string: "https://api.aniskip.com/v1/skip-times/\(seriesID)/\(epNumber)?types=op&types=ed")! - URLSession.shared.dataTask(with: url) { data, _, error in - if let e = error { - print("[SkipSidecar] AniSkip fetch error: \(e.localizedDescription)") + guard let http = response as? HTTPURLResponse else { + Logger.shared.log("[SkipSidecar] No HTTP response while fetching skip-times", type: "Error") + completion(false) + return + } + guard (200...299).contains(http.statusCode), let data = data else { + let size = data?.count ?? 0 + Logger.shared.log("[SkipSidecar] Bad status \(http.statusCode). Body bytes=\(size)", type: "Error") + completion(false) + return + } + + // Parse response + do { + let json = try JSONSerialization.jsonObject(with: data, options: []) + guard + let dict = json as? [String: Any], + let found = dict["found"] as? Bool, + found == true, + let results = dict["results"] as? [[String: Any]] + else { + Logger.shared.log("[SkipSidecar] AniSkip returned empty results for malID=\(malID) ep=\(episodeNumber)", type: "Download") completion(false) return } - guard let data = data else { completion(false); return } - struct Resp: Decodable { let found: Bool; let results: [Res]? } - struct Res: Decodable { let skip_type: String; let interval: Interval } - struct Interval: Decodable { let start_time: Double; let end_time: Double } - var opRange: (Double, Double)? = nil - var edRange: (Double, Double)? = nil - if let r = try? JSONDecoder().decode(Resp.self, from: data), r.found, let arr = r.results { - for item in arr { - if item.skip_type == "op" { opRange = (item.interval.start_time, item.interval.end_time) } - if item.skip_type == "ed" { edRange = (item.interval.start_time, item.interval.end_time) } + + var opStart: Double? = nil + var opEnd: Double? = nil + var edStart: Double? = nil + var edEnd: Double? = nil + + for entry in results { + guard + let interval = entry["interval"] as? [String: Any], + let start = interval["start_time"] as? Double, + let end = interval["end_time"] as? Double, + let type = entry["skip_type"] as? String + else { continue } + + if type.lowercased() == "op" { + opStart = start; opEnd = end + } else if type.lowercased() == "ed" { + edStart = start; edEnd = end } } - if opRange == nil && edRange == nil { completion(false); return } - // Determine sidecar path - let fm = FileManager.default - var dir = persistentURL.deletingLastPathComponent() - var baseName = persistentURL.deletingPathExtension().lastPathComponent - var isDir: ObjCBool = false - if fm.fileExists(atPath: persistentURL.path, isDirectory: &isDir), isDir.boolValue { - dir = persistentURL.deletingLastPathComponent() - baseName = persistentURL.lastPathComponent + + if opStart == nil && edStart == nil { + Logger.shared.log("[SkipSidecar] No OP/ED entries found in results for malID=\(malID) ep=\(episodeNumber)", type: "Download") + completion(false) + return } - let sidecar = dir.appendingPathComponent(baseName + ".skip.json") + + // Build sidecar JSON to store next to the video file + let baseURL = persistentURL.deletingPathExtension() + let sidecarURL = baseURL.appendingPathExtension("skip.json") + var payload: [String: Any] = [ - "source": "aniskip", - "idType": idType, - "episode": epNumber, - "createdAt": ISO8601DateFormatter().string(from: Date()) + "malID": malID, + "episode": episodeNumber, + "source": "AniSkip", + "version": 2 ] - if let aid = request.aniListID { payload["anilistId"] = aid } - if let mid = request.malID { payload["malId"] = mid } - if let op = opRange { payload["op"] = ["start": op.0, "end": op.1] } - if let ed = edRange { payload["ed"] = ["start": ed.0, "end": ed.1] } + if let s = opStart, let e = opEnd { payload["op"] = ["start": s, "end": e] } + if let s = edStart, let e = edEnd { payload["ed"] = ["start": s, "end": e] } + do { - let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]) - try data.write(to: sidecar, options: .atomic) - print("[SkipSidecar] Wrote sidecar at: \(sidecar.path)") + let sidecarData = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]) + try sidecarData.write(to: sidecarURL, options: [.atomic]) + Logger.shared.log("[SkipSidecar] Saved skip sidecar → \(sidecarURL.lastPathComponent)", type: "Download") completion(true) } catch { - print("[SkipSidecar] Sidecar write error: \(error.localizedDescription)") + Logger.shared.log("[SkipSidecar] Failed to save sidecar \(sidecarURL.lastPathComponent): \(error.localizedDescription)", type: "Error") completion(false) } - }.resume() + } catch { + Logger.shared.log("[SkipSidecar] JSON parse error: \(error.localizedDescription)", type: "Error") + completion(false) + } } + task.resume() +} + + } - -// 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 for fillers when available; falls back to AniList otherwise. - func fetchSkipTimestampsFor(request: JSActiveDownload, - persistentURL: URL, - completion: @escaping (Bool) -> Void) { - // Determine preferred ID - let epNumber = request.metadata?.episode ?? 0 - let useMAL = (request.isFiller == true) && (request.malID != nil) - let idType = useMAL ? "mal" : "anilist" - guard let seriesID = useMAL ? request.malID : request.aniListID else { - print("[SkipSidecar] Missing series ID for AniSkip (MAL/AniList)") - completion(false) - return - } - - // Single AniSkip v1 call for both OP/ED - guard let url = URL(string: "https://api.aniskip.com/v1/skip-times/\(seriesID)/\(epNumber)?types=op&types=ed") else { - completion(false) - return - } - - URLSession.shared.dataTask(with: url) { data, _, error in - if let e = error { - print("[SkipSidecar] AniSkip fetch error: \(e.localizedDescription)") - completion(false) - return - } - guard let data = data else { - completion(false) - return - } - - struct Resp: Decodable { let found: Bool; let results: [Res]? } - struct Res: Decodable { let skip_type: String; let interval: Interval } - struct Interval: Decodable { let start_time: Double; let end_time: Double } - - var opRange: (Double, Double)? = nil - var edRange: (Double, Double)? = nil - - if let r = try? JSONDecoder().decode(Resp.self, from: data), r.found, let arr = r.results { - for item in arr { - if item.skip_type == "op" { opRange = (item.interval.start_time, item.interval.end_time) } - if item.skip_type == "ed" { edRange = (item.interval.start_time, item.interval.end_time) } - } - } - - if opRange == nil && edRange == nil { - completion(false) - return - } - - // Determine sidecar path next to the persisted video file - let dir = persistentURL.deletingLastPathComponent() - let baseName = persistentURL.deletingPathExtension().lastPathComponent - let sidecar = dir.appendingPathComponent(baseName + ".skip.json") - - var payload: [String: Any] = [ - "source": "aniskip", - "idType": idType, - "episode": epNumber, - "createdAt": ISO8601DateFormatter().string(from: Date()) - ] - if let aid = request.aniListID { payload["anilistId"] = aid } - if let mid = request.malID { payload["malId"] = mid } - if let op = opRange { payload["op"] = ["start": op.0, "end": op.1] } - if let ed = edRange { payload["ed"] = ["start": ed.0, "end": ed.1] } - - do { - let json = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]) - try json.write(to: sidecar, options: .atomic) - print("[SkipSidecar] Wrote sidecar at: \(sidecar.path)") - completion(true) - } catch { - print("[SkipSidecar] Sidecar write error: \(error.localizedDescription)") - completion(false) - } - }.resume() - } -} diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 4aa70c4..e6184e2 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -657,10 +657,12 @@ private extension EpisodeCell { guard isDownloading else { return } if let sources = result.sources, !sources.isEmpty { + Logger.shared.log("Download: received \(sources.count) source option(s)", type: "Download") if sources.count > 1 { showDownloadStreamSelectionAlert(streams: sources, downloadID: downloadID, subtitleURL: result.subtitles?.first) return } else if let streamUrl = sources[0]["streamUrl"] as? String, let url = URL(string: streamUrl) { + Logger.shared.log("Download: auto-selecting first source", type: "Download") let subtitleURLString = sources[0]["subtitle"] as? String let subtitleURL = subtitleURLString.flatMap { URL(string: $0) } startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURL) @@ -669,12 +671,14 @@ private extension EpisodeCell { } if let streams = result.streams, !streams.isEmpty { + Logger.shared.log("Download: received \(streams.count) stream URL(s)", type: "Download") if streams[0] == "[object Promise]" { tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) return } if streams.count > 1 { + Logger.shared.log("Download: user selection required among multiple streams", type: "Download") showDownloadStreamSelectionAlert(streams: streams, downloadID: downloadID, subtitleURL: result.subtitles?.first) return } else if let url = URL(string: streams[0]) { @@ -688,6 +692,9 @@ private extension EpisodeCell { } func startActualDownload(url: URL, streamUrl: String, downloadID: UUID, subtitleURL: URL? = nil) { + +Logger.shared.log("Download kickoff → show=\(animeTitle), ep=\(self.episodeID + 1), malID=\(String(describing: self.malID))", type: "Download") + let headers = createDownloadHeaders(for: url) let episodeThumbnailURL = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl) let showPosterImageURL = URL(string: showPosterURL ?? defaultBannerImage) @@ -696,6 +703,7 @@ private extension EpisodeCell { let fullEpisodeTitle = episodeTitle.isEmpty ? baseTitle : "\(baseTitle): \(episodeTitle)" let animeTitle = parentTitle.isEmpty ? "Unknown Anime" : parentTitle + Logger.shared.log("Starting downloadWithStreamTypeSupport (MAL id pass-through) → mal=\(String(describing: malIDFromParent)), anilist=\(itemID), title=\(fullEpisodeTitle)", type: "Download"); jsController.downloadWithStreamTypeSupport( url: url, headers: headers, @@ -712,6 +720,7 @@ private extension EpisodeCell { malID: malIDFromParent, isFiller: isFiller ) { success, message in + Logger.shared.log("downloadWithStreamTypeSupport completed → success=\(success), message=\(message)", type: "Download") if success { Logger.shared.log("Started download for Episode \(self.episodeID + 1): \(self.episode)", type: "Download") AnalyticsManager.shared.sendEvent(