Ferrite-backup/Ferrite/ViewModels/DebridManager.swift
kingbri 84357ea2c5 Debrid: Update IA fetching for RealDebrid
To avoid inflating the IA value cache, restore the TTL logic and only
append IAs that are part of the sent magnets.

In addition, if an IA result isn't found for a model download, re-fetch
the IA cache.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-26 23:33:50 -05:00

493 lines
16 KiB
Swift

//
// DebridManager.swift
// Ferrite
//
// Created by Brian Dashore on 7/20/22.
//
import Foundation
import SwiftUI
@MainActor
class DebridManager: ObservableObject {
// Linked classes
var logManager: LoggingManager?
@Published var realDebrid: RealDebrid = .init()
@Published var allDebrid: AllDebrid = .init()
@Published var premiumize: Premiumize = .init()
@Published var torbox: TorBox = .init()
@Published var offcloud: OffCloud = .init()
lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox, offcloud]
// UI Variables
@Published var showWebView: Bool = false
@Published var showAuthSession: Bool = false
@Published var enabledDebrids: [DebridSource] = []
@Published var selectedDebridSource: DebridSource? {
didSet {
UserDefaults.standard.set(selectedDebridSource?.id ?? "", forKey: "Debrid.PreferredService")
}
}
var selectedDebridItem: DebridIA?
var selectedDebridFile: DebridIAFile?
var requiresUnrestrict: Bool = false
// TODO: Figure out a way to remove this var
private var selectedOAuthDebridSource: OAuthDebridSource?
@Published var filteredIAStatus: Set<IAStatus> = []
var currentDebridTask: Task<Void, Never>?
var downloadUrl: String = ""
var authUrl: URL?
@Published var showDeleteAlert: Bool = false
@Published var showWebLoginAlert: Bool = false
@Published var showNotImplementedAlert: Bool = false
@Published var notImplementedMessage: String = ""
init() {
// Update the UI for debrid services that are enabled
enabledDebrids = debridSources.filter(\.isLoggedIn)
// Set the preferred service. Contains migration logic for earlier versions
if let rawPreferredService = UserDefaults.standard.string(forKey: "Debrid.PreferredService") {
let debridServiceId: String?
if let preferredServiceInt = Int(rawPreferredService) {
debridServiceId = migratePreferredService(preferredServiceInt)
} else {
debridServiceId = rawPreferredService
}
// Only set the debrid source if it's logged in
// Otherwise remove the key
let tempDebridSource = debridSources.first { $0.id == debridServiceId }
if tempDebridSource?.isLoggedIn ?? false {
selectedDebridSource = tempDebridSource
} else {
UserDefaults.standard.removeObject(forKey: "Debrid.PreferredService")
}
}
}
// TODO: Remove after v0.8.0
// Function to migrate the preferred service to the new string ID format
private func migratePreferredService(_ idInt: Int) -> String? {
// Undo the EnabledDebrids key
UserDefaults.standard.removeObject(forKey: "Debrid.EnabledArray")
return DebridType(rawValue: idInt)?.toString()
}
// Wrapper function to match error descriptions
// Error can be suppressed to end user but must be printed in logs
private func sendDebridError(
_ error: Error,
prefix: String,
presentError: Bool = true,
cancelString: String? = nil
) async {
let error = error as NSError
if presentError {
switch error.code {
case -1009:
logManager?.info(
"DebridManager: The connection is offline",
description: "The connection is offline"
)
case -999:
if let cancelString {
logManager?.info(cancelString, description: cancelString)
} else {
break
}
default:
logManager?.error("\(prefix): \(error)")
}
}
}
// Cleans all cached IA values in the event of a full IA refresh
func clearIAValues() {
for debridSource in debridSources {
debridSource.IAValues = []
}
}
// Clears all selected files and items
func clearSelectedDebridItems() {
selectedDebridItem = nil
selectedDebridFile = nil
}
// Common function to populate hashes for debrid services
func populateDebridIA(_ resultMagnets: [Magnet]) async {
for debridSource in debridSources {
if !debridSource.isLoggedIn {
continue
}
// Don't exit the function if the API fetch errors
do {
try await debridSource.instantAvailability(magnets: resultMagnets)
} catch {
await sendDebridError(error, prefix: "\(debridSource.id) IA fetch error")
}
}
}
// Common function to match a magnet hash with a provided debrid service
func matchMagnetHash(_ magnet: Magnet) -> IAStatus {
guard let magnetHash = magnet.hash else {
return .none
}
if let selectedDebridSource,
let match = selectedDebridSource.IAValues.first(where: { magnetHash == $0.magnet.hash })
{
return match.files.count > 1 ? .partial : .full
} else {
return .none
}
}
func selectDebridResult(magnet: Magnet) -> Bool {
guard let magnetHash = magnet.hash else {
logManager?.error("DebridManager: Could not find the magnet hash")
return false
}
guard let selectedSource = selectedDebridSource else {
return false
}
if let IAItem = selectedSource.IAValues.first(where: { magnetHash == $0.magnet.hash }) {
selectedDebridItem = IAItem
if IAItem.files.count == 1 {
selectedDebridFile = IAItem.files[safe: 0]
}
return true
} else {
logManager?.warn("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)")
return false
}
}
// MARK: - Authentication UI linked functions
// Common function to delegate what debrid service to authenticate with
func authenticateDebrid(_ debridSource: some DebridSource, apiKey: String?) async {
defer {
// Don't cancel processing if using OAuth
if !(debridSource is OAuthDebridSource) {
debridSource.authProcessing = false
}
if enabledDebrids.count == 1 {
selectedDebridSource = debridSource
}
}
// Set an API key if manually provided
if let apiKey {
debridSource.setApiKey(apiKey)
enabledDebrids.append(debridSource)
return
}
// Processing has started
debridSource.authProcessing = true
if let pollingSource = debridSource as? PollingDebridSource {
do {
let authUrl = try await pollingSource.getAuthUrl()
if validateAuthUrl(authUrl) {
try await pollingSource.authTask?.value
enabledDebrids.append(debridSource)
} else {
throw DebridError.AuthQuery(description: "The authentication URL was invalid")
}
} catch {
await sendDebridError(error, prefix: "\(debridSource.id) authentication error")
pollingSource.authTask?.cancel()
}
} else if let oauthSource = debridSource as? OAuthDebridSource {
do {
let tempAuthUrl = try oauthSource.getAuthUrl()
selectedOAuthDebridSource = oauthSource
validateAuthUrl(tempAuthUrl, useAuthSession: true)
} catch {
await sendDebridError(error, prefix: "\(debridSource.id) authentication error")
}
} else {
// Let the user know that a traditional auth method doesn't exist
showWebLoginAlert.toggle()
logManager?.error(
"DebridManager: Auth: \(debridSource.id) does not have a login portal.",
showToast: false
)
return
}
}
// Get a truncated manual API key if it's being used
func getManualAuthKey(_ debridSource: some DebridSource) async -> String? {
if let debridToken = debridSource.manualToken {
let splitString = debridToken.suffix(4)
if debridToken.count > 4 {
return String(repeating: "*", count: debridToken.count - 4) + splitString
} else {
return String(splitString)
}
} else {
return nil
}
}
// Wrapper function to validate and present an auth URL to the user
@discardableResult private func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool {
guard let url else {
logManager?.error("DebridManager: Authentication: Invalid URL created: \(String(describing: url))")
return false
}
authUrl = url
if useAuthSession {
showAuthSession.toggle()
} else {
showWebView.toggle()
}
return true
}
// Currently handles Premiumize callback
func handleAuthCallback(url: URL?, error: Error?) async {
defer {
if enabledDebrids.count == 1 {
selectedDebridSource = selectedOAuthDebridSource
}
selectedOAuthDebridSource?.authProcessing = false
}
do {
guard let oauthDebridSource = selectedOAuthDebridSource else {
throw DebridError.AuthQuery(description: "OAuth source couldn't be found for callback. Aborting.")
}
if let error {
throw DebridError.AuthQuery(description: "OAuth callback Error: \(error)")
}
if let callbackUrl = url {
try oauthDebridSource.handleAuthCallback(url: callbackUrl)
enabledDebrids.append(oauthDebridSource)
} else {
throw DebridError.AuthQuery(description: "The callback URL was invalid")
}
} catch {
await sendDebridError(error, prefix: "Premiumize authentication error (callback)")
}
}
// MARK: - Logout UI functions
func logout(_ debridSource: some DebridSource) async {
await debridSource.logout()
if selectedDebridSource?.id == debridSource.id {
selectedDebridSource = nil
}
enabledDebrids.removeAll { $0.id == debridSource.id }
}
// MARK: - Debrid fetch UI linked functions
// Common function to delegate what debrid service to fetch from
// Cloudinfo is used for any extra information provided by debrid cloud
func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async {
defer {
logManager?.hideIndeterminateToast()
if !requiresUnrestrict {
clearSelectedDebridItems()
}
currentDebridTask = nil
}
logManager?.updateIndeterminateToast("Loading content", cancelAction: {
self.currentDebridTask?.cancel()
self.currentDebridTask = nil
})
guard let debridSource = selectedDebridSource else {
return
}
do {
// Cleanup beforehand
requiresUnrestrict = false
if let cloudInfo {
downloadUrl = try await debridSource.checkUserDownloads(link: cloudInfo) ?? ""
return
}
if let magnet {
let (restrictedFile, newIA) = try await debridSource.getRestrictedFile(
magnet: magnet, ia: selectedDebridItem, iaFile: selectedDebridFile
)
// Indicate that a link needs to be selected (batch)
if let newIA {
if newIA.files.isEmpty {
throw DebridError.EmptyData
}
selectedDebridItem = newIA
requiresUnrestrict = true
return
}
guard let restrictedFile else {
throw DebridError.FailedRequest(description: "No files found for your request")
}
// Update the UI
downloadUrl = try await debridSource.unrestrictFile(restrictedFile)
} else {
throw DebridError.FailedRequest(description: "Could not fetch your file from \(debridSource.id)'s cache or API")
}
// Fetch one more time to add updated data into the RD cloud cache
await fetchDebridCloud(bypassTTL: true)
} catch {
switch error {
case DebridError.IsCaching:
showDeleteAlert.toggle()
default:
await sendDebridError(error, prefix: "\(debridSource.id) download error", cancelString: "Download cancelled")
}
}
}
func unrestrictDownload() async {
defer {
logManager?.hideIndeterminateToast()
requiresUnrestrict = false
clearSelectedDebridItems()
currentDebridTask = nil
}
logManager?.updateIndeterminateToast("Loading content", cancelAction: {
self.currentDebridTask?.cancel()
self.currentDebridTask = nil
})
guard let debridFile = selectedDebridFile, let debridSource = selectedDebridSource else {
logManager?.error("DebridManager: Could not unrestrict the selected debrid file.")
return
}
do {
let downloadLink = try await debridSource.unrestrictFile(debridFile)
downloadUrl = downloadLink
} catch {
await sendDebridError(error, prefix: "\(debridSource.id) unrestrict error", cancelString: "Unrestrict cancelled")
}
}
// Wrapper to handle cloud fetching
func fetchDebridCloud(bypassTTL: Bool = false) async {
guard let selectedSource = selectedDebridSource else {
return
}
if bypassTTL || Date().timeIntervalSince1970 > selectedSource.cloudTTL {
do {
// Populates the inner downloads and magnet arrays
try await selectedSource.getUserDownloads()
try await selectedSource.getUserMagnets()
// Update the TTL to 5 minutes from now
selectedSource.cloudTTL = Date().timeIntervalSince1970 + 300
} catch {
let error = error as NSError
if error.code != -999 {
await sendDebridError(error, prefix: "\(selectedSource.id) cloud fetch error")
}
}
}
}
func deleteCloudDownload(_ download: DebridCloudDownload) async {
guard let selectedSource = selectedDebridSource else {
return
}
do {
try await selectedSource.deleteUserDownload(downloadId: download.id)
await fetchDebridCloud(bypassTTL: true)
} catch {
switch error {
case DebridError.NotImplemented:
let message = "Download deletion for \(selectedSource.id) is not implemented. Please delete from the service's website."
notImplementedMessage = message
showNotImplementedAlert.toggle()
logManager?.error(
"DebridManager: \(message)",
showToast: false
)
default:
await sendDebridError(error, prefix: "\(selectedSource.id) download delete error")
}
}
}
func deleteUserMagnet(_ cloudMagnet: DebridCloudMagnet) async {
guard let selectedSource = selectedDebridSource else {
return
}
do {
try await selectedSource.deleteUserMagnet(cloudMagnetId: cloudMagnet.id)
await fetchDebridCloud(bypassTTL: true)
} catch {
switch error {
case DebridError.NotImplemented:
let message = "Magnet deletion for \(selectedSource.id) is not implemented. Please use the service's website."
notImplementedMessage = message
showNotImplementedAlert.toggle()
logManager?.error(
"DebridManager: \(message)",
showToast: false
)
default:
await sendDebridError(error, prefix: "\(selectedSource.id) magnet delete error")
}
}
}
}