From 17867db40caa191bd956bb9eeea1083cd54349c2 Mon Sep 17 00:00:00 2001 From: kingbri Date: Mon, 5 Dec 2022 16:01:26 -0500 Subject: [PATCH] Debrid: Add Premiumize support and cleanup Premiumize is another debrid provider. Add support in addition to other debrid services. Add a unified Magnet type that encloses both the link and hash when needed for certain services. A universal ASAuthenticationSession has been added to make implicit authentication easier for services that support it. Clean up declarations of certain variables that were mismanaged during the debrid decentralization process. Signed-off-by: kingbri --- Ferrite.xcodeproj/project.pbxproj | 37 ++- Ferrite/API/AllDebridWrapper.swift | 6 +- Ferrite/API/PremiumizeWrapper.swift | 158 +++++++++++++ Ferrite/API/RealDebridWrapper.swift | 6 +- Ferrite/Extensions/Array.swift | 21 +- Ferrite/Extensions/Set.swift | 26 +++ Ferrite/Info.plist | 13 ++ Ferrite/Models/DebridManagerModels.swift | 9 + Ferrite/Models/PremiumizeModels.swift | 68 ++++++ Ferrite/Models/SearchModels.swift | 2 +- Ferrite/ViewModels/DebridManager.swift | 216 +++++++++++++++--- .../Library/BookmarksView.swift | 10 +- .../SearchResult/SearchResultInfoView.swift | 4 + Ferrite/Views/ContentView.swift | 13 +- .../Views/RepresentableViews/WebView.swift | 6 +- Ferrite/Views/SettingsView.swift | 31 ++- .../Views/SheetViews/BatchChoiceView.swift | 17 +- .../Views/SheetViews/MagnetChoiceView.swift | 2 +- 18 files changed, 577 insertions(+), 68 deletions(-) create mode 100644 Ferrite/API/PremiumizeWrapper.swift create mode 100644 Ferrite/Extensions/Set.swift create mode 100644 Ferrite/Models/PremiumizeModels.swift diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index 999e7e2..55d366e 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -22,8 +22,10 @@ 0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */; }; 0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */; }; 0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */; }; + 0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */; }; + 0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; }; 0C42B5962932F2D5008057A0 /* DebridChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */; }; - 0C42B5982932F6DD008057A0 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Array.swift */; }; + 0C42B5982932F6DD008057A0 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Set.swift */; }; 0C44E2A828D4DDDC007711AE /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2A728D4DDDC007711AE /* Application.swift */; }; 0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AC28D51C63007711AE /* BackupManager.swift */; }; 0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AE28D52E8A007711AE /* BackupsView.swift */; }; @@ -105,7 +107,9 @@ 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; }; 0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; }; 0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */; }; + 0CD72E17293D9928001A7EA4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD72E16293D9928001A7EA4 /* Array.swift */; }; 0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */; }; + 0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */ = {isa = PBXBuildFile; productRef = 0CDDDE042935235E006810B1 /* BetterSafariView */; }; 0CE66B3A28E640D200F69346 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE66B3928E640D200F69346 /* Backport.swift */; }; /* End PBXBuildFile section */ @@ -125,8 +129,10 @@ 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = ""; }; 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultButtonView.swift; sourceTree = ""; }; 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModels.swift; sourceTree = ""; }; + 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeWrapper.swift; sourceTree = ""; }; + 0C422E7F293542F300486D65 /* PremiumizeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeModels.swift; sourceTree = ""; }; 0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridChoiceView.swift; sourceTree = ""; }; - 0C42B5972932F6DD008057A0 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; + 0C42B5972932F6DD008057A0 /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = ""; }; 0C44E2A728D4DDDC007711AE /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 0C44E2AC28D51C63007711AE /* BackupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManager.swift; sourceTree = ""; }; 0C44E2AE28D52E8A007711AE /* BackupsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsView.swift; sourceTree = ""; }; @@ -203,6 +209,7 @@ 0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = ""; }; 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisabledAppearance.swift; sourceTree = ""; }; + 0CD72E16293D9928001A7EA4 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyInstructionView.swift; sourceTree = ""; }; 0CE66B3928E640D200F69346 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -219,6 +226,7 @@ 0CB6516828C5A5EC00DCA721 /* Introspect in Frameworks */, 0C64A4B7288903880079976D /* KeychainSwift in Frameworks */, 0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */, + 0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -287,6 +295,7 @@ 0C7ED14028D61BBA009E29AD /* BackupModels.swift */, 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */, 0C68135128BC1A7C00FAD890 /* GithubModels.swift */, + 0C422E7F293542F300486D65 /* PremiumizeModels.swift */, 0C0167DB29293FA900B65783 /* RealDebridModels.swift */, 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */, 0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */, @@ -401,7 +410,6 @@ 0CA148C8288903F000DE2211 /* Extensions */ = { isa = PBXGroup; children = ( - 0C42B5972932F6DD008057A0 /* Array.swift */, 0CA148C9288903F000DE2211 /* Collection.swift */, 0CA148CA288903F000DE2211 /* Data.swift */, 0CA429F728C5098D000D0610 /* DateFormatter.swift */, @@ -410,7 +418,9 @@ 0CA148CB288903F000DE2211 /* Task.swift */, 0C7D11FD28AA03FE00ED92DB /* View.swift */, 0C7ED14228D65518009E29AD /* FileManager.swift */, + 0C42B5972932F6DD008057A0 /* Set.swift */, 0C7C128528DAA3CD00381CD1 /* URL.swift */, + 0CD72E16293D9928001A7EA4 /* Array.swift */, ); path = Extensions; sourceTree = ""; @@ -460,6 +470,7 @@ children = ( 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */, 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */, + 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */, 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */, ); path = API; @@ -526,6 +537,7 @@ 0C7376EF28A97D1400D60918 /* SwiftUIX */, 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */, 0CB6516728C5A5EC00DCA721 /* Introspect */, + 0CDDDE042935235E006810B1 /* BetterSafariView */, ); productName = Torrenter; productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */; @@ -563,6 +575,7 @@ 0C7376EE28A97D1400D60918 /* XCRemoteSwiftPackageReference "SwiftUIX" */, 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, 0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, + 0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */, ); productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */; projectDirPath = ""; @@ -621,6 +634,7 @@ 0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */, 0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */, 0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */, + 0CD72E17293D9928001A7EA4 /* Array.swift in Sources */, 0C44E2A828D4DDDC007711AE /* Application.swift in Sources */, 0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */, 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */, @@ -631,6 +645,7 @@ 0C794B69289DACC800DD1CC8 /* InstalledSourceButtonView.swift in Sources */, 0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */, 0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */, + 0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */, 0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */, 0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */, 0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */, @@ -654,7 +669,7 @@ 0CA148E2288903F000DE2211 /* Data.swift in Sources */, 0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */, 0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */, - 0C42B5982932F6DD008057A0 /* Array.swift in Sources */, + 0C42B5982932F6DD008057A0 /* Set.swift in Sources */, 0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */, 0CA05459288EE9E600850554 /* SourceManager.swift in Sources */, 0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */, @@ -666,6 +681,7 @@ 0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */, 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */, 0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */, + 0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */, 0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */, 0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */, 0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */, @@ -951,6 +967,14 @@ kind = branch; }; }; + 0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/stleamist/BetterSafariView"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -989,6 +1013,11 @@ package = 0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; productName = Introspect; }; + 0CDDDE042935235E006810B1 /* BetterSafariView */ = { + isa = XCSwiftPackageProductDependency; + package = 0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */; + productName = BetterSafariView; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Ferrite/API/AllDebridWrapper.swift b/Ferrite/API/AllDebridWrapper.swift index 8e79f5f..8a868c6 100644 --- a/Ferrite/API/AllDebridWrapper.swift +++ b/Ferrite/API/AllDebridWrapper.swift @@ -49,7 +49,7 @@ public class AllDebrid { authTask = Task { var count = 0 - while count < 20 { + while count < 12 { if Task.isCancelled { throw ADError.AuthQuery(description: "Token request cancelled.") } @@ -177,8 +177,8 @@ public class AllDebrid { return rawResponse.link } - public func instantAvailability(hashes: [String]) async throws -> [IA] { - let queryItems = hashes.map { URLQueryItem(name: "magnets[]", value: $0) } + public func instantAvailability(magnets: [Magnet]) async throws -> [IA] { + let queryItems = magnets.map { URLQueryItem(name: "magnets[]", value: $0.hash) } var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/instant", queryItems: queryItems)) let data = try await performRequest(request: &request, requestName: #function) diff --git a/Ferrite/API/PremiumizeWrapper.swift b/Ferrite/API/PremiumizeWrapper.swift new file mode 100644 index 0000000..d0a0023 --- /dev/null +++ b/Ferrite/API/PremiumizeWrapper.swift @@ -0,0 +1,158 @@ +// +// PremiumizeWrapper.swift +// Ferrite +// +// Created by Brian Dashore on 11/28/22. +// + +import Foundation +import KeychainSwift + +public class Premiumize { + let jsonDecoder = JSONDecoder() + let keychain = KeychainSwift() + + let baseAuthUrl = "https://www.premiumize.me/authorize" + let baseApiUrl = "https://www.premiumize.me/api" + let clientId = "791565696" + + public func buildAuthUrl() throws -> URL { + var urlComponents = URLComponents(string: baseAuthUrl)! + urlComponents.queryItems = [ + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "response_type", value: "token"), + URLQueryItem(name: "state", value: UUID().uuidString) + ] + + if let url = urlComponents.url { + return url + } else { + throw PMError.InvalidUrl + } + } + + public func handleAuthCallback(url: URL) throws { + let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) + + guard let callbackFragment = callbackComponents?.fragment else { + throw PMError.InvalidResponse + } + + var fragmentComponents = URLComponents() + fragmentComponents.query = callbackFragment + + guard let accessToken = fragmentComponents.queryItems?.first(where: { $0.name == "access_token" })?.value else { + throw PMError.InvalidToken + } + + keychain.set(accessToken, forKey: "Premiumize.AccessToken") + } + + // Clears tokens. No endpoint to deregister a device + public func deleteTokens() { + keychain.delete("Premiumize.AccessToken") + } + + // Wrapper request function which matches the responses and returns data + @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { + guard let token = keychain.get("Premiumize.AccessToken") else { + throw PMError.InvalidToken + } + + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let response = response as? HTTPURLResponse else { + throw PMError.FailedRequest(description: "No HTTP response given") + } + + if response.statusCode >= 200, response.statusCode <= 299 { + return data + } else if response.statusCode == 401 { + deleteTokens() + throw PMError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.") + } else { + throw PMError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") + } + } + + // Parent function for initial checking of the cache + public func checkCache(magnets: [Magnet]) async throws -> [Magnet] { + var urlComponents = URLComponents(string: "\(baseApiUrl)/cache/check")! + urlComponents.queryItems = magnets.map { URLQueryItem(name: "items[]", value: $0.hash) } + guard let url = urlComponents.url else { + throw PMError.InvalidUrl + } + var request = URLRequest(url: url) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(CacheCheckResponse.self, from: data) + + if rawResponse.response.isEmpty { + throw PMError.EmptyData + } else { + let availableMagnets = magnets.enumerated().compactMap { index, magnet in + if rawResponse.response[safe: index] == true { + return magnet + } else { + return nil + } + } + + return availableMagnets + } + } + + // Function to divide and execute DDL endpoint requests in parallel + // Calls this for 10 requests at a time to not overwhelm API servers + public func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [IA] { + let tempIA = try await withThrowingTaskGroup(of: Premiumize.IA.self) { group in + for magnet in magnetChunk { + group.addTask { + try await self.fetchDDL(magnet: magnet) + } + } + + var chunkedIA: [Premiumize.IA] = [] + for try await ia in group { + chunkedIA.append(ia) + } + return chunkedIA + } + + return tempIA + } + + // Grabs DDL links + func fetchDDL(magnet: Magnet) async throws -> IA { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var bodyComponents = URLComponents() + bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnet.link)] + + request.httpBody = bodyComponents.query?.data(using: .utf8) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(DDLResponse.self, from: data) + + if !rawResponse.content.isEmpty { + let files = rawResponse.content.map { file in + IAFile( + name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path, + streamUrlString: file.link + ) + } + + return IA( + hash: magnet.hash, + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: files + ) + } else { + throw PMError.EmptyData + } + } +} diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index c49f65d..ad6d06f 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -60,7 +60,7 @@ public class RealDebrid { authTask = Task { var count = 0 - while count < 20 { + while count < 12 { if Task.isCancelled { throw RDError.AuthQuery(description: "Token request cancelled.") } @@ -186,9 +186,9 @@ public class RealDebrid { // Checks if the magnet is streamable on RD // Currently does not work for batch links - public func instantAvailability(magnetHashes: [String]) async throws -> [IA] { + public func instantAvailability(magnets: [Magnet]) async throws -> [IA] { var availableHashes: [RealDebrid.IA] = [] - var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnetHashes.joined(separator: "/"))")!) + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnets.map(\.hash).joined(separator: "/"))")!) let data = try await performRequest(request: &request, requestName: #function) diff --git a/Ferrite/Extensions/Array.swift b/Ferrite/Extensions/Array.swift index b4ca668..ec92c34 100644 --- a/Ferrite/Extensions/Array.swift +++ b/Ferrite/Extensions/Array.swift @@ -2,25 +2,16 @@ // Array.swift // Ferrite // -// Created by Brian Dashore on 11/26/22. +// Created by Brian Dashore on 12/4/22. // import Foundation -extension Set: RawRepresentable where Element: Codable { - public init?(rawValue: String) { - guard let data = rawValue.data(using: .utf8), - let result = try? JSONDecoder().decode(Set.self, from: data) - else { return nil } - self = result - } - - public var rawValue: String { - guard let data = try? JSONEncoder().encode(self), - let result = String(data: data, encoding: .utf8) - else { - return "[]" +extension Array { + // From https://www.hackingwithswift.com/example-code/language/how-to-split-an-array-into-chunks + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) } - return result } } diff --git a/Ferrite/Extensions/Set.swift b/Ferrite/Extensions/Set.swift new file mode 100644 index 0000000..b4ca668 --- /dev/null +++ b/Ferrite/Extensions/Set.swift @@ -0,0 +1,26 @@ +// +// Array.swift +// Ferrite +// +// Created by Brian Dashore on 11/26/22. +// + +import Foundation + +extension Set: RawRepresentable where Element: Codable { + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode(Set.self, from: data) + else { return nil } + self = result + } + + public var rawValue: String { + guard let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) + else { + return "[]" + } + return result + } +} diff --git a/Ferrite/Info.plist b/Ferrite/Info.plist index a289566..612dfc7 100644 --- a/Ferrite/Info.plist +++ b/Ferrite/Info.plist @@ -15,6 +15,19 @@ + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + Ferrite + CFBundleURLSchemes + + ferrite:// + + + NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/Ferrite/Models/DebridManagerModels.swift b/Ferrite/Models/DebridManagerModels.swift index 1280222..0cdf395 100644 --- a/Ferrite/Models/DebridManagerModels.swift +++ b/Ferrite/Models/DebridManagerModels.swift @@ -20,6 +20,7 @@ public enum IAStatus: Codable, Hashable, Sendable { public enum DebridType: Int, Codable, Hashable, CaseIterable { case realDebrid = 1 case allDebrid = 2 + case premiumize = 3 func toString(abbreviated: Bool = false) -> String { switch self { @@ -27,6 +28,14 @@ public enum DebridType: Int, Codable, Hashable, CaseIterable { return abbreviated ? "RD" : "RealDebrid" case .allDebrid: return abbreviated ? "AD" : "AllDebrid" + case .premiumize: + return abbreviated ? "PM" : "Premiumize" } } } + +// Wrapper struct for magnet links to contain both the link and hash for easy access +public struct Magnet: Codable, Hashable, Sendable { + let link: String + let hash: String +} diff --git a/Ferrite/Models/PremiumizeModels.swift b/Ferrite/Models/PremiumizeModels.swift new file mode 100644 index 0000000..4a1daca --- /dev/null +++ b/Ferrite/Models/PremiumizeModels.swift @@ -0,0 +1,68 @@ +// +// PremiumizeModels.swift +// Ferrite +// +// Created by Brian Dashore on 11/28/22. +// + +import Foundation + +public extension Premiumize { + // MARK: - Errors + + // TODO: Hybridize debrid errors in one structure + enum PMError: Error { + case InvalidUrl + case InvalidPostBody + case InvalidResponse + case InvalidToken + case EmptyData + case EmptyTorrents + case FailedRequest(description: String) + case AuthQuery(description: String) + } + + // MARK: - CacheCheckResponse + + struct CacheCheckResponse: Codable { + let status: String + let response: [Bool] + } + + // MARK: - DDLResponse + + struct DDLResponse: Codable { + let status: String + let content: [DDLData] + let location: String + let filename: String + let filesize: Int + } + + // MARK: - Content + + struct DDLData: Codable { + let path: String + let size: Int + let link: String + let streamLink: String + + enum CodingKeys: String, CodingKey { + case path, size, link + case streamLink = "stream_link" + } + } + + // MARK: - InstantAvailability client side structures + + struct IA: Codable, Hashable { + let hash: String + let expiryTimeStamp: Double + let files: [IAFile] + } + + struct IAFile: Codable, Hashable { + let name: String + let streamUrlString: String + } +} diff --git a/Ferrite/Models/SearchModels.swift b/Ferrite/Models/SearchModels.swift index 4f7fb59..8ebbe63 100644 --- a/Ferrite/Models/SearchModels.swift +++ b/Ferrite/Models/SearchModels.swift @@ -7,7 +7,7 @@ import Foundation -public struct SearchResult: Hashable, Codable, Sendable { +public struct SearchResult: Codable, Hashable, Sendable { let title: String? let source: String let size: String? diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 6338d02..5db4338 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -14,9 +14,11 @@ public class DebridManager: ObservableObject { var toastModel: ToastViewModel? let realDebrid: RealDebrid = .init() let allDebrid: AllDebrid = .init() + let premiumize: Premiumize = .init() // UI Variables @Published var showWebView: Bool = false + @Published var showAuthSession: Bool = false @Published var showLoadingProgress: Bool = false // Service agnostic variables @@ -34,7 +36,7 @@ public class DebridManager: ObservableObject { var currentDebridTask: Task? var downloadUrl: String = "" - var authUrl: String = "" + var authUrl: URL? // RealDebrid auth variables @Published var realDebridAuthProcessing: Bool = false @@ -57,6 +59,15 @@ public class DebridManager: ObservableObject { var selectedAllDebridItem: AllDebrid.IA? var selectedAllDebridFile: AllDebrid.IAFile? + // Premiumize auth variables + @Published var premiumizeAuthProcessing: Bool = false + + // Premiumize fetch variables + @Published var premiumizeIAValues: [Premiumize.IA] = [] + + var selectedPremiumizeItem: Premiumize.IA? + var selectedPremiumizeFile: Premiumize.IAFile? + init() { if let rawDebridList = UserDefaults.standard.string(forKey: "Debrid.EnabledArray"), let serializedDebridList = Set(rawValue: rawDebridList) @@ -88,45 +99,69 @@ public class DebridManager: ObservableObject { enabledDebrids.insert(.allDebrid) UserDefaults.standard.set(false, forKey: "AllDebrid.Enabled") } + + let premiumizeEnabled = UserDefaults.standard.bool(forKey: "Premiumize.Enabled") + if premiumizeEnabled { + enabledDebrids.insert(.premiumize) + UserDefaults.standard.set(false, forKey: "Premiumize.Enabled") + } } // Common function to populate hashes for debrid services - public func populateDebridHashes(_ resultHashes: [String]) async { + public func populateDebridIA(_ resultMagnets: [Magnet]) async { do { let now = Date() // If a hash isn't found in the IA, update it // If the hash is expired, remove it and update it - let sendHashes = resultHashes.filter { hash in - if let IAIndex = realDebridIAValues.firstIndex(where: { $0.hash == hash }), enabledDebrids.contains(.realDebrid) { + let sendMagnets = resultMagnets.filter { magnet in + if let IAIndex = realDebridIAValues.firstIndex(where: { $0.hash == magnet.hash }), enabledDebrids.contains(.realDebrid) { if now.timeIntervalSince1970 > realDebridIAValues[IAIndex].expiryTimeStamp { realDebridIAValues.remove(at: IAIndex) return true } else { return false } - } else if let IAIndex = allDebridIAValues.firstIndex(where: { $0.hash == hash }), enabledDebrids.contains(.allDebrid) { + } else if let IAIndex = allDebridIAValues.firstIndex(where: { $0.hash == magnet.hash }), enabledDebrids.contains(.allDebrid) { if now.timeIntervalSince1970 > allDebridIAValues[IAIndex].expiryTimeStamp { allDebridIAValues.remove(at: IAIndex) return true } else { return false } + } else if let IAIndex = premiumizeIAValues.firstIndex(where: { $0.hash == magnet.hash }), enabledDebrids.contains(.premiumize) { + if now.timeIntervalSince1970 > premiumizeIAValues[IAIndex].expiryTimeStamp { + premiumizeIAValues.remove(at: IAIndex) + return true + } else { + return false + } } else { return true } } - if !sendHashes.isEmpty { + if !sendMagnets.isEmpty { if enabledDebrids.contains(.realDebrid) { - let fetchedRealDebridIA = try await realDebrid.instantAvailability(magnetHashes: sendHashes) + let fetchedRealDebridIA = try await realDebrid.instantAvailability(magnets: sendMagnets) realDebridIAValues += fetchedRealDebridIA } if enabledDebrids.contains(.allDebrid) { - let fetchedAllDebridIA = try await allDebrid.instantAvailability(hashes: sendHashes) + let fetchedAllDebridIA = try await allDebrid.instantAvailability(magnets: sendMagnets) allDebridIAValues += fetchedAllDebridIA } + + if enabledDebrids.contains(.premiumize) { + let availableMagnets = try await premiumize.checkCache(magnets: sendMagnets) + + // Split DDL requests into chunks of 10 + for chunk in availableMagnets.chunked(into: 10) { + let tempIA = try await premiumize.divideDDLRequests(magnetChunk: chunk) + + premiumizeIAValues += tempIA + } + } } } catch { let error = error as NSError @@ -166,6 +201,16 @@ public class DebridManager: ObservableObject { } else { return .full } + case .premiumize: + guard let premiumizeMatch = premiumizeIAValues.first(where: { result.magnetHash == $0.hash }) else { + return .none + } + + if premiumizeMatch.files.count > 1 { + return .partial + } else { + return .full + } case .none: return .none } @@ -194,6 +239,14 @@ public class DebridManager: ObservableObject { toastModel?.updateToastDescription("Could not find the associated AllDebrid entry for magnet hash \(magnetHash)") return false } + case .premiumize: + if let premiumizeItem = premiumizeIAValues.first(where: { magnetHash == $0.hash }) { + selectedPremiumizeItem = premiumizeItem + return true + } else { + toastModel?.updateToastDescription("Could not find the associated Premiumize entry for magnet hash \(magnetHash)") + return false + } case .none: return false } @@ -205,50 +258,129 @@ public class DebridManager: ObservableObject { public func authenticateDebrid(debridType: DebridType) async { switch debridType { case .realDebrid: - await authenticateRd() - enabledDebrids.insert(.realDebrid) + let success = await authenticateRd() + completeDebridAuth(debridType, success: success) case .allDebrid: - await authenticateAd() - enabledDebrids.insert(.allDebrid) - } - - // Automatically sets the preferred debrid service if only one login is provided - if enabledDebrids.count == 1 { - selectedDebridType = enabledDebrids.first + let success = await authenticateAd() + completeDebridAuth(debridType, success: success) + case .premiumize: + await authenticatePm() } } - private func authenticateRd() async { + // Callback to finish debrid auth since functions can be split + func completeDebridAuth(_ debridType: DebridType, success: Bool = true) { + if enabledDebrids.count == 1, success { + print("Enabled debrids is 1!") + selectedDebridType = enabledDebrids.first + } + + switch debridType { + case .realDebrid: + realDebridAuthProcessing = false + case .allDebrid: + allDebridAuthProcessing = false + case .premiumize: + premiumizeAuthProcessing = false + } + } + + // Wrapper function to validate and present an auth URL to the user + @discardableResult func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool { + guard let url else { + toastModel?.updateToastDescription("Authentication Error: Invalid URL created: \(String(describing: url))") + return false + } + + authUrl = url + if useAuthSession { + showAuthSession.toggle() + } else { + showWebView.toggle() + } + + return true + } + + private func authenticateRd() async -> Bool { do { realDebridAuthProcessing = true let verificationResponse = try await realDebrid.getVerificationInfo() - authUrl = verificationResponse.directVerificationURL - showWebView.toggle() + if validateAuthUrl(URL(string: verificationResponse.directVerificationURL)) { + try await realDebrid.getDeviceCredentials(deviceCode: verificationResponse.deviceCode) + enabledDebrids.insert(.realDebrid) + } else { + throw RealDebrid.RDError.AuthQuery(description: "The verification URL was invalid") + } - try await realDebrid.getDeviceCredentials(deviceCode: verificationResponse.deviceCode) + return true } catch { toastModel?.updateToastDescription("RealDebrid authentication error: \(error)") realDebrid.authTask?.cancel() print("RealDebrid authentication error: \(error)") + + return false } } - private func authenticateAd() async { + private func authenticateAd() async -> Bool { do { allDebridAuthProcessing = true let pinResponse = try await allDebrid.getPinInfo() - authUrl = pinResponse.userURL - showWebView.toggle() + if validateAuthUrl(URL(string: pinResponse.userURL)) { + try await allDebrid.getApiKey(checkID: pinResponse.check, pin: pinResponse.pin) + enabledDebrids.insert(.allDebrid) + } else { + throw AllDebrid.ADError.AuthQuery(description: "The PIN URL was invalid") + } - try await allDebrid.getApiKey(checkID: pinResponse.check, pin: pinResponse.pin) + return true } catch { toastModel?.updateToastDescription("AllDebrid authentication error: \(error)") allDebrid.authTask?.cancel() print("AllDebrid authentication error: \(error)") + + return false + } + } + + private func authenticatePm() async { + do { + premiumizeAuthProcessing = true + let tempAuthUrl = try premiumize.buildAuthUrl() + + validateAuthUrl(tempAuthUrl, useAuthSession: true) + } catch { + toastModel?.updateToastDescription("Premiumize authentication error: \(error)") + completeDebridAuth(.premiumize, success: false) + + print("Premiumize authentication error (auth): \(error)") + } + } + + // Currently handles Premiumize callback + public func handleCallback(url: URL?, error: Error?) { + do { + if let error { + throw Premiumize.PMError.AuthQuery(description: "OAuth callback Error: \(error)") + } + + if let callbackUrl = url { + try premiumize.handleAuthCallback(url: callbackUrl) + enabledDebrids.insert(.premiumize) + completeDebridAuth(.premiumize) + } else { + throw Premiumize.PMError.AuthQuery(description: "The callback URL was invalid") + } + } catch { + toastModel?.updateToastDescription("Premiumize authentication error: \(error)") + completeDebridAuth(.premiumize, success: false) + + print("Premiumize authentication error (callback): \(error)") } } @@ -261,6 +393,8 @@ public class DebridManager: ObservableObject { await logoutRd() case .allDebrid: logoutAd() + case .premiumize: + logoutPm() } // Automatically resets the preferred debrid service if it was set to the logged out service @@ -273,7 +407,6 @@ public class DebridManager: ObservableObject { do { try await realDebrid.deleteTokens() enabledDebrids.remove(.realDebrid) - realDebridAuthProcessing = false } catch { toastModel?.updateToastDescription("RealDebrid logout error: \(error)") @@ -284,11 +417,15 @@ public class DebridManager: ObservableObject { private func logoutAd() { allDebrid.deleteTokens() enabledDebrids.remove(.allDebrid) - allDebridAuthProcessing = false toastModel?.updateToastDescription("Please manually delete the AllDebrid API key", newToastType: .info) } + private func logoutPm() { + premiumize.deleteTokens() + enabledDebrids.remove(.premiumize) + } + // MARK: - Debrid fetch UI linked functions // Common function to delegate what debrid service to fetch from @@ -300,7 +437,8 @@ public class DebridManager: ObservableObject { showLoadingProgress = true - guard let magnetLink = searchResult.magnetLink else { + // Premiumize doesn't need a magnet link + guard let magnetLink = searchResult.magnetLink, selectedDebridType == .premiumize else { toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.") print("Debrid error: Invalid magnet link") @@ -312,14 +450,14 @@ public class DebridManager: ObservableObject { await fetchRdDownload(magnetLink: magnetLink) case .allDebrid: await fetchAdDownload(magnetLink: magnetLink) + case .premiumize: + fetchPmDownload() case .none: break } } func fetchRdDownload(magnetLink: String) async { - print("Called RD Download function!") - do { var fileIds: [Int] = [] @@ -416,4 +554,22 @@ public class DebridManager: ObservableObject { } } } + + func fetchPmDownload() { + guard let premiumizeItem = selectedPremiumizeItem else { + toastModel?.updateToastDescription("Could not run your action because the result is invalid") + print("Premiumize download error: Invalid selected Premiumize item") + + return + } + + if let premiumizeFile = selectedPremiumizeFile { + downloadUrl = premiumizeFile.streamUrlString + } else if let firstFile = premiumizeItem.files[safe: 0] { + downloadUrl = firstFile.streamUrlString + } else { + toastModel?.updateToastDescription("Could not run your action because the result could not be found") + print("Premiumize download error: Could not find the selected Premiumize file") + } + } } diff --git a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift index 85cf526..10ad01d 100644 --- a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift +++ b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift @@ -54,8 +54,14 @@ struct BookmarksView: View { .onAppear { if debridManager.enabledDebrids.count > 0 { viewTask = Task { - let hashes = bookmarks.compactMap(\.magnetHash) - await debridManager.populateDebridHashes(hashes) + let magnets = bookmarks.compactMap { + if let magnetLink = $0.magnetLink, let magnetHash = $0.magnetHash { + return Magnet(link: magnetLink, hash: magnetHash) + } else { + return nil + } + } + await debridManager.populateDebridIA(magnets) } } } diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift index 10072bd..cceec97 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift @@ -37,6 +37,10 @@ struct SearchResultInfoView: View { if debridManager.selectedDebridType == .allDebrid { DebridLabelView(result: result, debridAbbreviation: "AD") } + + if debridManager.selectedDebridType == .premiumize { + DebridLabelView(result: result, debridAbbreviation: "PM") + } } .font(.caption) } diff --git a/Ferrite/Views/ContentView.swift b/Ferrite/Views/ContentView.swift index 3d7c495..48ae107 100644 --- a/Ferrite/Views/ContentView.swift +++ b/Ferrite/Views/ContentView.swift @@ -90,9 +90,14 @@ struct ContentView: View { debridManager.realDebridIAValues = [] debridManager.allDebridIAValues = [] - await debridManager.populateDebridHashes( - scrapingModel.searchResults.compactMap(\.magnetHash) - ) + let magnets = scrapingModel.searchResults.compactMap { + if let magnetLink = $0.magnetLink, let magnetHash = $0.magnetHash { + return Magnet(link: magnetLink, hash: magnetHash) + } else { + return nil + } + } + await debridManager.populateDebridIA(magnets) } navModel.showSearchProgress = false @@ -109,6 +114,8 @@ struct ContentView: View { } .introspectSearchController { searchController in searchController.hidesNavigationBarDuringPresentation = false + searchController.searchBar.autocorrectionType = .no + searchController.searchBar.autocapitalizationType = .none } .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { diff --git a/Ferrite/Views/RepresentableViews/WebView.swift b/Ferrite/Views/RepresentableViews/WebView.swift index 9ef07fd..e903622 100644 --- a/Ferrite/Views/RepresentableViews/WebView.swift +++ b/Ferrite/Views/RepresentableViews/WebView.swift @@ -12,7 +12,11 @@ struct WebView: UIViewRepresentable { var url: URL func makeUIView(context: Context) -> WKWebView { - let webView = WKWebView() + // Make the WebView ephemeral + let config = WKWebViewConfiguration() + config.websiteDataStore = WKWebsiteDataStore.nonPersistent() + + let webView = WKWebView(frame: .zero, configuration: config) let _ = webView.load(URLRequest(url: url)) return webView } diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index b39b316..a985c5a 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -5,6 +5,7 @@ // Created by Brian Dashore on 7/11/22. // +import BetterSafariView import Introspect import SwiftUI @@ -47,7 +48,7 @@ struct SettingsView: View { Task { if debridManager.enabledDebrids.contains(.allDebrid) { await debridManager.logoutDebrid(debridType: .allDebrid) - } else if !debridManager.realDebridAuthProcessing { + } else if !debridManager.allDebridAuthProcessing { await debridManager.authenticateDebrid(debridType: .allDebrid) } } @@ -56,6 +57,23 @@ struct SettingsView: View { .foregroundColor(debridManager.enabledDebrids.contains(.allDebrid) ? .red : .blue) } } + + HStack { + Text("Premiumize") + Spacer() + Button { + Task { + if debridManager.enabledDebrids.contains(.premiumize) { + await debridManager.logoutDebrid(debridType: .premiumize) + } else if !debridManager.premiumizeAuthProcessing { + await debridManager.authenticateDebrid(debridType: .premiumize) + } + } + } label: { + Text(debridManager.enabledDebrids.contains(.premiumize) ? "Logout" : (debridManager.premiumizeAuthProcessing ? "Processing" : "Login")) + .foregroundColor(debridManager.enabledDebrids.contains(.premiumize) ? .red : .blue) + } + } } Section(header: Text("Source management")) { @@ -132,7 +150,16 @@ struct SettingsView: View { } } .sheet(isPresented: $debridManager.showWebView) { - LoginWebView(url: URL(string: debridManager.authUrl)!) + LoginWebView(url: debridManager.authUrl ?? URL(string: "https://google.com")!) + } + .webAuthenticationSession(isPresented: $debridManager.showAuthSession) { + WebAuthenticationSession( + url: debridManager.authUrl ?? URL(string: "https://google.com")!, + callbackURLScheme: "ferrite" + ) { callbackURL, error in + debridManager.handleCallback(url: callbackURL, error: error) + } + .prefersEphemeralWebBrowserSession(false) } .navigationTitle("Settings") } diff --git a/Ferrite/Views/SheetViews/BatchChoiceView.swift b/Ferrite/Views/SheetViews/BatchChoiceView.swift index 9385702..d527ba6 100644 --- a/Ferrite/Views/SheetViews/BatchChoiceView.swift +++ b/Ferrite/Views/SheetViews/BatchChoiceView.swift @@ -34,6 +34,14 @@ struct BatchChoiceView: View { queueCommonDownload(fileName: file.fileName) } } + case .premiumize: + ForEach(debridManager.selectedPremiumizeItem?.files ?? [], id: \.self) { file in + Button(file.name) { + debridManager.selectedPremiumizeFile = file + + queueCommonDownload(fileName: file.name) + } + } case .none: EmptyView() } @@ -78,11 +86,14 @@ struct BatchChoiceView: View { switch debridManager.selectedDebridType { case .realDebrid: - debridManager.selectedAllDebridFile = nil - debridManager.selectedAllDebridItem = nil - case .allDebrid: debridManager.selectedRealDebridFile = nil debridManager.selectedRealDebridItem = nil + case .allDebrid: + debridManager.selectedAllDebridFile = nil + debridManager.selectedAllDebridItem = nil + case .premiumize: + debridManager.selectedPremiumizeFile = nil + debridManager.selectedPremiumizeItem = nil case .none: break } diff --git a/Ferrite/Views/SheetViews/MagnetChoiceView.swift b/Ferrite/Views/SheetViews/MagnetChoiceView.swift index dd3c1d3..0bf9cfb 100644 --- a/Ferrite/Views/SheetViews/MagnetChoiceView.swift +++ b/Ferrite/Views/SheetViews/MagnetChoiceView.swift @@ -38,7 +38,7 @@ struct MagnetChoiceView: View { } if !debridManager.downloadUrl.isEmpty { - Section(header: "Real Debrid options") { + Section(header: "Debrid options") { ListRowButtonView("Play on Outplayer", systemImage: "arrow.up.forward.app.fill") { navModel.runDebridAction(urlString: debridManager.downloadUrl, .outplayer) }