Add filler episodes support using Jikan API + Episode Intro & Outro timestamp download (#235)
Some checks failed
Build and Release / Build IPA (push) Has been cancelled
Build and Release / Build macOS App (push) Has been cancelled

* 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
This commit is contained in:
scigward 2025-08-23 16:53:07 +03:00 committed by GitHub
parent 122e248d9c
commit 4b836d6331
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 834 additions and 167 deletions

View file

@ -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)")
}
}
}

View file

@ -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
}
}

View file

@ -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
)
}

View file

@ -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
)
}

View file

@ -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 {
)
)
}
}
}

View file

@ -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<Int>?
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<Int>? = 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)
}
}

View file

@ -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()
}
}
}

View file

@ -33,6 +33,17 @@ struct MediaInfoView: View {
@State private var tmdbID: Int?
@State private var tmdbType: TMDBFetcher.MediaType? = nil
@State private var currentFetchTask: Task<Void, Never>? = nil
// Jikan filler set for this media (passed down to EpisodeCell)
@State private var jikanFillerSet: Set<Int>? = 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<Int> = []
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..<seasons.count, id: \..self) { index in
ForEach(0..<seasons.count, id: \.self) { index in
Button(action: { selectedSeason = index }) {
Text(String(format: NSLocalizedString("Season %d", comment: ""), index + 1))
}
@ -670,6 +691,7 @@ struct MediaInfoView: View {
episodeIndex: index,
episode: episode.href,
episodeID: episode.number - 1,
malID: matchedMalID,
progress: progress,
itemID: itemID ?? 0,
totalEpisodes: episodeLinks.count,
@ -689,7 +711,8 @@ struct MediaInfoView: View {
markAllPreviousEpisodes(episode: episode, index: index, inSeason: isGroupedBySeasons)
},
tmdbID: tmdbID,
seasonNumber: season
seasonNumber: season,
fillerEpisodes: jikanFillerSet,
)
.disabled(isFetchingEpisode)
}
@ -733,7 +756,7 @@ struct MediaInfoView: View {
}
LazyVStack(spacing: 15) {
ForEach(chapters.indices.filter { selectedChapterRange.contains($0) }, id: \..self) { i in
ForEach(chapters.indices.filter { selectedChapterRange.contains($0) }, id: \.self) { i in
let chapter = chapters[i]
let _ = refreshTrigger
if let href = chapter["href"] as? String,
@ -787,7 +810,7 @@ struct MediaInfoView: View {
@ViewBuilder
private var chapterRangeSelectorStyled: some View {
Menu {
ForEach(generateChapterRanges(), id: \..self) { range in
ForEach(generateChapterRanges(), id: \.self) { range in
Button(action: { selectedChapterRange = range }) {
Text("\(range.lowerBound + 1)-\(range.upperBound)")
}
@ -857,9 +880,17 @@ struct MediaInfoView: View {
.circularGradientOutline()
}
.sheet(isPresented: $isMatchingPresented) {
AnilistMatchPopupView(seriesTitle: title) { id, matched in
AnilistMatchPopupView(seriesTitle: title) { id, title, malId in
handleAniListMatch(selectedID: id)
matchedTitle = matched
matchedTitle = title
if let malId = malId, malId != 0 {
matchedMalID = malId
} else {
fetchMalIDFromAniList(anilistID: id) { fetchedMalID in
matchedMalID = fetchedMalID
}
}
fetchMetadataIDIfNeeded()
}
}
@ -1636,6 +1667,9 @@ struct MediaInfoView: View {
self.itemID = id
aniListSuccess = true
Logger.shared.log("Successfully fetched AniList ID: \(id)", type: "Debug")
self.fetchMalIDFromAniList(anilistID: id) { fetchedMalID in
self.matchedMalID = fetchedMalID
}
case .failure(let error):
Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Debug")
}
@ -1707,6 +1741,38 @@ struct MediaInfoView: View {
}.resume()
}
func fetchMalIDFromAniList(anilistID: Int, completion: @escaping (Int?) -> 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")
}
}