From b8a225e1419a77196d0c0048bac8c54b73e650d1 Mon Sep 17 00:00:00 2001 From: kingbri Date: Sun, 2 Jun 2024 12:31:19 -0400 Subject: [PATCH] Debrid: Begin using common protocols Unifying the debrid services under a protocol will help slim down on excess redundant code and allow for easy addition of new services in the future. Signed-off-by: kingbri --- Ferrite.xcodeproj/project.pbxproj | 8 +++ Ferrite/API/AllDebridWrapper.swift | 28 ++++++--- Ferrite/API/PremiumizeWrapper.swift | 13 +++-- Ferrite/API/RealDebridWrapper.swift | 79 ++++++++++++++------------ Ferrite/Models/DebridModels.swift | 16 ++++++ Ferrite/Protocols/Debrid.swift | 34 +++++++++++ Ferrite/ViewModels/DebridManager.swift | 26 ++++----- 7 files changed, 140 insertions(+), 64 deletions(-) create mode 100644 Ferrite/Models/DebridModels.swift create mode 100644 Ferrite/Protocols/Debrid.swift diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index de3bb7d..14919aa 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -154,6 +154,8 @@ 0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; }; 0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */; }; 0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */; }; + 0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */; }; + 0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */; }; 0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */; }; /* End PBXBuildFile section */ @@ -300,6 +302,8 @@ 0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = ""; }; 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilterHeaderView.swift; sourceTree = ""; }; 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryPickerView.swift; sourceTree = ""; }; + 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debrid.swift; sourceTree = ""; }; + 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridModels.swift; sourceTree = ""; }; 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -399,6 +403,7 @@ 0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */, 0C6771F929B3D1AE005D38D2 /* KodiModels.swift */, 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */, + 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */, ); path = Models; sourceTree = ""; @@ -492,6 +497,7 @@ isa = PBXGroup; children = ( 0CE1C4172981E8D700418F20 /* Plugin.swift */, + 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */, ); path = Protocols; sourceTree = ""; @@ -923,6 +929,7 @@ 0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */, 0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */, 0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */, + 0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */, 0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */, 0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */, 0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */, @@ -942,6 +949,7 @@ 0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */, 0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */, 0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */, + 0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */, 0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */, 0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */, 0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */, diff --git a/Ferrite/API/AllDebridWrapper.swift b/Ferrite/API/AllDebridWrapper.swift index 2b850ff..0f2c50e 100644 --- a/Ferrite/API/AllDebridWrapper.swift +++ b/Ferrite/API/AllDebridWrapper.swift @@ -8,24 +8,36 @@ import Foundation // TODO: Fix errors -public class AllDebrid { - let jsonDecoder = JSONDecoder() +public class AllDebrid: PollingDebridSource { + + public let id = "AllDebrid" + public var authTask: Task? let baseApiUrl = "https://api.alldebrid.com/v4" let appName = "Ferrite" - var authTask: Task? + let jsonDecoder = JSONDecoder() // Fetches information for PIN auth - public func getPinInfo() async throws -> PinResponse { + public func getAuthUrl() async throws -> URL { let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get") let request = URLRequest(url: url) do { let (data, _) = try await URLSession.shared.data(for: request) - let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data - return rawResponse + // Validate the URL before doing anything else + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data + guard let userUrl = URL(string: rawResponse.userURL) else { + throw ADError.AuthQuery(description: "The login URL is invalid") + } + + // Spawn the polling task separately + authTask = Task { + try await getApiKey(checkID: rawResponse.check, pin: rawResponse.pin) + } + + return userUrl } catch { print("Couldn't get pin information!") throw ADError.AuthQuery(description: error.localizedDescription) @@ -88,7 +100,7 @@ public class AllDebrid { } // Clears tokens. No endpoint to deregister a device - public func deleteTokens() { + public func logout() { FerriteKeychain.shared.delete("AllDebrid.ApiKey") UserDefaults.standard.removeObject(forKey: "AllDebrid.UseManualKey") } @@ -110,7 +122,7 @@ public class AllDebrid { if response.statusCode >= 200, response.statusCode <= 299 { return data } else if response.statusCode == 401 { - deleteTokens() + logout() throw ADError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.") } else { throw ADError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") diff --git a/Ferrite/API/PremiumizeWrapper.swift b/Ferrite/API/PremiumizeWrapper.swift index 3ad235c..d771536 100644 --- a/Ferrite/API/PremiumizeWrapper.swift +++ b/Ferrite/API/PremiumizeWrapper.swift @@ -7,14 +7,17 @@ import Foundation -public class Premiumize { - let jsonDecoder = JSONDecoder() +public class Premiumize: OAuthDebridSource { + + public let id = "Premiumize" let baseAuthUrl = "https://www.premiumize.me/authorize" let baseApiUrl = "https://www.premiumize.me/api" let clientId = "791565696" - public func buildAuthUrl() throws -> URL { + let jsonDecoder = JSONDecoder() + + public func getAuthUrl() throws -> URL { var urlComponents = URLComponents(string: baseAuthUrl)! urlComponents.queryItems = [ URLQueryItem(name: "client_id", value: clientId), @@ -59,7 +62,7 @@ public class Premiumize { } // Clears tokens. No endpoint to deregister a device - public func deleteTokens() { + public func logout() { FerriteKeychain.shared.delete("Premiumize.AccessToken") UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey") } @@ -101,7 +104,7 @@ public class Premiumize { if response.statusCode >= 200, response.statusCode <= 299 { return data } else if response.statusCode == 401 { - deleteTokens() + logout() throw PMError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.") } else { throw PMError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index 1291b2e..14e46a5 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -7,14 +7,16 @@ import Foundation -public class RealDebrid { - let jsonDecoder = JSONDecoder() +public class RealDebrid: PollingDebridSource { + + public let id = "RealDebrid" + public var authTask: Task? let baseAuthUrl = "https://api.real-debrid.com/oauth/v2" let baseApiUrl = "https://api.real-debrid.com/rest/1.0" let openSourceClientId = "X245A4XAIBGVM" - var authTask: Task? + let jsonDecoder = JSONDecoder() @MainActor func setUserDefaultsValue(_ value: Any, forKey: String) { @@ -27,7 +29,7 @@ public class RealDebrid { } // Fetches the device code from RD - public func getVerificationInfo() async throws -> DeviceCodeResponse { + public func getAuthUrl() async throws -> URL { var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")! urlComponents.queryItems = [ URLQueryItem(name: "client_id", value: openSourceClientId), @@ -42,8 +44,18 @@ public class RealDebrid { do { let (data, _) = try await URLSession.shared.data(for: request) + // Validate the URL before doing anything else let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data) - return rawResponse + guard let directVerificationUrl = URL(string: rawResponse.directVerificationURL) else { + throw RDError.AuthQuery(description: "The verification URL is invalid") + } + + // Spawn the polling task separately + authTask = Task { + try await getDeviceCredentials(deviceCode: rawResponse.deviceCode) + } + + return directVerificationUrl } catch { print("Couldn't get the new client creds!") throw RDError.AuthQuery(description: error.localizedDescription) @@ -65,39 +77,33 @@ public class RealDebrid { let request = URLRequest(url: url) // Timer to poll RD API for credentials - authTask = Task { - var count = 0 + var count = 0 - while count < 12 { - if Task.isCancelled { - throw RDError.AuthQuery(description: "Token request cancelled.") - } - - let (data, _) = try await URLSession.shared.data(for: request) - - // We don't care if this fails - let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data) - - // If there's a client ID from the response, end the task successfully - if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret { - await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId") - FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret") - - try await getTokens(deviceCode: deviceCode) - - return - } else { - try await Task.sleep(seconds: 5) - count += 1 - } + while count < 12 { + if Task.isCancelled { + throw RDError.AuthQuery(description: "Token request cancelled.") } - throw RDError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.") + let (data, _) = try await URLSession.shared.data(for: request) + + // We don't care if this fails + let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data) + + // If there's a client ID from the response, end the task successfully + if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret { + await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId") + FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret") + + try await getTokens(deviceCode: deviceCode) + + return + } else { + try await Task.sleep(seconds: 5) + count += 1 + } } - if case let .failure(error) = await authTask?.result { - throw error - } + throw RDError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.") } // Fetch all tokens for the user and store in FerriteKeychain.shared @@ -163,8 +169,9 @@ public class RealDebrid { return FerriteKeychain.shared.get("RealDebrid.AccessToken") == key } - - public func deleteTokens() async throws { + + // Deletes tokens from device and RD's servers + public func logout() async { FerriteKeychain.shared.delete("RealDebrid.RefreshToken") FerriteKeychain.shared.delete("RealDebrid.ClientSecret") await removeUserDefaultsValue(forKey: "RealDebrid.ClientId") @@ -198,7 +205,7 @@ public class RealDebrid { if response.statusCode >= 200, response.statusCode <= 299 { return data } else if response.statusCode == 401 { - try await deleteTokens() + await logout() throw RDError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.") } else { throw RDError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") diff --git a/Ferrite/Models/DebridModels.swift b/Ferrite/Models/DebridModels.swift new file mode 100644 index 0000000..011c61f --- /dev/null +++ b/Ferrite/Models/DebridModels.swift @@ -0,0 +1,16 @@ +// +// DebridModels.swift +// Ferrite +// +// Created by Brian Dashore on 6/2/24. +// + +import Foundation + +public struct DebridIAFile { + +} + +public struct DebridCloudFile { + +} diff --git a/Ferrite/Protocols/Debrid.swift b/Ferrite/Protocols/Debrid.swift new file mode 100644 index 0000000..3a51061 --- /dev/null +++ b/Ferrite/Protocols/Debrid.swift @@ -0,0 +1,34 @@ +// +// Debrid.swift +// Ferrite +// +// Created by Brian Dashore on 6/1/24. +// + +import Foundation + +public protocol DebridSource { + // ID of the service + var id: String { get } + + // Common authentication functions + func setApiKey(_ key: String) -> Bool + func logout() async +} + +public protocol PollingDebridSource: DebridSource { + // Task reference for polling + var authTask: Task? { get set } + + // Fetches the Auth URL + func getAuthUrl() async throws -> URL +} + +public protocol OAuthDebridSource: DebridSource { + + // Fetches the auth URL + func getAuthUrl() throws -> URL + + // Handles an OAuth callback + func handleAuthCallback(url: URL) throws +} diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index e7da300..5d464fd 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -450,10 +450,10 @@ public class DebridManager: ObservableObject { private func authenticateRd() async -> Bool { do { realDebridAuthProcessing = true - let verificationResponse = try await realDebrid.getVerificationInfo() + let authUrl = try await realDebrid.getAuthUrl() - if validateAuthUrl(URL(string: verificationResponse.directVerificationURL)) { - try await realDebrid.getDeviceCredentials(deviceCode: verificationResponse.deviceCode) + if validateAuthUrl(authUrl) { + try await realDebrid.authTask?.value return true } else { throw RealDebrid.RDError.AuthQuery(description: "The verification URL was invalid") @@ -469,10 +469,10 @@ public class DebridManager: ObservableObject { private func authenticateAd() async -> Bool { do { allDebridAuthProcessing = true - let pinResponse = try await allDebrid.getPinInfo() + let authUrl = try await allDebrid.getAuthUrl() - if validateAuthUrl(URL(string: pinResponse.userURL)) { - try await allDebrid.getApiKey(checkID: pinResponse.check, pin: pinResponse.pin) + if validateAuthUrl(authUrl) { + try await allDebrid.authTask?.value return true } else { throw AllDebrid.ADError.AuthQuery(description: "The PIN URL was invalid") @@ -488,7 +488,7 @@ public class DebridManager: ObservableObject { private func authenticatePm() async { do { premiumizeAuthProcessing = true - let tempAuthUrl = try premiumize.buildAuthUrl() + let tempAuthUrl = try premiumize.getAuthUrl() validateAuthUrl(tempAuthUrl, useAuthSession: true) } catch { @@ -538,16 +538,12 @@ public class DebridManager: ObservableObject { } private func logoutRd() async { - do { - try await realDebrid.deleteTokens() - enabledDebrids.remove(.realDebrid) - } catch { - await sendDebridError(error, prefix: "RealDebrid logout error") - } + await realDebrid.logout() + enabledDebrids.remove(.realDebrid) } private func logoutAd() { - allDebrid.deleteTokens() + allDebrid.logout() enabledDebrids.remove(.allDebrid) logManager?.info( @@ -557,7 +553,7 @@ public class DebridManager: ObservableObject { } private func logoutPm() { - premiumize.deleteTokens() + premiumize.logout() enabledDebrids.remove(.premiumize) }