This commit is contained in:
Francesco 2025-05-25 13:05:53 +02:00
parent fe097bfd14
commit d4bbf87703
4 changed files with 274 additions and 1 deletions

View file

@ -0,0 +1,38 @@
//
// TMDBEpisodeMetadata.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import Foundation
struct TMDBEpisodeMetadata: Codable {
let title: String
let imageUrl: String
let tmdbId: Int
let seasonNumber: Int
let episodeNumber: Int
let cacheDate: Date
var cacheKey: String {
return "tmdb_\(tmdbId)_s\(seasonNumber)_e\(episodeNumber)"
}
init(title: String, imageUrl: String, tmdbId: Int, seasonNumber: Int, episodeNumber: Int) {
self.title = title
self.imageUrl = imageUrl
self.tmdbId = tmdbId
self.seasonNumber = seasonNumber
self.episodeNumber = episodeNumber
self.cacheDate = Date()
}
func toData() -> Data? {
return try? JSONEncoder().encode(self)
}
static func fromData(_ data: Data) -> TMDBEpisodeMetadata? {
return try? JSONDecoder().decode(TMDBEpisodeMetadata.self, from: data)
}
}

View file

@ -26,6 +26,7 @@ struct EpisodeCell: View {
var module: ScrapingModule
var parentTitle: String
var showPosterURL: String?
var tmdbID: Int?
var isMultiSelectMode: Bool = false
var isSelected: Bool = false
@ -74,6 +75,7 @@ struct EpisodeCell: View {
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
itemID: Int, totalEpisodes: Int? = nil, defaultBannerImage: String = "",
module: ScrapingModule, parentTitle: String, showPosterURL: String? = nil,
tmdbID: Int? = nil,
isMultiSelectMode: Bool = false, isSelected: Bool = false,
onSelectionChanged: ((Bool) -> Void)? = nil,
onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) {
@ -83,6 +85,7 @@ struct EpisodeCell: View {
self.progress = progress
self.itemID = itemID
self.totalEpisodes = totalEpisodes
self.tmdbID = tmdbID
let isLightMode = (UserDefaults.standard.string(forKey: "selectedAppearance") == "light") ||
((UserDefaults.standard.string(forKey: "selectedAppearance") == "system") &&
@ -126,6 +129,10 @@ struct EpisodeCell: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
fetchAnimeEpisodeDetails()
}
} else if tmdbID != nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
fetchTMDBEpisodeDetails()
}
} else {
isLoading = false
}
@ -525,6 +532,28 @@ struct EpisodeCell: View {
}
private func fetchEpisodeDetails() {
if let tmdbID = tmdbID,
MetadataCacheManager.shared.isCachingEnabled &&
(UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil ||
UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata")) {
let seasonNumber = 1
let episodeNumber = episodeID + 1
let cacheKey = "tmdb_\(tmdbID)_s\(seasonNumber)_e\(episodeNumber)"
if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
let metadata = TMDBEpisodeMetadata.fromData(cachedData) {
DispatchQueue.main.async {
self.episodeTitle = metadata.title
self.episodeImageUrl = metadata.imageUrl
self.isLoading = false
self.loadedFromCache = true
}
return
}
}
if MetadataCacheManager.shared.isCachingEnabled &&
(UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil ||
UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata")) {
@ -544,7 +573,13 @@ struct EpisodeCell: View {
}
}
fetchAnimeEpisodeDetails()
if let type = module.metadata.type?.lowercased(), type == "anime" {
fetchAnimeEpisodeDetails()
} else if tmdbID != nil {
fetchTMDBEpisodeDetails()
} else {
isLoading = false
}
}
private func fetchAnimeEpisodeDetails() {
@ -659,6 +694,103 @@ struct EpisodeCell: View {
}.resume()
}
private func fetchTMDBEpisodeDetails() {
guard let tmdbID = tmdbID else {
isLoading = false
return
}
let seasonNumber = 1
let episodeNumber = episodeID + 1
guard let url = URL(string: "https://api.themoviedb.org/3/tv/\(tmdbID)/season/\(seasonNumber)/episode/\(episodeNumber)?api_key=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI3MzhiNGVkZDBhMTU2Y2MxMjZkYzRhNGI4YWVhNGFjYSIsIm5iZiI6MTc0MTE3MzcwMi43ODcwMDAyLCJzdWIiOiI2N2M4MzNjNmQ3NDE5Y2RmZDg2ZTJkZGYiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.Gfe7F-8CWJXgONv34mg3jHXfL6Bxbj-hAYf9fYi9CkE") else {
isLoading = false
Logger.shared.log("Invalid TMDB URL for show ID: \(tmdbID)", type: "Error")
return
}
if retryAttempts > 0 {
Logger.shared.log("Retrying TMDB episode details fetch (attempt \(retryAttempts)/\(maxRetryAttempts))", type: "Debug")
}
URLSession.custom.dataTask(with: url) { data, response, error in
if let error = error {
Logger.shared.log("Failed to fetch TMDB episode details: \(error)", type: "Error")
self.handleFetchFailure(error: error)
return
}
guard let data = data else {
self.handleFetchFailure(error: NSError(domain: "com.sora.episode", code: 1, userInfo: [NSLocalizedDescriptionKey: "No data received"]))
return
}
do {
guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
self.handleFetchFailure(error: NSError(domain: "com.sora.episode", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format"]))
return
}
var title = ""
var image = ""
var missingFields: [String] = []
if let episodeTitle = json["name"] as? String, !episodeTitle.isEmpty {
title = episodeTitle
} else {
missingFields.append("name")
}
if let stillPath = json["still_path"] as? String, !stillPath.isEmpty {
image = "https://image.tmdb.org/t/p/w500\(stillPath)"
} else {
missingFields.append("still_path")
}
if !missingFields.isEmpty {
Logger.shared.log("TMDB Episode \(episodeNumber) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning")
}
// Cache TMDB episode metadata
if MetadataCacheManager.shared.isCachingEnabled && (!title.isEmpty || !image.isEmpty) {
let metadata = TMDBEpisodeMetadata(
title: title,
imageUrl: image,
tmdbId: tmdbID,
seasonNumber: seasonNumber,
episodeNumber: episodeNumber
)
if let metadataData = metadata.toData() {
MetadataCacheManager.shared.storeMetadata(
metadataData,
forKey: metadata.cacheKey
)
}
}
DispatchQueue.main.async {
self.isLoading = false
self.retryAttempts = 0
if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil
|| UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") {
self.episodeTitle = title
if !image.isEmpty {
self.episodeImageUrl = image
}
}
}
} catch {
Logger.shared.log("TMDB JSON parsing error: \(error.localizedDescription)", type: "Error")
DispatchQueue.main.async {
self.isLoading = false
self.retryAttempts = 0
}
}
}.resume()
}
private func handleFetchFailure(error: Error) {
Logger.shared.log("Episode details fetch error: \(error.localizedDescription)", type: "Error")

View file

@ -122,6 +122,7 @@ struct MediaInfoView: View {
itemID = savedID
Logger.shared.log("Using custom AniList ID: \(savedID)", type: "Debug")
} else {
// Try AniList first for anime
fetchItemID(byTitle: cleanTitle(title)) { result in
switch result {
case .success(let id):
@ -131,6 +132,16 @@ struct MediaInfoView: View {
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch AniList ID"])
}
}
// Also try TMDB for series/movies
fetchTMDBID(byTitle: cleanTitle(title)) { result in
switch result {
case .success(let id):
tmdbID = id
case .failure(let error):
Logger.shared.log("Failed to fetch TMDB ID: \(error)")
}
}
}
selectedRange = 0..<episodeChunkSize
@ -276,6 +287,12 @@ struct MediaInfoView: View {
Label("Set Custom AniList ID", systemImage: "number")
}
Button(action: {
showCustomTMDBIDAlert()
}) {
Label("Set Custom TMDB ID", systemImage: "tv")
}
if let customID = customAniListID {
Button(action: {
customAniListID = nil
@ -303,6 +320,16 @@ struct MediaInfoView: View {
}
}
if let id = tmdbID {
Button(action: {
if let url = URL(string: "https://www.themoviedb.org/tv/\(id)") {
openSafariViewController(with: url.absoluteString)
}
}) {
Label("Open in TMDB", systemImage: "tv")
}
}
Divider()
Button(action: {
@ -463,6 +490,7 @@ struct MediaInfoView: View {
module: module,
parentTitle: title,
showPosterURL: imageUrl,
tmdbID: tmdbID,
isMultiSelectMode: isMultiSelectMode,
isSelected: selectedEpisodes.contains(ep.number),
onSelectionChanged: { isSelected in
@ -543,6 +571,7 @@ struct MediaInfoView: View {
module: module,
parentTitle: title,
showPosterURL: imageUrl,
tmdbID: tmdbID,
isMultiSelectMode: isMultiSelectMode,
isSelected: selectedEpisodes.contains(ep.number),
onSelectionChanged: { isSelected in
@ -1094,6 +1123,40 @@ struct MediaInfoView: View {
}.resume()
}
private func fetchTMDBID(byTitle title: String, completion: @escaping (Result<Int, Error>) -> Void) {
guard let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "https://api.themoviedb.org/3/search/tv?api_key=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI3MzhiNGVkZDBhMTU2Y2MxMjZkYzRhNGI4YWVhNGFjYSIsIm5iZiI6MTc0MTE3MzcwMi43ODcwMDAyLCJzdWIiOiI2N2M4MzNjNmQ3NDE5Y2RmZDg2ZTJkZGYiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.Gfe7F-8CWJXgONv34mg3jHXfL6Bxbj-hAYf9fYi9CkE&query=\(encodedTitle)") else {
completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])))
return
}
URLSession.custom.dataTask(with: url) { data, _, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data received"])))
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let results = json["results"] as? [[String: Any]],
let firstResult = results.first,
let id = firstResult["id"] as? Int {
completion(.success(id))
} else {
let error = NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "No TMDB results found"])
completion(.failure(error))
}
} catch {
completion(.failure(error))
}
}.resume()
}
private func showCustomIDAlert() {
let alert = UIAlertController(title: "Set Custom AniList ID", message: "Enter the AniList ID for this media", preferredStyle: .alert)
@ -1123,6 +1186,34 @@ struct MediaInfoView: View {
}
}
private func showCustomTMDBIDAlert() {
let alert = UIAlertController(title: "Set Custom TMDB ID", message: "Enter the TMDB ID for this TV show", preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "TMDB ID"
textField.keyboardType = .numberPad
if let tmdbID = tmdbID {
textField.text = "\(tmdbID)"
}
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Save", style: .default) { _ in
if let text = alert.textFields?.first?.text,
let id = Int(text) {
tmdbID = id
UserDefaults.standard.set(id, forKey: "custom_tmdb_id_\(href)")
Logger.shared.log("Set custom TMDB ID: \(id)", type: "General")
}
})
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let rootVC = window.rootViewController {
findTopViewController.findViewController(rootVC).present(alert, animated: true)
}
}
private func selectEpisodeRange(start: Int, end: Int) {
selectedEpisodes.removeAll()
for episodeNumber in start...end {

View file

@ -18,6 +18,7 @@
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; };
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; };
132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */; };
132FC5B82DE33051009A80F7 /* TMDBEpisodeMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5B72DE33051009A80F7 /* TMDBEpisodeMetadata.swift */; };
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; };
@ -98,6 +99,7 @@
132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = "<group>"; };
132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = "<group>"; };
132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewAlternateAppIconPicker.swift; sourceTree = "<group>"; };
132FC5B72DE33051009A80F7 /* TMDBEpisodeMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TMDBEpisodeMetadata.swift; sourceTree = "<group>"; };
133D7C6A2D2BE2500075467E /* Sulfur.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sulfur.app; sourceTree = BUILT_PRODUCTS_DIR; };
133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = "<group>"; };
133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@ -181,6 +183,7 @@
13103E802D589D6C000F0673 /* Tracking Services */ = {
isa = PBXGroup;
children = (
132FC5B42DE33042009A80F7 /* TMDB */,
13E62FBF2DABC3A20007E259 /* Trakt */,
13103E812D589D77000F0673 /* AniList */,
);
@ -213,6 +216,14 @@
path = Analytics;
sourceTree = "<group>";
};
132FC5B42DE33042009A80F7 /* TMDB */ = {
isa = PBXGroup;
children = (
132FC5B72DE33051009A80F7 /* TMDBEpisodeMetadata.swift */,
);
path = TMDB;
sourceTree = "<group>";
};
133D7C612D2BE2500075467E = {
isa = PBXGroup;
children = (
@ -616,6 +627,7 @@
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
132FC5B82DE33051009A80F7 /* TMDBEpisodeMetadata.swift in Sources */,
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,