anilist improvement, logic for upscaling images (#137)

This commit is contained in:
Seiike 2025-05-27 06:33:11 +02:00 committed by GitHub
parent 967791878a
commit f2d0e55e57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 358 additions and 61 deletions

View file

@ -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))

View file

@ -33,32 +33,41 @@ class AniListMutation {
return String(data: tokenData, encoding: .utf8)
}
func updateAnimeProgress(animeId: Int, episodeNumber: Int, completion: @escaping (Result<Void, Error>) -> 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, Error>) -> 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<String, Error>) -> 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<Int, Error>) -> Void) {
let query = """
query ($id: Int) {

View file

@ -19,4 +19,5 @@ struct ContinueWatchingItem: Codable, Identifiable {
let aniListID: Int?
let module: ScrapingModule
let headers: [String:String]?
let totalEpisodes: Int
}

View file

@ -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"
)
}
}
}
}

View file

@ -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)
}

View file

@ -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
)

View file

@ -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

View file

@ -568,18 +568,40 @@ struct MediaInfoView: View {
var updates = [String: Double]()
for idx in 0..<index {
if idx < episodeLinks.count {
let href = episodeLinks[idx].href
updates["lastPlayedTime_\(href)"] = 1000.0
updates["totalTime_\(href)"] = 1000.0
}
let href = episodeLinks[idx].href
updates["lastPlayedTime_\(href)"] = 1000.0
updates["totalTime_\(href)"] = 1000.0
}
for (key, value) in updates {
userDefaults.set(value, forKey: key)
}
Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
userDefaults.synchronize()
Logger.shared.log(
"Marked \(ep.number - 1) episodes watched within series \"\(title)\".",
type: "General"
)
guard let listID = itemID, listID > 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 bulksync: set progress to \(watchedCount) (\(statusToSend))",
type: "General"
)
case .failure(let error):
Logger.shared.log(
"AniList bulksync 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
)

View file

@ -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 = "<group>"; };
1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = "<group>"; };
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
1EA64DCC2DE5030100AC14BC /* ImageUpscaler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUpscaler.swift; sourceTree = "<group>"; };
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = "<group>"; };
7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadata.swift; sourceTree = "<group>"; };
@ -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 */,