mirror of
https://github.com/cranci1/Sora.git
synced 2026-01-11 20:10:24 +00:00
Add filler episodes support using Jikan API + Episode Intro & Outro timestamp download (#235)
* 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:
parent
122e248d9c
commit
4b836d6331
9 changed files with 834 additions and 167 deletions
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue