From e2f7f92d475ab0c8cc4845b45774fead5dfb4ab3 Mon Sep 17 00:00:00 2001 From: scigward <162128369+scigward@users.noreply.github.com> Date: Sat, 16 Aug 2025 21:41:16 +0300 Subject: [PATCH] Test jikan API filler implementation --- .../EpisodeCell/EpisodeCell.swift | 160 +++++++++++++----- 1 file changed, 118 insertions(+), 42 deletions(-) diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index fa39a6b..71e6f3e 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -22,6 +22,7 @@ struct EpisodeCell: View { let showPosterURL: String? let tmdbID: Int? let seasonNumber: Int? + let malID: Int? // Add MAL ID property let isMultiSelectMode: Bool let isSelected: Bool @@ -53,11 +54,32 @@ struct EpisodeCell: View { @Environment(\.colorScheme) private var colorScheme @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system - //Filler state & cache + // Filler state @State private var isFiller: Bool = false - private static var fillerCache: [String: (fetchedAt: Date, episodes: Set)] = [:] - private static let fillerCacheQueue = DispatchQueue(label: "sora.filler.cache.queue", attributes: .concurrent) - private static let fillerCacheTTL: TimeInterval = 60 * 60 * 24 + + // Jikan API cache + 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") + + // Jikan data models + private struct JikanResponse: Decodable { + let data: [JikanEpisode] + let pagination: Pagination + } + + private struct Pagination: Decodable { + let last_visible_page: Int + let has_next_page: Bool + } + + private struct JikanEpisode: Decodable { + let mal_id: Int + let filler: Bool + let recap: Bool + } init( episodeIndex: Int, @@ -76,7 +98,8 @@ struct EpisodeCell: View { onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void, tmdbID: Int? = nil, - seasonNumber: Int? = nil + seasonNumber: Int? = nil, + malID: Int? = nil // Add MAL ID parameter ) { self.episodeIndex = episodeIndex self.episode = episode @@ -94,6 +117,7 @@ struct EpisodeCell: View { self.onMarkAllPrevious = onMarkAllPrevious self.tmdbID = tmdbID self.seasonNumber = seasonNumber + self.malID = malID // Initialize MAL ID let isLightMode = (UserDefaults.standard.string(forKey: "selectedAppearance") == "light") || ((UserDefaults.standard.string(forKey: "selectedAppearance") == "system") && @@ -538,8 +562,8 @@ private extension EpisodeCell { } else { fetchAnimeEpisodeDetails() } - // Fetch filler info in parallel with episode details - fetchFillerInfo() + // Fetch filler info using Jikan API + fetchJikanFillerInfo() } } @@ -959,69 +983,121 @@ private extension EpisodeCell { }.resume() } - func fetchFillerInfo() { - let raw = parentTitle.trimmingCharacters(in: .whitespacesAndNewlines) - guard !raw.isEmpty else { return } - - var slug = raw.lowercased() - slug = slug.replacingOccurrences(of: " ", with: "-") - slug = slug.components(separatedBy: CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted).joined() - let epNum = self.episodeID + 1 + // MARK: - Jikan Filler Implementation + + private func fetchJikanFillerInfo() { + guard let malID = malID else { return } + let episodeNumber = episodeID + 1 // Check cache first - var cachedEpisodes: Set? = nil - Self.fillerCacheQueue.sync { - if let entry = Self.fillerCache[slug], Date().timeIntervalSince(entry.fetchedAt) < Self.fillerCacheTTL { + var cachedEpisodes: [JikanEpisode]? = nil + Self.jikanCacheQueue.sync { + if let entry = Self.jikanCache[malID], Date().timeIntervalSince(entry.fetchedAt) < Self.jikanCacheTTL { cachedEpisodes = entry.episodes } } - if let set = cachedEpisodes { - DispatchQueue.main.async { - self.isFiller = set.contains(epNum) - } + if let episodes = cachedEpisodes { + updateFillerStatus(episodes: episodes) return } - // Not in cache or expired, fetch from API - guard let url = URL(string: "https://sora-filler-episodes-api.jmcrafter26.workers.dev/\(slug)") else { return } + // Prevent duplicate requests + var shouldFetch = false + Self.inProgressQueue.sync { + if !Self.inProgressMALIDs.contains(malID) { + Self.inProgressMALIDs.insert(malID) + shouldFetch = true + } + } - URLSession.shared.dataTask(with: url) { data, response, error in - var episodesSet = Set() - - defer { - // Cache the result (even if empty) and update UI - Self.fillerCacheQueue.async(flags: .barrier) { - Self.fillerCache[slug] = (fetchedAt: Date(), episodes: episodesSet) + if !shouldFetch { return } + + // Fetch all pages + fetchAllJikanPages(malID: malID) { episodes in + // Update cache + if let episodes = episodes { + Self.jikanCacheQueue.async(flags: .barrier) { + Self.jikanCache[malID] = (Date(), episodes) } + // Update UI DispatchQueue.main.async { - self.isFiller = episodesSet.contains(epNum) + self.updateFillerStatus(episodes: episodes) } } - // Handle API errors or empty responses + // Remove from in-progress set + Self.inProgressQueue.async { + Self.inProgressMALIDs.remove(malID) + } + } + } + + private func fetchAllJikanPages(malID: Int, completion: @escaping ([JikanEpisode]?) -> Void) { + let firstPageURL = URL(string: "https://api.jikan.moe/v4/anime/\(malID)/episodes?page=1")! + + URLSession.shared.dataTask(with: firstPageURL) { data, response, error in guard let data = data, error == nil else { + completion(nil) return } do { - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let fillerArray = json["fillerEpisodes"] as? [String], - !fillerArray.isEmpty { - - let fillerString = fillerArray[0] - let numbers = fillerString.split(separator: ",").compactMap { - Int($0.trimmingCharacters(in: .whitespaces)) + let firstPageResponse = try JSONDecoder().decode(JikanResponse.self, from: data) + var allEpisodes = firstPageResponse.data + let totalPages = firstPageResponse.pagination.last_visible_page + + if totalPages <= 1 { + completion(allEpisodes) + return + } + + // Fetch remaining pages + let group = DispatchGroup() + var episodesFromPages: [Int: [JikanEpisode]] = [:] + + for page in 2...totalPages { + group.enter() + // Add delay to respect rate limits + DispatchQueue.global().asyncAfter(deadline: .now() + Double(page-1) * 0.3) { + let url = URL(string: "https://api.jikan.moe/v4/anime/\(malID)/episodes?page=\(page)")! + URLSession.shared.dataTask(with: url) { data, response, error in + defer { group.leave() } + guard let data = data, error == nil else { return } + do { + let response = try JSONDecoder().decode(JikanResponse.self, from: data) + episodesFromPages[page] = response.data + } catch { + // Ignore page error + } + }.resume() } - episodesSet = Set(numbers) + } + + group.notify(queue: .global()) { + // Combine episodes in order + for page in 2...totalPages { + if let episodes = episodesFromPages[page] { + allEpisodes.append(contentsOf: episodes) + } + } + completion(allEpisodes) } } catch { - print("Filler parse error: \(error)") + completion(nil) } }.resume() } + private func updateFillerStatus(episodes: [JikanEpisode]) { + let episodeNumber = episodeID + 1 + guard episodeNumber <= episodes.count else { return } + + let jikanEpisode = episodes[episodeNumber - 1] + isFiller = jikanEpisode.filler || jikanEpisode.recap + } + func handleFetchFailure(error: Error) { Logger.shared.log("Episode details fetch error: \(error.localizedDescription)", type: "Error")