This commit is contained in:
Francesco 2025-06-01 17:13:54 +02:00
parent fddf940b95
commit 62802fd30e
7 changed files with 197 additions and 88 deletions

View file

@ -183,9 +183,7 @@ class AppInfo: NSObject {
}
@objc func getDisplayName() -> String {
return Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ??
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ??
"Sora"
return Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String
}
}

View file

@ -0,0 +1,104 @@
//
// TMDB-FetchID.swift
// Sulfur
//
// Created by Francesco on 01/06/25.
//
import Foundation
class TMDBFetcher {
enum MediaType: String, CaseIterable {
case tv, movie
}
struct TMDBResult: Decodable {
let id: Int
let name: String?
let title: String?
let popularity: Double
}
struct TMDBResponse: Decodable {
let results: [TMDBResult]
}
private let apiKey = "738b4edd0a156cc126dc4a4b8aea4aca"
private let session = URLSession.custom
func fetchBestMatchID(for title: String, completion: @escaping (Int?, MediaType?) -> Void) {
let group = DispatchGroup()
var bestResults: [(id: Int, score: Double, type: MediaType)] = []
for type in MediaType.allCases {
group.enter()
fetchBestMatchID(for: title, type: type) { id, score in
if let id = id, let score = score {
bestResults.append((id, score, type))
}
group.leave()
}
}
group.notify(queue: .main) {
let best = bestResults.max { $0.score < $1.score }
completion(best?.id, best?.type)
}
}
private func fetchBestMatchID(for title: String, type: MediaType, completion: @escaping (Int?, Double?) -> Void) {
let query = title.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
let urlString = "https://api.themoviedb.org/3/search/\(type.rawValue)?api_key=\(apiKey)&query=\(query)"
guard let url = URL(string: urlString) else {
completion(nil, nil)
return
}
session.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else {
completion(nil, nil)
return
}
do {
let response = try JSONDecoder().decode(TMDBResponse.self, from: data)
let scored = response.results.map { result -> (Int, Double) in
let candidateTitle = type == .tv ? result.name ?? "" : result.title ?? ""
let similarity = TMDBFetcher.titleSimilarity(title, candidateTitle)
let score = (similarity * 0.7) + ((result.popularity / 100.0) * 0.3)
return (result.id, score)
}
let best = scored.max { $0.1 < $1.1 }
completion(best?.0, best?.1)
} catch {
completion(nil, nil)
}
}.resume()
}
static func titleSimilarity(_ a: String, _ b: String) -> Double {
let lowerA = a.lowercased()
let lowerB = b.lowercased()
let distance = Double(levenshtein(lowerA, lowerB))
let maxLen = Double(max(lowerA.count, lowerB.count))
if maxLen == 0 { return 1.0 }
return 1.0 - (distance / maxLen)
}
static func levenshtein(_ a: String, _ b: String) -> Int {
let a = Array(a)
let b = Array(b)
var dist = Array(repeating: Array(repeating: 0, count: b.count + 1), count: a.count + 1)
for i in 0...a.count { dist[i][0] = i }
for j in 0...b.count { dist[0][j] = j }
for i in 1...a.count {
for j in 1...b.count {
if a[i-1] == b[j-1] {
dist[i][j] = dist[i-1][j-1]
} else {
dist[i][j] = min(dist[i-1][j-1], dist[i][j-1], dist[i-1][j]) + 1
}
}
}
return dist[a.count][b.count]
}
}

View file

@ -9,6 +9,8 @@ import SwiftUI
import Kingfisher
import SafariServices
private let tmdbFetcher = TMDBFetcher()
struct MediaItem: Identifiable {
let id = UUID()
let description: String
@ -153,8 +155,7 @@ struct MediaInfoView: View {
buttonRefreshTrigger.toggle()
let savedID = UserDefaults.standard.integer(forKey: "custom_anilist_id_\(href)")
if savedID != 0 {
customAniListID = savedID }
if savedID != 0 { customAniListID = savedID }
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
@ -167,15 +168,7 @@ struct MediaInfoView: View {
itemID = savedID
Logger.shared.log("Using custom AniList ID: \(savedID)", type: "Debug")
} else {
fetchItemID(byTitle: cleanTitle(title)) { result in
switch result {
case .success(let id):
itemID = id
case .failure(let error):
Logger.shared.log("Failed to fetch AniList ID: \(error)")
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch AniList ID"])
}
}
fetchMetadataIDIfNeeded()
}
selectedRange = 0..<episodeChunkSize
@ -464,7 +457,6 @@ struct MediaInfoView: View {
@ViewBuilder
private var menuButton: some View {
Menu {
// Show current match (title if available, else ID)
if let id = itemID ?? customAniListID {
let labelText = (matchedTitle?.isEmpty == false ? matchedTitle! : "\(id)")
Text("Matched with: \(labelText)")
@ -803,44 +795,32 @@ struct MediaInfoView: View {
}
}
private func fetchAniListTitle(id: Int) {
let query = """
query ($id: Int) {
Media(id: $id, type: ANIME) {
title {
english
romaji
private func fetchMetadataIDIfNeeded() {
let provider = UserDefaults.standard.string(forKey: "metadataProviders") ?? "Anilist"
let cleaned = cleanTitle(title)
if provider == "TMDB" {
tmdbID = nil
tmdbFetcher.fetchBestMatchID(for: cleaned) { id, type in
DispatchQueue.main.async {
self.tmdbID = id
Logger.shared.log("Fetched TMDB ID: \(id ?? -1) (\(type?.rawValue ?? "unknown")) for title: \(cleaned)", type: "Debug")
}
}
} else if provider == "Anilist" {
itemID = nil
fetchItemID(byTitle: cleaned) { result in
switch result {
case .success(let id):
DispatchQueue.main.async {
self.itemID = id
Logger.shared.log("Fetched AniList ID: \(id) for title: \(cleaned)", type: "Debug")
}
case .failure(let error):
Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Error")
}
}
}
}
"""
let variables: [String: Any] = ["id": id]
guard let url = URL(string: "https://graphql.anilist.co") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: ["query": query, "variables": variables])
URLSession.shared.dataTask(with: request) { data, _, _ in
guard
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 titleDict = media["title"] as? [String: Any]
else { return }
let english = titleDict["english"] as? String
let romaji = titleDict["romaji"] as? String
let finalTitle = (english?.isEmpty == false ? english! : (romaji ?? "Unknown"))
DispatchQueue.main.async {
matchedTitle = finalTitle
}
}.resume()
}
private func markAllPreviousEpisodesInFlatList(ep: EpisodeLink, index: Int) {
let userDefaults = UserDefaults.standard

View file

@ -145,7 +145,7 @@ struct SettingsViewData: View {
@State private var documentsSize: Int64 = 0
@State private var movPkgSize: Int64 = 0
@State private var showRemoveMovPkgAlert = false
@State private var isMetadataCachingEnabled: Bool = true
@State private var isMetadataCachingEnabled: Bool = false
@State private var isImageCachingEnabled: Bool = true
@State private var isMemoryOnlyMode: Bool = false
@State private var showAlert = false

View file

@ -150,17 +150,15 @@ fileprivate struct SettingsPickerRow<T: Hashable>: View {
struct SettingsViewGeneral: View {
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = false
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true
@AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false
@AppStorage("multiThreads") private var multiThreadsEnabled: Bool = false
@AppStorage("metadataProviders") private var metadataProviders: String = "AniList"
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@AppStorage("currentAppIcon") private var currentAppIcon = "Default"
@AppStorage("episodeSortOrder") private var episodeSortOrder: String = "Ascending"
private let metadataProvidersList = ["AniList"]
private let metadataProvidersList = ["AniList", "TMDB"]
private let sortOrderOptions = ["Ascending", "Descending"]
@EnvironmentObject var settings: Settings
@State private var showAppIconPicker = false

View file

@ -90,6 +90,7 @@ fileprivate struct SettingsToggleRow: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 48)
if showDivider {
Divider()
@ -118,7 +119,7 @@ struct SettingsViewTrackers: View {
VStack(spacing: 24) {
SettingsSection(title: "AniList") {
VStack(spacing: 0) {
HStack {
HStack(alignment: .center, spacing: 10) {
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
.placeholder {
RoundedRectangle(cornerRadius: 10)
@ -137,30 +138,37 @@ struct SettingsViewTrackers: View {
.font(.title3)
.fontWeight(.semibold)
if isAnilistLoading {
ProgressView()
.scaleEffect(0.8)
} else if isAnilistLoggedIn {
HStack(spacing: 0) {
Text("Logged in as ")
Group {
if isAnilistLoading {
ProgressView()
.scaleEffect(0.8)
.frame(height: 18)
} else if isAnilistLoggedIn {
HStack(spacing: 0) {
Text("Logged in as ")
.font(.footnote)
.foregroundStyle(.gray)
Text(anilistUsername)
.font(.footnote)
.fontWeight(.medium)
.foregroundStyle(profileColor)
}
.frame(height: 18)
} else {
Text(anilistStatus)
.font(.footnote)
.foregroundStyle(.gray)
Text(anilistUsername)
.font(.footnote)
.fontWeight(.medium)
.foregroundStyle(profileColor)
.frame(height: 18)
}
} else {
Text(anilistStatus)
.font(.footnote)
.foregroundStyle(.gray)
}
}
.frame(height: 60, alignment: .center)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 84)
if isAnilistLoggedIn {
Divider()
@ -196,13 +204,14 @@ struct SettingsViewTrackers: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 48)
}
}
}
SettingsSection(title: "Trakt") {
VStack(spacing: 0) {
HStack {
HStack(alignment: .center, spacing: 10) {
KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png"))
.placeholder {
RoundedRectangle(cornerRadius: 10)
@ -221,30 +230,37 @@ struct SettingsViewTrackers: View {
.font(.title3)
.fontWeight(.semibold)
if isTraktLoading {
ProgressView()
.scaleEffect(0.8)
} else if isTraktLoggedIn {
HStack(spacing: 0) {
Text("Logged in as ")
Group {
if isTraktLoading {
ProgressView()
.scaleEffect(0.8)
.frame(height: 18)
} else if isTraktLoggedIn {
HStack(spacing: 0) {
Text("Logged in as ")
.font(.footnote)
.foregroundStyle(.gray)
Text(traktUsername)
.font(.footnote)
.fontWeight(.medium)
.foregroundStyle(.primary)
}
.frame(height: 18)
} else {
Text(traktStatus)
.font(.footnote)
.foregroundStyle(.gray)
Text(traktUsername)
.font(.footnote)
.fontWeight(.medium)
.foregroundStyle(.primary)
.frame(height: 18)
}
} else {
Text(traktStatus)
.font(.footnote)
.foregroundStyle(.gray)
}
}
.frame(height: 60, alignment: .center)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 84)
Divider()
.padding(.horizontal, 16)
@ -268,6 +284,7 @@ struct SettingsViewTrackers: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 48)
}
}
}
@ -405,8 +422,8 @@ struct SettingsViewTrackers: View {
guard status == errSecSuccess,
let tokenData = item as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return nil
}
return token
}

View file

@ -56,6 +56,7 @@
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136BBE7F2DB1038000906B5E /* Notification+Name.swift */; };
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; };
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; };
138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; };
1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */; };
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; };
@ -153,6 +154,7 @@
136BBE7F2DB1038000906B5E /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = "<group>"; };
138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = "<group>"; };
138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = "<group>"; };
138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-FetchID.swift"; sourceTree = "<group>"; };
1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewAbout.swift; sourceTree = "<group>"; };
139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = "<group>"; };
1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewLogger.swift; sourceTree = "<group>"; };
@ -275,6 +277,7 @@
13103E802D589D6C000F0673 /* Tracking Services */ = {
isa = PBXGroup;
children = (
138FE1CE2DEC9FFA00936D81 /* TMDB */,
13E62FBF2DABC3A20007E259 /* Trakt */,
13103E812D589D77000F0673 /* AniList */,
);
@ -363,8 +366,8 @@
133D7C7F2D2BE2630075467E /* MediaInfoView */ = {
isa = PBXGroup;
children = (
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */,
138AA1B52D2D66EC0021F9DF /* EpisodeCell */,
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */,
133D7C802D2BE2630075467E /* MediaInfoView.swift */,
);
path = MediaInfoView;
@ -490,6 +493,14 @@
path = EpisodeCell;
sourceTree = "<group>";
};
138FE1CE2DEC9FFA00936D81 /* TMDB */ = {
isa = PBXGroup;
children = (
138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */,
);
path = TMDB;
sourceTree = "<group>";
};
1399FAD12D3AB33D00E97C31 /* Logger */ = {
isa = PBXGroup;
children = (
@ -729,6 +740,7 @@
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */,
138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */,
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */,
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,