From f2d0e55e570d92606d06f75dc59b1ab72f632e55 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Tue, 27 May 2025 06:33:11 +0200 Subject: [PATCH] anilist improvement, logic for upscaling images (#137) --- Sora/Managers/ImageUpscaler.swift | 165 ++++++++++++++++++ .../Mutations/AniListPushUpdates.swift | 101 ++++++++--- .../ContinueWatchingItem.swift | 1 + .../CustomPlayer/CustomPlayer.swift | 102 ++++++++--- Sora/Utils/MediaPlayer/VideoPlayer.swift | 4 +- Sora/Views/DownloadView.swift | 1 + Sora/Views/LibraryView/LibraryView.swift | 2 +- Sora/Views/MediaInfoView/MediaInfoView.swift | 39 ++++- Sulfur.xcodeproj/project.pbxproj | 4 + 9 files changed, 358 insertions(+), 61 deletions(-) create mode 100644 Sora/Managers/ImageUpscaler.swift diff --git a/Sora/Managers/ImageUpscaler.swift b/Sora/Managers/ImageUpscaler.swift new file mode 100644 index 0000000..70643d8 --- /dev/null +++ b/Sora/Managers/ImageUpscaler.swift @@ -0,0 +1,165 @@ +// +// ImageUpscaler.swift +// Sulfur +// +// Created by seiike on 26/05/2025. +// + + +import UIKit +import CoreImage +import CoreImage.CIFilterBuiltins +import Vision +import CoreML +import Kingfisher + +public enum ImageUpscaler { + /// Lanczos interpolation + unsharp mask for sharper upscaling. + /// - Parameters: + /// - scale: The factor to upscale (e.g. 2.0 doubles width/height). + /// - sharpeningIntensity: The unsharp mask intensity (0...1). + /// - sharpeningRadius: The unsharp mask radius in pixels. + public static func lanczosProcessor( + scale: CGFloat, + sharpeningIntensity: Float = 0.7, + sharpeningRadius: Float = 2.0 + ) -> ImageProcessor { + return LanczosUpscaleProcessor( + scale: scale, + sharpeningIntensity: sharpeningIntensity, + sharpeningRadius: sharpeningRadius + ) + } + + public static func superResolutionProcessor(modelURL: URL) -> ImageProcessor { + return MLScaleProcessor(modelURL: modelURL) + } +} + +// MARK: - Lanczos + Unsharp Mask Processor +public struct LanczosUpscaleProcessor: ImageProcessor { + public let scale: CGFloat + public let sharpeningIntensity: Float + public let sharpeningRadius: Float + public var identifier: String { + "com.yourapp.lanczos_\(scale)_sharp_\(sharpeningIntensity)_\(sharpeningRadius)" + } + + public init( + scale: CGFloat, + sharpeningIntensity: Float = 0.7, + sharpeningRadius: Float = 2.0 + ) { + self.scale = scale + self.sharpeningIntensity = sharpeningIntensity + self.sharpeningRadius = sharpeningRadius + } + + public func process( + item: ImageProcessItem, + options: KingfisherParsedOptionsInfo + ) -> KFCrossPlatformImage? { + + let inputImage: KFCrossPlatformImage? + switch item { + case .image(let image): + inputImage = image + case .data(let data): + inputImage = KFCrossPlatformImage(data: data) + } + guard let uiImage = inputImage, + let cgImage = uiImage.cgImage else { + return nil + } + + let ciInput = CIImage(cgImage: cgImage) + + let scaleFilter = CIFilter.lanczosScaleTransform() + scaleFilter.inputImage = ciInput + scaleFilter.scale = Float(scale) + scaleFilter.aspectRatio = 1.0 + guard let scaledCI = scaleFilter.outputImage else { + return uiImage + } + + let unsharp = CIFilter.unsharpMask() + unsharp.inputImage = scaledCI + unsharp.intensity = sharpeningIntensity + unsharp.radius = sharpeningRadius + guard let sharpCI = unsharp.outputImage else { + return UIImage(ciImage: scaledCI) + } + + let context = CIContext(options: nil) + guard let outputCG = context.createCGImage(sharpCI, from: sharpCI.extent) else { + return UIImage(ciImage: sharpCI) + } + return KFCrossPlatformImage(cgImage: outputCG) + } +} + +// MARK: - Core ML Super-Resolution Processor +public struct MLScaleProcessor: ImageProcessor { + private let request: VNCoreMLRequest + private let ciContext = CIContext() + public let identifier: String + + public init(modelURL: URL) { + + self.identifier = "com.yourapp.ml_sr_\(modelURL.lastPathComponent)" + guard let mlModel = try? MLModel(contentsOf: modelURL), + let visionModel = try? VNCoreMLModel(for: mlModel) else { + fatalError("Failed to load Core ML model at \(modelURL)") + } + let req = VNCoreMLRequest(model: visionModel) + req.imageCropAndScaleOption = .scaleFill + self.request = req + } + + public func process( + item: ImageProcessItem, + options: KingfisherParsedOptionsInfo + ) -> KFCrossPlatformImage? { + + let inputImage: KFCrossPlatformImage? + switch item { + case .image(let image): + inputImage = image + case .data(let data): + inputImage = KFCrossPlatformImage(data: data) + } + guard let uiImage = inputImage, + let cgImage = uiImage.cgImage else { + return nil + } + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + do { + try handler.perform([request]) + } catch { + print("[MLScaleProcessor] Vision error: \(error)") + return uiImage + } + guard let obs = request.results?.first as? VNPixelBufferObservation else { + return uiImage + } + + let ciOutput = CIImage(cvPixelBuffer: obs.pixelBuffer) + let rect = CGRect( + origin: .zero, + size: CGSize( + width: CVPixelBufferGetWidth(obs.pixelBuffer), + height: CVPixelBufferGetHeight(obs.pixelBuffer) + ) + ) + guard let finalCG = ciContext.createCGImage(ciOutput, from: rect) else { + return uiImage + } + return KFCrossPlatformImage(cgImage: finalCG) + } +} + +// the sweet spot (for mediainfoview poster) +// .setProcessor(ImageUpscaler.lanczosProcessor(scale: 3.2, +// sharpeningIntensity: 0.75, +// sharpeningRadius: 2.25)) diff --git a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift index 997ab5a..934b0f8 100644 --- a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift +++ b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift @@ -33,32 +33,41 @@ class AniListMutation { return String(data: tokenData, encoding: .utf8) } - func updateAnimeProgress(animeId: Int, episodeNumber: Int, completion: @escaping (Result) -> Void) { - if let sendPushUpdates = UserDefaults.standard.object(forKey: "sendPushUpdates") as? Bool, - sendPushUpdates == false { - return - } - - guard let userToken = getTokenFromKeychain() else { - completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"]))) - return - } - - let query = """ - mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) { - SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: $status) { - id - progress - status + func updateAnimeProgress( + animeId: Int, + episodeNumber: Int, + status: String = "CURRENT", + completion: @escaping (Result) -> Void + ) { + if let sendPushUpdates = UserDefaults.standard.object(forKey: "sendPushUpdates") as? Bool, + sendPushUpdates == false { + return } - } - """ - - let variables: [String: Any] = [ + + guard let userToken = getTokenFromKeychain() else { + completion(.failure(NSError( + domain: "", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Access token not found"] + ))) + return + } + + let query = """ + mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) { + SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status) { + id + progress + status + } + } + """ + + let variables: [String: Any] = [ "mediaId": animeId, "progress": episodeNumber, - "status": "CURRENT" - ] + "status": status + ] let requestBody: [String: Any] = [ "query": query, @@ -104,6 +113,52 @@ class AniListMutation { task.resume() } + func fetchMediaStatus( + mediaId: Int, + completion: @escaping (Result) -> Void + ) { + guard let token = getTokenFromKeychain() else { + completion(.failure(NSError( + domain: "", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Access token not found"] + ))) + return + } + + let query = """ + query ($mediaId: Int) { + Media(id: $mediaId) { + status + } + } + """ + + let vars = ["mediaId": mediaId] + var req = URLRequest(url: URL(string: "https://graphql.anilist.co")!) + req.httpMethod = "POST" + req.addValue("application/json", forHTTPHeaderField: "Content-Type") + req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.httpBody = try? JSONSerialization.data( + withJSONObject: ["query": query, "variables": vars] + ) + + URLSession.shared.dataTask(with: req) { data, _, error in + if let e = error { return completion(.failure(e)) } + guard + let d = data, + let json = try? JSONSerialization.jsonObject(with: d) as? [String: Any], + let md = (json["data"] as? [String: Any])?["Media"] as? [String: Any], + let status = md["status"] as? String + else { + return completion(.failure(NSError( + domain: "", code: -2, + userInfo: [NSLocalizedDescriptionKey: "Invalid response"] + ))) + } + completion(.success(status)) + }.resume() + } + func fetchMalID(animeId: Int, completion: @escaping (Result) -> Void) { let query = """ query ($id: Int) { diff --git a/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift b/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift index 3d7d737..6e83792 100644 --- a/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift +++ b/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift @@ -19,4 +19,5 @@ struct ContinueWatchingItem: Codable, Identifiable { let aniListID: Int? let module: ScrapingModule let headers: [String:String]? + let totalEpisodes: Int } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 1dbfc78..93511ca 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -28,6 +28,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private var aniListUpdateImpossible: Bool = false private var aniListRetryCount = 0 private let aniListMaxRetries = 6 + private let totalEpisodes: Int var player: AVPlayer! var timeObserverToken: Any? @@ -183,6 +184,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele onWatchNext: @escaping () -> Void, subtitlesURL: String?, aniListID: Int, + totalEpisodes: Int, episodeImageUrl: String,headers:[String:String]?) { self.module = module @@ -195,6 +197,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.subtitlesURL = subtitlesURL self.aniListID = aniListID self.headers = headers + self.totalEpisodes = totalEpisodes super.init(nibName: nil, bundle: nil) @@ -1424,7 +1427,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele subtitles: self.subtitlesURL, aniListID: self.aniListID, module: self.module, - headers: self.headers + headers: self.headers, + totalEpisodes: self.totalEpisodes ) ContinueWatchingManager.shared.save(item: item) } @@ -1758,35 +1762,77 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } private func tryAniListUpdate() { - let aniListMutation = AniListMutation() - aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { [weak self] result in + guard !aniListUpdatedSuccessfully else { return } + + guard aniListID > 0 else { + Logger.shared.log("AniList ID is invalid, skipping update.", type: "Warning") + return + } + + let client = AniListMutation() + + client.fetchMediaStatus(mediaId: aniListID) { [weak self] statusResult in guard let self = self else { return } - - switch result { - case .success: - self.aniListUpdatedSuccessfully = true - Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General") - - case .failure(let error): - let errorString = error.localizedDescription.lowercased() - Logger.shared.log("AniList progress update failed: \(errorString)", type: "Error") - - if errorString.contains("access token not found") { - Logger.shared.log("AniList update will NOT retry due to missing token.", type: "Error") - self.aniListUpdateImpossible = true - - } else { - if self.aniListRetryCount < self.aniListMaxRetries { - self.aniListRetryCount += 1 - - let delaySeconds = 5.0 - Logger.shared.log("AniList update will retry in \(delaySeconds)s (attempt \(self.aniListRetryCount)).", type: "Debug") - - DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) { - self.tryAniListUpdate() - } + + let newStatus: String = { + switch statusResult { + case .success(let mediaStatus): + if mediaStatus == "RELEASING" { + return "CURRENT" + } + return (self.episodeNumber == self.totalEpisodes) ? "COMPLETED" : "CURRENT" + + case .failure(let error): + Logger.shared.log( + "Failed to fetch AniList status: \(error.localizedDescription). " + + "Using default CURRENT/COMPLETED logic.", + type: "Warning" + ) + return (self.episodeNumber == self.totalEpisodes) ? "COMPLETED" : "CURRENT" + } + }() + + client.updateAnimeProgress( + animeId: self.aniListID, + episodeNumber: self.episodeNumber, + status: newStatus + ) { result in + switch result { + case .success: + self.aniListUpdatedSuccessfully = true + Logger.shared.log( + "AniList progress updated to \(newStatus) for ep \(self.episodeNumber)", + type: "General" + ) + + case .failure(let error): + let errorString = error.localizedDescription.lowercased() + Logger.shared.log("AniList progress update failed: \(errorString)", type: "Error") + + if errorString.contains("access token not found") { + Logger.shared.log("AniList update will NOT retry due to missing token.", type: "Error") + self.aniListUpdateImpossible = true + } else { - Logger.shared.log("AniList update reached max retries. No more attempts.", type: "Error") + if self.aniListRetryCount < self.aniListMaxRetries { + self.aniListRetryCount += 1 + + let delaySeconds = 5.0 + Logger.shared.log( + "AniList update will retry in \(delaySeconds)s " + + "(attempt \(self.aniListRetryCount)).", + type: "Debug" + ) + + DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) { + self.tryAniListUpdate() + } + } else { + Logger.shared.log( + "Reached max retry count (\(self.aniListMaxRetries)). Giving up.", + type: "Error" + ) + } } } } diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index b8bbb20..e66fbee 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -20,6 +20,7 @@ class VideoPlayerViewController: UIViewController { var aniListID: Int = 0 var headers: [String:String]? = nil + var totalEpisodes: Int = 0 var episodeNumber: Int = 0 var episodeImageUrl: String = "" var mediaTitle: String = "" @@ -139,7 +140,8 @@ class VideoPlayerViewController: UIViewController { subtitles: self.subtitles, aniListID: self.aniListID, module: self.module, - headers: self.headers + headers: self.headers, + totalEpisodes: self.totalEpisodes ) ContinueWatchingManager.shared.save(item: item) } diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index 6da69cf..f956e6e 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -244,6 +244,7 @@ struct DownloadView: View { onWatchNext: {}, subtitlesURL: asset.localSubtitleURL?.absoluteString, aniListID: 0, + totalEpisodes: asset.metadata?.episode ?? 0, episodeImageUrl: asset.metadata?.posterURL?.absoluteString ?? "", headers: nil ) diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index dbde878..dee4e62 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -284,9 +284,9 @@ struct ContinueWatchingCell: View { onWatchNext: { }, subtitlesURL: item.subtitles, aniListID: item.aniListID ?? 0, + totalEpisodes: item.totalEpisodes, episodeImageUrl: item.imageUrl, headers: item.headers ?? nil - ) customMediaPlayer.modalPresentationStyle = .fullScreen diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index eae7ad3..b57ae3a 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -568,18 +568,40 @@ struct MediaInfoView: View { var updates = [String: Double]() for idx in 0.. 0 else { return } + let watchedCount = ep.number - 1 + let statusToSend = (watchedCount == episodeLinks.count) ? "COMPLETED" : "CURRENT" + AniListMutation().updateAnimeProgress( + animeId: listID, + episodeNumber: watchedCount, + status: statusToSend + ) { result in + switch result { + case .success: + Logger.shared.log( + "AniList bulk‐sync: set progress to \(watchedCount) (\(statusToSend))", + type: "General" + ) + case .failure(let error): + Logger.shared.log( + "AniList bulk‐sync failed: \(error.localizedDescription)", + type: "Error" + ) + } + } } @ViewBuilder @@ -997,6 +1019,7 @@ struct MediaInfoView: View { }, subtitlesURL: subtitles, aniListID: itemID ?? 0, + totalEpisodes: episodeLinks.count, episodeImageUrl: selectedEpisodeImage, headers: headers ?? nil ) diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index fd74cbf..4a3fff4 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -66,6 +66,7 @@ 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; }; 1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; + 1EA64DCD2DE5030100AC14BC /* ImageUpscaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA64DCC2DE5030100AC14BC /* ImageUpscaler.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; 1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */; }; 7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */; }; @@ -146,6 +147,7 @@ 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = ""; }; 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = ""; }; 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; + 1EA64DCC2DE5030100AC14BC /* ImageUpscaler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUpscaler.swift; sourceTree = ""; }; 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = ""; }; 7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadata.swift; sourceTree = ""; }; @@ -520,6 +522,7 @@ 72AC3A002DD4DAEA00C60B96 /* Managers */ = { isa = PBXGroup; children = ( + 1EA64DCC2DE5030100AC14BC /* ImageUpscaler.swift */, 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */, 72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */, 72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */, @@ -668,6 +671,7 @@ 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */, 727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */, 132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */, + 1EA64DCD2DE5030100AC14BC /* ImageUpscaler.swift in Sources */, 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */, 72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */, 72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */,