Added subtitles selection
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build macOS App (push) Waiting to run

This commit is contained in:
cranci1 2025-11-13 20:17:33 +01:00
parent 2f38763e1a
commit 8d6d2acc7e

View file

@ -33,7 +33,7 @@ struct MediaInfoView: View {
@State private var tmdbID: Int?
@State private var tmdbType: TMDBFetcher.MediaType? = nil
@State private var currentFetchTask: Task<Void, Never>? = nil
@State private var jikanFillerSet: Set<Int>? = nil
private static var jikanCache: [Int: (fetchedAt: Date, episodes: [JikanEpisode])] = [:]
private static let jikanCacheQueue = DispatchQueue(label: "sora.jikan.cache.queue", attributes: .concurrent)
@ -196,7 +196,7 @@ struct MediaInfoView: View {
.ignoresSafeArea(.container, edges: .top)
.onAppear {
setupViewOnAppear()
NotificationCenter.default.post(name: .hideTabBar, object: nil)
UserDefaults.standard.set(true, forKey: "isMediaInfoActive")
}
@ -861,14 +861,14 @@ struct MediaInfoView: View {
AnilistMatchPopupView(seriesTitle: title) { id, title, malId in
handleAniListMatch(selectedID: id)
matchedTitle = title
if let malId = malId, malId != 0 {
matchedMalID = malId
} else {
fetchMalIDFromAniList(anilistID: id) { fetchedMalID in
matchedMalID = fetchedMalID
}
}
}
fetchMetadataIDIfNeeded()
}
}
@ -1647,7 +1647,7 @@ struct MediaInfoView: View {
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")
}
@ -1800,27 +1800,43 @@ struct MediaInfoView: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard self.activeFetchID == fetchID else { return }
if let sources = result.sources, !sources.isEmpty {
if sources.count > 1 {
self.showStreamSelectionAlert(sources: sources, fullURL: href, subtitles: result.subtitles?.first, fetchID: fetchID)
} else if let streamUrl = sources[0]["streamUrl"] as? String {
let headers = sources[0]["headers"] as? [String: String]
self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles?.first, headers: headers, fetchID: fetchID)
} else {
self.handleStreamFailure(error: nil)
}
} else if let streams = result.streams, !streams.isEmpty {
if streams.count > 1 {
self.showStreamSelectionAlert(sources: streams, fullURL: href, subtitles: result.subtitles?.first, fetchID: fetchID)
} else {
self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first, fetchID: fetchID)
}
} else {
self.handleStreamFailure(error: nil)
}
let hasSubtitleOptions = !(result.subtitles?.isEmpty ?? true)
DispatchQueue.main.async {
self.isFetchingEpisode = false
self.selectStream(sources: result.sources, streams: result.streams, fetchID: fetchID) { option in
guard self.activeFetchID == fetchID else { return }
self.resolveSubtitleSelection(
subtitles: result.subtitles,
defaultSubtitle: option.subtitle,
fetchID: fetchID
) { selectedSubtitle in
guard self.activeFetchID == fetchID else { return }
DispatchQueue.main.async {
self.isFetchingEpisode = false
}
let subtitleToUse: String?
if let selectedSubtitle {
subtitleToUse = selectedSubtitle
} else if hasSubtitleOptions {
subtitleToUse = nil
} else {
subtitleToUse = option.subtitle
}
self.playStream(
url: option.url,
fullURL: href,
subtitles: subtitleToUse,
headers: option.headers,
fetchID: fetchID
)
}
} failure: {
self.handleStreamFailure(error: nil)
DispatchQueue.main.async {
self.isFetchingEpisode = false
}
}
}
}
@ -1860,59 +1876,56 @@ struct MediaInfoView: View {
}
}
func showStreamSelectionAlert(sources: [Any], fullURL: String, subtitles: String? = nil, fetchID: UUID) {
guard self.activeFetchID == fetchID else { return }
private struct StreamOption {
let title: String
let url: String
let headers: [String:String]?
let subtitle: String?
}
private func selectStream(
sources: [[String: Any]]?,
streams: [String]?,
fetchID: UUID,
completion: @escaping (StreamOption) -> Void,
failure: @escaping () -> Void
) {
var options: [StreamOption] = []
self.isFetchingEpisode = false
self.showLoadingAlert = false
if let sources = sources, !sources.isEmpty {
options = streamOptions(fromSources: sources)
} else if let streams = streams, !streams.isEmpty {
options = streamOptions(fromStrings: streams)
}
guard !options.isEmpty else {
failure()
return
}
if options.count == 1 {
completion(options[0])
} else {
presentStreamSelectionAlert(options: options, fetchID: fetchID, completion: completion)
}
}
private func presentStreamSelectionAlert(options: [StreamOption], fetchID: UUID, completion: @escaping (StreamOption) -> Void) {
guard self.activeFetchID == fetchID else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard self.activeFetchID == fetchID else { return }
self.isFetchingEpisode = false
self.showLoadingAlert = false
let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet)
var index = 0
var streamIndex = 1
while index < sources.count {
var title: String = ""
var streamUrl: String = ""
var headers: [String:String]? = nil
if let sources = sources as? [String] {
if index + 1 < sources.count {
if !sources[index].lowercased().contains("http") {
title = sources[index]
streamUrl = sources[index + 1]
index += 2
} else {
title = "Stream \(streamIndex)"
streamUrl = sources[index]
index += 1
}
} else {
title = "Stream \(streamIndex)"
streamUrl = sources[index]
index += 1
}
} else if let sources = sources as? [[String: Any]] {
if let currTitle = sources[index]["title"] as? String {
title = currTitle
streamUrl = (sources[index]["streamUrl"] as? String) ?? ""
} else {
title = "Stream \(streamIndex)"
streamUrl = (sources[index]["streamUrl"] as? String)!
}
headers = sources[index]["headers"] as? [String:String] ?? [:]
index += 1
}
alert.addAction(UIAlertAction(title: title, style: .default) { _ in
for option in options {
alert.addAction(UIAlertAction(title: option.title, style: .default) { _ in
guard self.activeFetchID == fetchID else { return }
self.playStream(url: streamUrl, fullURL: fullURL, subtitles: subtitles, headers: headers, fetchID: fetchID)
completion(option)
})
streamIndex += 1
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
@ -1921,6 +1934,120 @@ struct MediaInfoView: View {
}
}
private func streamOptions(fromSources sources: [[String: Any]]) -> [StreamOption] {
var options: [StreamOption] = []
for (idx, source) in sources.enumerated() {
guard let rawUrl = source["streamUrl"] as? String ?? source["url"] as? String, !rawUrl.isEmpty else { continue }
let title = (source["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let headers = source["headers"] as? [String:String]
let subtitle = source["subtitle"] as? String
let option = StreamOption(
title: title?.isEmpty == false ? title! : "Stream \(idx + 1)",
url: rawUrl,
headers: headers,
subtitle: subtitle
)
options.append(option)
}
return options
}
private func streamOptions(fromStrings strings: [String]) -> [StreamOption] {
var options: [StreamOption] = []
var index = 0
var unnamedCount = 1
while index < strings.count {
let entry = strings[index]
if isURL(entry) {
options.append(StreamOption(title: "Stream \(unnamedCount)", url: entry, headers: nil, subtitle: nil))
unnamedCount += 1
index += 1
} else {
let nextIndex = index + 1
if nextIndex < strings.count, isURL(strings[nextIndex]) {
options.append(StreamOption(title: entry, url: strings[nextIndex], headers: nil, subtitle: nil))
index += 2
} else {
index += 1
}
}
}
return options
}
private func resolveSubtitleSelection(subtitles: [String]?, defaultSubtitle: String?, fetchID: UUID, completion: @escaping (String?) -> Void) {
guard let subtitles = subtitles, !subtitles.isEmpty else {
completion(defaultSubtitle)
return
}
let options = subtitleOptions(from: subtitles)
guard !options.isEmpty else {
completion(defaultSubtitle)
return
}
if options.count == 1 {
completion(options[0].url)
return
}
DispatchQueue.main.async {
self.isFetchingEpisode = false
}
presentSubtitleSelectionAlert(options: options, fetchID: fetchID, completion: completion)
}
private func presentSubtitleSelectionAlert(options: [(title: String, url: String)], fetchID: UUID, completion: @escaping (String?) -> Void) {
DispatchQueue.main.async {
guard self.activeFetchID == fetchID else {
completion(nil)
return
}
let alert = UIAlertController(title: "Select Subtitle", message: "Choose a subtitle track", preferredStyle: .actionSheet)
for option in options {
alert.addAction(UIAlertAction(title: option.title, style: .default) { _ in
guard self.activeFetchID == fetchID else { return }
completion(option.url)
})
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
completion(nil)
})
self.presentAlert(alert)
}
}
private func subtitleOptions(from subtitles: [String]) -> [(title: String, url: String)] {
var options: [(String, String)] = []
var index = 0
var fallbackIndex = 1
while index < subtitles.count {
let entry = subtitles[index]
if isURL(entry) {
options.append(("Subtitle \(fallbackIndex)", entry))
fallbackIndex += 1
index += 1
} else {
let nextIndex = index + 1
if nextIndex < subtitles.count, isURL(subtitles[nextIndex]) {
options.append((entry, subtitles[nextIndex]))
fallbackIndex += 1
index += 2
} else {
index += 1
}
}
}
return options
}
private func isURL(_ value: String) -> Bool {
let lowercased = value.lowercased()
return lowercased.hasPrefix("http://") || lowercased.hasPrefix("https://")
}
func playStream(url: String, fullURL: String, subtitles: String? = nil, headers: [String:String]? = nil, fetchID: UUID) {
guard self.activeFetchID == fetchID else { return }
@ -2473,7 +2600,7 @@ struct MediaInfoView: View {
}
return ""
}
// MARK: - Updated Jikan Filler Implementation
private struct JikanResponse: Decodable {
let data: [JikanEpisode]
@ -2483,19 +2610,18 @@ struct MediaInfoView: View {
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 {
@ -2509,7 +2635,6 @@ struct MediaInfoView: View {
return
}
// Prevent duplicate requests
var shouldFetch = false
Self.inProgressQueue.sync {
if !Self.inProgressMALIDs.contains(malID) {
@ -2525,16 +2650,13 @@ struct MediaInfoView: View {
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)
}
@ -2542,7 +2664,6 @@ struct MediaInfoView: View {
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)
}
@ -2554,9 +2675,8 @@ struct MediaInfoView: View {
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 {
@ -2567,57 +2687,55 @@ struct MediaInfoView: View {
}
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()
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
let http = response as? HTTPURLResponse
let status = http?.statusCode ?? 0
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
}
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)
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
}
} catch {
Logger.shared.log("Failed to parse Jikan response: \(error)", type: "Error")
completion(nil)
}
}.resume()
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 })