mirror of
https://github.com/cranci1/Sora.git
synced 2026-03-11 17:45:37 +00:00
Added subtitles selection
This commit is contained in:
parent
2f38763e1a
commit
8d6d2acc7e
1 changed files with 244 additions and 126 deletions
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Reference in a new issue