From 06d4f8e84e7aa179cb9cac942b87be7014ab5477 Mon Sep 17 00:00:00 2001 From: kingbri Date: Fri, 25 Nov 2022 14:41:54 -0500 Subject: [PATCH 01/19] RealDebrid, Github: Reorganize models Prep for more debrid services Signed-off-by: kingbri --- Ferrite/API/AllDebridWrapper.swift | 8 + Ferrite/API/GithubWrapper.swift | 8 +- Ferrite/API/RealDebridWrapper.swift | 55 ++- Ferrite/Models/GithubModels.swift | 14 +- Ferrite/Models/RealDebridModels.swift | 350 +++++++++--------- Ferrite/ViewModels/DebridManager.swift | 10 +- .../SettingsAppVersionView.swift | 2 +- 7 files changed, 230 insertions(+), 217 deletions(-) create mode 100644 Ferrite/API/AllDebridWrapper.swift diff --git a/Ferrite/API/AllDebridWrapper.swift b/Ferrite/API/AllDebridWrapper.swift new file mode 100644 index 0000000..9d9459c --- /dev/null +++ b/Ferrite/API/AllDebridWrapper.swift @@ -0,0 +1,8 @@ +// +// AllDebridWrapper.swift +// Ferrite +// +// Created by Brian Dashore on 11/25/22. +// + +import Foundation diff --git a/Ferrite/API/GithubWrapper.swift b/Ferrite/API/GithubWrapper.swift index 294164e..5d77b7d 100644 --- a/Ferrite/API/GithubWrapper.swift +++ b/Ferrite/API/GithubWrapper.swift @@ -8,21 +8,21 @@ import Foundation public class Github { - public func fetchLatestRelease() async throws -> GithubRelease? { + public func fetchLatestRelease() async throws -> Release? { let url = URL(string: "https://api.github.com/repos/bdashore3/Ferrite/releases/latest")! let (data, _) = try await URLSession.shared.data(from: url) - let rawResponse = try JSONDecoder().decode(GithubRelease.self, from: data) + let rawResponse = try JSONDecoder().decode(Release.self, from: data) return rawResponse } - public func fetchReleases() async throws -> [GithubRelease]? { + public func fetchReleases() async throws -> [Release]? { let url = URL(string: "https://api.github.com/repos/bdashore3/Ferrite/releases")! let (data, _) = try await URLSession.shared.data(from: url) - let rawResponse = try JSONDecoder().decode([GithubRelease].self, from: data) + let rawResponse = try JSONDecoder().decode([Release].self, from: data) return rawResponse } } diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index ecefd2a..92bd60a 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -8,17 +8,6 @@ import Foundation import KeychainSwift -public enum RealDebridError: Error { - case InvalidUrl - case InvalidPostBody - case InvalidResponse - case InvalidToken - case EmptyData - case EmptyTorrents - case FailedRequest(description: String) - case AuthQuery(description: String) -} - public class RealDebrid { let jsonDecoder = JSONDecoder() let keychain = KeychainSwift() @@ -38,7 +27,7 @@ public class RealDebrid { ] guard let url = urlComponents.url else { - throw RealDebridError.InvalidUrl + throw RDError.InvalidUrl } let request = URLRequest(url: url) @@ -49,7 +38,7 @@ public class RealDebrid { return rawResponse } catch { print("Couldn't get the new client creds!") - throw RealDebridError.AuthQuery(description: error.localizedDescription) + throw RDError.AuthQuery(description: error.localizedDescription) } } @@ -62,7 +51,7 @@ public class RealDebrid { ] guard let url = urlComponents.url else { - throw RealDebridError.InvalidUrl + throw RDError.InvalidUrl } let request = URLRequest(url: url) @@ -73,7 +62,7 @@ public class RealDebrid { while count < 20 { if Task.isCancelled { - throw RealDebridError.AuthQuery(description: "Token request cancelled.") + throw RDError.AuthQuery(description: "Token request cancelled.") } let (data, _) = try await URLSession.shared.data(for: request) @@ -95,7 +84,7 @@ public class RealDebrid { } } - throw RealDebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.") + throw RDError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.") } if case let .failure(error) = await authTask?.result { @@ -106,11 +95,11 @@ public class RealDebrid { // Fetch all tokens for the user and store in keychain public func getTokens(deviceCode: String) async throws { guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else { - throw RealDebridError.EmptyData + throw RDError.EmptyData } guard let clientSecret = keychain.get("RealDebrid.ClientSecret") else { - throw RealDebridError.EmptyData + throw RDError.EmptyData } var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!) @@ -174,7 +163,7 @@ public class RealDebrid { // Wrapper request function which matches the responses and returns data @discardableResult public func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { guard let token = await fetchToken() else { - throw RealDebridError.InvalidToken + throw RDError.InvalidToken } request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") @@ -182,23 +171,23 @@ public class RealDebrid { let (data, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { - throw RealDebridError.FailedRequest(description: "No HTTP response given") + throw RDError.FailedRequest(description: "No HTTP response given") } if response.statusCode >= 200, response.statusCode <= 299 { return data } else if response.statusCode == 401 { try await deleteTokens() - throw RealDebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.") + throw RDError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.") } else { - throw RealDebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") + throw RDError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") } } // Checks if the magnet is streamable on RD // Currently does not work for batch links - public func instantAvailability(magnetHashes: [String]) async throws -> [RealDebridIA] { - var availableHashes: [RealDebridIA] = [] + public func instantAvailability(magnetHashes: [String]) async throws -> [RealDebrid.IA] { + var availableHashes: [RealDebrid.IA] = [] var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnetHashes.joined(separator: "/"))")!) let data = try await performRequest(request: &request, requestName: #function) @@ -219,17 +208,17 @@ public class RealDebrid { if data.rd.count > 1 || data.rd[0].count > 1 { // Batch array let batches = data.rd.map { fileDict in - let batchFiles: [RealDebridIABatchFile] = fileDict.map { key, value in + let batchFiles: [RealDebrid.IABatchFile] = fileDict.map { key, value in // Force unwrapped ID. Is safe because ID is guaranteed on a successful response - RealDebridIABatchFile(id: Int(key)!, fileName: value.filename) + RealDebrid.IABatchFile(id: Int(key)!, fileName: value.filename) }.sorted(by: { $0.id < $1.id }) - return RealDebridIABatch(files: batchFiles) + return RealDebrid.IABatch(files: batchFiles) } // RD files array // Possibly sort this in the future, but not sure how at the moment - var files: [RealDebridIAFile] = [] + var files: [RealDebrid.IAFile] = [] for index in batches.indices { let batchFiles = batches[index].files @@ -239,7 +228,7 @@ public class RealDebrid { if !files.contains(where: { $0.name == batchFile.fileName }) { files.append( - RealDebridIAFile( + RealDebrid.IAFile( name: batchFile.fileName, batchIndex: index, batchFileIndex: batchFileIndex @@ -251,7 +240,7 @@ public class RealDebrid { // TTL: 5 minutes availableHashes.append( - RealDebridIA( + RealDebrid.IA( hash: hash, expiryTimeStamp: Date().timeIntervalSince1970 + 300, files: files, @@ -260,7 +249,7 @@ public class RealDebrid { ) } else { availableHashes.append( - RealDebridIA( + RealDebrid.IA( hash: hash, expiryTimeStamp: Date().timeIntervalSince1970 + 300 ) @@ -319,9 +308,9 @@ public class RealDebrid { if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" { return torrentLink } else if rawResponse.status == "downloading" || rawResponse.status == "queued" { - throw RealDebridError.EmptyTorrents + throw RDError.EmptyTorrents } else { - throw RealDebridError.EmptyData + throw RDError.EmptyData } } diff --git a/Ferrite/Models/GithubModels.swift b/Ferrite/Models/GithubModels.swift index fb4a066..242d5e0 100644 --- a/Ferrite/Models/GithubModels.swift +++ b/Ferrite/Models/GithubModels.swift @@ -7,12 +7,14 @@ import Foundation -public struct GithubRelease: Codable, Hashable, Sendable { - let htmlUrl: String - let tagName: String +extension Github { + public struct Release: Codable, Hashable, Sendable { + let htmlUrl: String + let tagName: String - enum CodingKeys: String, CodingKey { - case htmlUrl = "html_url" - case tagName = "tag_name" + enum CodingKeys: String, CodingKey { + case htmlUrl = "html_url" + case tagName = "tag_name" + } } } diff --git a/Ferrite/Models/RealDebridModels.swift b/Ferrite/Models/RealDebridModels.swift index b58de72..114a202 100644 --- a/Ferrite/Models/RealDebridModels.swift +++ b/Ferrite/Models/RealDebridModels.swift @@ -8,187 +8,201 @@ import Foundation -// MARK: - device code endpoint - -public struct DeviceCodeResponse: Codable, Sendable { - let deviceCode, userCode: String - let interval, expiresIn: Int - let verificationURL, directVerificationURL: String - - enum CodingKeys: String, CodingKey { - case deviceCode = "device_code" - case userCode = "user_code" - case interval - case expiresIn = "expires_in" - case verificationURL = "verification_url" - case directVerificationURL = "direct_verification_url" +extension RealDebrid { + // MARK: - Errors + public enum RDError: Error { + case InvalidUrl + case InvalidPostBody + case InvalidResponse + case InvalidToken + case EmptyData + case EmptyTorrents + case FailedRequest(description: String) + case AuthQuery(description: String) } -} -// MARK: - device credentials endpoint + // MARK: - device code endpoint -public struct DeviceCredentialsResponse: Codable, Sendable { - let clientID, clientSecret: String? + public struct DeviceCodeResponse: Codable, Sendable { + let deviceCode, userCode: String + let interval, expiresIn: Int + let verificationURL, directVerificationURL: String - enum CodingKeys: String, CodingKey { - case clientID = "client_id" - case clientSecret = "client_secret" + enum CodingKeys: String, CodingKey { + case deviceCode = "device_code" + case userCode = "user_code" + case interval + case expiresIn = "expires_in" + case verificationURL = "verification_url" + case directVerificationURL = "direct_verification_url" + } } -} -// MARK: - token endpoint + // MARK: - device credentials endpoint -public struct TokenResponse: Codable, Sendable { - let accessToken: String - let expiresIn: Int - let refreshToken, tokenType: String + public struct DeviceCredentialsResponse: Codable, Sendable { + let clientID, clientSecret: String? - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case expiresIn = "expires_in" - case refreshToken = "refresh_token" - case tokenType = "token_type" + enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case clientSecret = "client_secret" + } } -} -// MARK: - instantAvailability endpoint + // MARK: - token endpoint -// Thanks Skitty! -public struct InstantAvailabilityResponse: Codable, Sendable { - var data: InstantAvailabilityData? + public struct TokenResponse: Codable, Sendable { + let accessToken: String + let expiresIn: Int + let refreshToken, tokenType: String - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case expiresIn = "expires_in" + case refreshToken = "refresh_token" + case tokenType = "token_type" + } + } - if let data = try? container.decode(InstantAvailabilityData.self) { - self.data = data + // MARK: - instantAvailability endpoint + + // Thanks Skitty! + public struct InstantAvailabilityResponse: Codable, Sendable { + var data: InstantAvailabilityData? + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let data = try? container.decode(InstantAvailabilityData.self) { + self.data = data + } + } + } + + struct InstantAvailabilityData: Codable, Sendable { + var rd: [[String: InstantAvailabilityInfo]] + } + + struct InstantAvailabilityInfo: Codable, Sendable { + var filename: String + var filesize: Int + } + + // MARK: - Instant Availability client side structures + + public struct IA: Codable, Hashable, Sendable { + let hash: String + let expiryTimeStamp: Double + var files: [IAFile] = [] + var batches: [IABatch] = [] + } + + public struct IABatch: Codable, Hashable, Sendable { + let files: [IABatchFile] + } + + public struct IABatchFile: Codable, Hashable, Sendable { + let id: Int + let fileName: String + } + + public struct IAFile: Codable, Hashable, Sendable { + let name: String + let batchIndex: Int + let batchFileIndex: Int + } + + public enum IAStatus: Codable, Hashable, Sendable { + case full + case partial + case none + } + + // MARK: - addMagnet endpoint + + public struct AddMagnetResponse: Codable, Sendable { + let id: String + let uri: String + } + + // MARK: - torrentInfo endpoint + + struct TorrentInfoResponse: Codable, Sendable { + let id, filename, originalFilename, hash: String + let bytes, originalBytes: Int + let host: String + let split, progress: Int + let status, added: String + let files: [TorrentInfoFile] + let links: [String] + let ended: String? + let speed: Int? + let seeders: Int? + + enum CodingKeys: String, CodingKey { + case id, filename + case originalFilename = "original_filename" + case hash, bytes + case originalBytes = "original_bytes" + case host, split, progress, status, added, files, links, ended, speed, seeders + } + } + + struct TorrentInfoFile: Codable, Sendable { + let id: Int + let path: String + let bytes, selected: Int + } + + public struct UserTorrentsResponse: Codable, Sendable { + let id, filename, hash: String + let bytes: Int + let host: String + let split, progress: Int + let status, added: String + let links: [String] + let speed, seeders: Int? + let ended: String? + } + + // MARK: - unrestrictLink endpoint + + struct UnrestrictLinkResponse: Codable, Sendable { + let id, filename: String + let mimeType: String? + let filesize: Int + let link: String + let host: String + let hostIcon: String + let chunks, crc: Int + let download: String + let streamable: Int + + enum CodingKeys: String, CodingKey { + case id, filename, mimeType, filesize, link, host + case hostIcon = "host_icon" + case chunks, crc, download, streamable + } + } + + // MARK: - User downloads list + + public struct UserDownloadsResponse: Codable, Sendable { + let id, filename: String + let mimeType: String? + let filesize: Int + let link: String + let host: String + let hostIcon: String + let chunks: Int + let download: String + let streamable: Int + let generated: String + + enum CodingKeys: String, CodingKey { + case id, filename, mimeType, filesize, link, host + case hostIcon = "host_icon" + case chunks, download, streamable, generated } } } - -// MARK: - Instant Availability client side structures - -struct InstantAvailabilityData: Codable, Sendable { - var rd: [[String: InstantAvailabilityInfo]] -} - -struct InstantAvailabilityInfo: Codable, Sendable { - var filename: String - var filesize: Int -} - -public struct RealDebridIA: Codable, Hashable, Sendable { - let hash: String - let expiryTimeStamp: Double - var files: [RealDebridIAFile] = [] - var batches: [RealDebridIABatch] = [] -} - -public struct RealDebridIABatch: Codable, Hashable, Sendable { - let files: [RealDebridIABatchFile] -} - -public struct RealDebridIABatchFile: Codable, Hashable, Sendable { - let id: Int - let fileName: String -} - -public struct RealDebridIAFile: Codable, Hashable, Sendable { - let name: String - let batchIndex: Int - let batchFileIndex: Int -} - -public enum RealDebridIAStatus: Codable, Hashable, Sendable { - case full - case partial - case none -} - -// MARK: - addMagnet endpoint - -public struct AddMagnetResponse: Codable, Sendable { - let id: String - let uri: String -} - -// MARK: - torrentInfo endpoint - -struct TorrentInfoResponse: Codable, Sendable { - let id, filename, originalFilename, hash: String - let bytes, originalBytes: Int - let host: String - let split, progress: Int - let status, added: String - let files: [TorrentInfoFile] - let links: [String] - let ended: String? - let speed: Int? - let seeders: Int? - - enum CodingKeys: String, CodingKey { - case id, filename - case originalFilename = "original_filename" - case hash, bytes - case originalBytes = "original_bytes" - case host, split, progress, status, added, files, links, ended, speed, seeders - } -} - -struct TorrentInfoFile: Codable, Sendable { - let id: Int - let path: String - let bytes, selected: Int -} - -public struct UserTorrentsResponse: Codable, Sendable { - let id, filename, hash: String - let bytes: Int - let host: String - let split, progress: Int - let status, added: String - let links: [String] - let speed, seeders: Int? - let ended: String? -} - -// MARK: - unrestrictLink endpoint - -struct UnrestrictLinkResponse: Codable, Sendable { - let id, filename: String - let mimeType: String? - let filesize: Int - let link: String - let host: String - let hostIcon: String - let chunks, crc: Int - let download: String - let streamable: Int - - enum CodingKeys: String, CodingKey { - case id, filename, mimeType, filesize, link, host - case hostIcon = "host_icon" - case chunks, crc, download, streamable - } -} - -// MARK: - User downloads list - -public struct UserDownloadsResponse: Codable, Sendable { - let id, filename: String - let mimeType: String? - let filesize: Int - let link: String - let host: String - let hostIcon: String - let chunks: Int - let download: String - let streamable: Int - let generated: String - - enum CodingKeys: String, CodingKey { - case id, filename, mimeType, filesize, link, host - case hostIcon = "host_icon" - case chunks, download, streamable, generated - } -} diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 5758b0d..175694a 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -32,14 +32,14 @@ public class DebridManager: ObservableObject { var realDebridAuthUrl: String = "" // RealDebrid fetch variables - @Published var realDebridIAValues: [RealDebridIA] = [] + @Published var realDebridIAValues: [RealDebrid.IA] = [] var realDebridDownloadUrl: String = "" @Published var showDeleteAlert: Bool = false // TODO: Switch to an individual item based sheet system to remove these variables - var selectedRealDebridItem: RealDebridIA? - var selectedRealDebridFile: RealDebridIAFile? + var selectedRealDebridItem: RealDebrid.IA? + var selectedRealDebridFile: RealDebrid.IAFile? var selectedRealDebridID: String? init() { @@ -81,7 +81,7 @@ public class DebridManager: ObservableObject { } } - public func matchSearchResult(result: SearchResult?) -> RealDebridIAStatus { + public func matchSearchResult(result: SearchResult?) -> RealDebrid.IAStatus { guard let result else { return .none } @@ -202,7 +202,7 @@ public class DebridManager: ObservableObject { } } catch { switch error { - case RealDebridError.EmptyTorrents: + case RealDebrid.RDError.EmptyTorrents: showDeleteAlert.toggle() default: let error = error as NSError diff --git a/Ferrite/Views/SettingsViews/SettingsAppVersionView.swift b/Ferrite/Views/SettingsViews/SettingsAppVersionView.swift index f15f15d..d340ccb 100644 --- a/Ferrite/Views/SettingsViews/SettingsAppVersionView.swift +++ b/Ferrite/Views/SettingsViews/SettingsAppVersionView.swift @@ -11,7 +11,7 @@ struct SettingsAppVersionView: View { @EnvironmentObject var toastModel: ToastViewModel @State private var viewTask: Task? - @State private var releases: [GithubRelease] = [] + @State private var releases: [Github.Release] = [] @State private var loadedReleases = false -- 2.45.2 From 2322d3af67af4c44839ed70a800ba2a67ff715b6 Mon Sep 17 00:00:00 2001 From: kingbri Date: Sun, 27 Nov 2022 18:14:32 -0500 Subject: [PATCH 02/19] Debrid: Decentralize and add AllDebrid support AllDebrid is another debrid provider. Add support to Ferrite in addition to RealDebrid. The overall debrid login backend has changed to accomodate for a more agnostic app structure where more services can be added as needed. Also add some cosmetic changes to search so filters can be added while searching for a phrase. Signed-off-by: kingbri --- Ferrite.xcodeproj/project.pbxproj | 84 ++++-- Ferrite/API/AllDebridWrapper.swift | 195 +++++++++++++ Ferrite/API/RealDebridWrapper.swift | 4 +- Ferrite/Extensions/Array.swift | 26 ++ Ferrite/Extensions/View.swift | 10 + Ferrite/Models/AllDebridModels.swift | 157 ++++++++++ Ferrite/Models/DebridManagerModels.swift | 32 +++ Ferrite/Models/GithubModels.swift | 4 +- Ferrite/Models/RealDebridModels.swift | 44 ++- Ferrite/ViewModels/DebridManager.swift | 268 +++++++++++++++--- Ferrite/ViewModels/ScrapingViewModel.swift | 2 - Ferrite/Views/BatchChoiceView.swift | 69 ----- .../Debrid/DebridChoiceView.swift | 37 +++ .../Debrid/DebridLabelView.swift | 36 +++ .../Library}/BookmarksView.swift | 4 +- .../Library}/HistoryActionsView.swift | 0 .../Library}/HistoryButtonView.swift | 4 +- .../Library}/HistoryView.swift | 0 .../SearchResultButtonView.swift | 19 +- .../SearchResult/SearchResultInfoView.swift | 43 +++ .../Settings}/BackupsView.swift | 0 .../Settings}/DefaultActionsPickerViews.swift | 0 .../Settings}/SettingsAppVersionView.swift | 0 .../Settings}/SettingsSourceListView.swift | 0 .../Settings}/SourceListEditorView.swift | 0 .../Buttons/InstalledSourceButtonView.swift | 0 .../Buttons/SourceCatalogButtonView.swift | 0 .../Buttons/SourceUpdateButtonView.swift | 0 .../Source}/SourceSettingsView.swift | 0 Ferrite/Views/ContentView.swift | 15 +- Ferrite/Views/LibraryView.swift | 5 +- .../SearchResultRDView.swift | 57 ---- Ferrite/Views/SearchResultsView.swift | 1 + Ferrite/Views/SettingsView.swift | 34 ++- .../Views/SheetViews/BatchChoiceView.swift | 100 +++++++ .../{ => SheetViews}/MagnetChoiceView.swift | 16 +- 36 files changed, 1014 insertions(+), 252 deletions(-) create mode 100644 Ferrite/Extensions/Array.swift create mode 100644 Ferrite/Models/AllDebridModels.swift create mode 100644 Ferrite/Models/DebridManagerModels.swift delete mode 100644 Ferrite/Views/BatchChoiceView.swift create mode 100644 Ferrite/Views/ComponentViews/Debrid/DebridChoiceView.swift create mode 100644 Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift rename Ferrite/Views/{LibraryViews => ComponentViews/Library}/BookmarksView.swift (95%) rename Ferrite/Views/{LibraryViews => ComponentViews/Library}/HistoryActionsView.swift (100%) rename Ferrite/Views/{LibraryViews => ComponentViews/Library}/HistoryButtonView.swift (95%) rename Ferrite/Views/{LibraryViews => ComponentViews/Library}/HistoryView.swift (100%) rename Ferrite/Views/{SearchResultViews => ComponentViews/SearchResult}/SearchResultButtonView.swift (88%) create mode 100644 Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift rename Ferrite/Views/{SettingsViews => ComponentViews/Settings}/BackupsView.swift (100%) rename Ferrite/Views/{SettingsViews => ComponentViews/Settings}/DefaultActionsPickerViews.swift (100%) rename Ferrite/Views/{SettingsViews => ComponentViews/Settings}/SettingsAppVersionView.swift (100%) rename Ferrite/Views/{SettingsViews => ComponentViews/Settings}/SettingsSourceListView.swift (100%) rename Ferrite/Views/{SettingsViews => ComponentViews/Settings}/SourceListEditorView.swift (100%) rename Ferrite/Views/{SourceViews => ComponentViews/Source}/Buttons/InstalledSourceButtonView.swift (100%) rename Ferrite/Views/{SourceViews => ComponentViews/Source}/Buttons/SourceCatalogButtonView.swift (100%) rename Ferrite/Views/{SourceViews => ComponentViews/Source}/Buttons/SourceUpdateButtonView.swift (100%) rename Ferrite/Views/{SourceViews => ComponentViews/Source}/SourceSettingsView.swift (100%) delete mode 100644 Ferrite/Views/SearchResultViews/SearchResultRDView.swift create mode 100644 Ferrite/Views/SheetViews/BatchChoiceView.swift rename Ferrite/Views/{ => SheetViews}/MagnetChoiceView.swift (93%) diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index c9689eb..999e7e2 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0167DB29293FA900B65783 /* RealDebridModels.swift */; }; + 0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; }; + 0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; }; 0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; }; 0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */; }; 0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */; }; @@ -20,6 +22,8 @@ 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 */; }; + 0C42B5962932F2D5008057A0 /* DebridChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */; }; + 0C42B5982932F6DD008057A0 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Array.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 */; }; @@ -28,11 +32,13 @@ 0C4CFC4E28970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */; }; 0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */; }; 0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; }; - 0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */; }; + 0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; }; 0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; }; 0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; }; 0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */; }; 0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68135128BC1A7C00FAD890 /* GithubModels.swift */; }; + 0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */; }; + 0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */; }; 0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */; }; 0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */; }; 0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; }; @@ -105,6 +111,8 @@ /* Begin PBXFileReference section */ 0C0167DB29293FA900B65783 /* RealDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = ""; }; + 0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = ""; }; + 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = ""; }; 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = ""; }; 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesView.swift; sourceTree = ""; }; 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAppVersionView.swift; sourceTree = ""; }; @@ -117,6 +125,8 @@ 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 = ""; }; + 0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridChoiceView.swift; sourceTree = ""; }; + 0C42B5972932F6DD008057A0 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.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 = ""; }; @@ -124,9 +134,11 @@ 0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataProperties.swift"; sourceTree = ""; }; 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = ""; }; 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = ""; }; - 0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRDView.swift; sourceTree = ""; }; + 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = ""; }; 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubWrapper.swift; sourceTree = ""; }; 0C68135128BC1A7C00FAD890 /* GithubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubModels.swift; sourceTree = ""; }; + 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridWrapper.swift; sourceTree = ""; }; + 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridModels.swift; sourceTree = ""; }; 0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalContextMenu.swift; sourceTree = ""; }; 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenter.swift; sourceTree = ""; }; 0C733286289C4C820058D1FE /* SourceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsView.swift; sourceTree = ""; }; @@ -213,6 +225,36 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0C0755C22934241F00ECA142 /* SheetViews */ = { + isa = PBXGroup; + children = ( + 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */, + 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */, + ); + path = SheetViews; + sourceTree = ""; + }; + 0C0755C32934244500ECA142 /* ComponentViews */ = { + isa = PBXGroup; + children = ( + 0C0755C42934245800ECA142 /* Debrid */, + 0CA3B23528C265FD00616D3A /* Library */, + 0C44E2AB28D4E126007711AE /* SearchResult */, + 0CA0545C288F7CB200850554 /* Settings */, + 0C794B65289DAC9F00DD1CC8 /* Source */, + ); + path = ComponentViews; + sourceTree = ""; + }; + 0C0755C42934245800ECA142 /* Debrid */ = { + isa = PBXGroup; + children = ( + 0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */, + 0C0755C5293424A200ECA142 /* DebridLabelView.swift */, + ); + path = Debrid; + sourceTree = ""; + }; 0C0D50DE288DF72D0035ECC8 /* Classes */ = { isa = PBXGroup; children = ( @@ -241,7 +283,9 @@ 0C0D50E3288DFE6E0035ECC8 /* Models */ = { isa = PBXGroup; children = ( + 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */, 0C7ED14028D61BBA009E29AD /* BackupModels.swift */, + 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */, 0C68135128BC1A7C00FAD890 /* GithubModels.swift */, 0C0167DB29293FA900B65783 /* RealDebridModels.swift */, 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */, @@ -281,25 +325,25 @@ path = Buttons; sourceTree = ""; }; - 0C44E2AB28D4E126007711AE /* SearchResultViews */ = { + 0C44E2AB28D4E126007711AE /* SearchResult */ = { isa = PBXGroup; children = ( 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */, - 0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */, + 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */, ); - path = SearchResultViews; + path = SearchResult; sourceTree = ""; }; - 0C794B65289DAC9F00DD1CC8 /* SourceViews */ = { + 0C794B65289DAC9F00DD1CC8 /* Source */ = { isa = PBXGroup; children = ( 0C44E2AA28D4E09B007711AE /* Buttons */, 0C733286289C4C820058D1FE /* SourceSettingsView.swift */, ); - path = SourceViews; + path = Source; sourceTree = ""; }; - 0CA0545C288F7CB200850554 /* SettingsViews */ = { + 0CA0545C288F7CB200850554 /* Settings */ = { isa = PBXGroup; children = ( 0C44E2AE28D52E8A007711AE /* BackupsView.swift */, @@ -308,7 +352,7 @@ 0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */, 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */, ); - path = SettingsViews; + path = Settings; sourceTree = ""; }; 0CA148BA288903F000DE2211 /* Ferrite */ = { @@ -357,6 +401,7 @@ 0CA148C8288903F000DE2211 /* Extensions */ = { isa = PBXGroup; children = ( + 0C42B5972932F6DD008057A0 /* Array.swift */, 0CA148C9288903F000DE2211 /* Collection.swift */, 0CA148CA288903F000DE2211 /* Data.swift */, 0CA429F728C5098D000D0610 /* DateFormatter.swift */, @@ -373,12 +418,10 @@ 0CA148EE2889061200DE2211 /* Views */ = { isa = PBXGroup; children = ( - 0CA3B23528C265FD00616D3A /* LibraryViews */, - 0C794B65289DAC9F00DD1CC8 /* SourceViews */, + 0C0755C32934244500ECA142 /* ComponentViews */, 0CA148F02889062700DE2211 /* RepresentableViews */, 0CA148C0288903F000DE2211 /* CommonViews */, - 0C44E2AB28D4E126007711AE /* SearchResultViews */, - 0CA0545C288F7CB200850554 /* SettingsViews */, + 0C0755C22934241F00ECA142 /* SheetViews */, 0CA148D1288903F000DE2211 /* MainView.swift */, 0CA148D4288903F000DE2211 /* ContentView.swift */, 0CA148D3288903F000DE2211 /* SearchResultsView.swift */, @@ -387,8 +430,6 @@ 0CA148BB288903F000DE2211 /* SettingsView.swift */, 0C32FB522890D19D002BD219 /* AboutView.swift */, 0CA148BC288903F000DE2211 /* LoginWebView.swift */, - 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */, - 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */, ); path = Views; sourceTree = ""; @@ -417,13 +458,14 @@ 0CA148F12889066000DE2211 /* API */ = { isa = PBXGroup; children = ( + 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */, 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */, 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */, ); path = API; sourceTree = ""; }; - 0CA3B23528C265FD00616D3A /* LibraryViews */ = { + 0CA3B23528C265FD00616D3A /* Library */ = { isa = PBXGroup; children = ( 0CA3B23828C2660D00616D3A /* BookmarksView.swift */, @@ -431,7 +473,7 @@ 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */, 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */, ); - path = LibraryViews; + path = Library; sourceTree = ""; }; 0CAF1C5F286F5C0D00296F86 = { @@ -562,11 +604,13 @@ 0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */, 0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */, 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */, + 0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */, 0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */, 0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */, 0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */, 0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */, 0CE66B3A28E640D200F69346 /* Backport.swift in Sources */, + 0C42B5962932F2D5008057A0 /* DebridChoiceView.swift in Sources */, 0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */, 0C794B6B289DACF100DD1CC8 /* SourceCatalogButtonView.swift in Sources */, 0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */, @@ -588,6 +632,7 @@ 0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */, 0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */, 0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */, + 0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */, 0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */, 0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */, 0C84F4862895BFED0074B7C9 /* SourceList+CoreDataClass.swift in Sources */, @@ -596,6 +641,8 @@ 0C95D8DA28A55BB6005E22B3 /* SettingsModels.swift in Sources */, 0CA148E3288903F000DE2211 /* Task.swift in Sources */, 0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */, + 0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */, + 0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */, 0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */, 0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */, 0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */, @@ -606,7 +653,8 @@ 0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */, 0CA148E2288903F000DE2211 /* Data.swift in Sources */, 0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */, - 0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */, + 0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */, + 0C42B5982932F6DD008057A0 /* Array.swift in Sources */, 0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */, 0CA05459288EE9E600850554 /* SourceManager.swift in Sources */, 0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */, diff --git a/Ferrite/API/AllDebridWrapper.swift b/Ferrite/API/AllDebridWrapper.swift index 9d9459c..8e79f5f 100644 --- a/Ferrite/API/AllDebridWrapper.swift +++ b/Ferrite/API/AllDebridWrapper.swift @@ -6,3 +6,198 @@ // import Foundation +import KeychainSwift + +// TODO: Fix errors +public class AllDebrid { + let jsonDecoder = JSONDecoder() + let keychain = KeychainSwift() + + let baseApiUrl = "https://api.alldebrid.com/v4" + let appName = "Ferrite" + + var authTask: Task? + + // Fetches information for PIN auth + public func getPinInfo() async throws -> PinResponse { + let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get") + print("Auth URL: \(url)") + 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 + } catch { + print("Couldn't get pin information!") + throw ADError.AuthQuery(description: error.localizedDescription) + } + } + + // Fetches API keys + public func getApiKey(checkID: String, pin: String) async throws { + let queryItems = [ + URLQueryItem(name: "agent", value: appName), + URLQueryItem(name: "check", value: checkID), + URLQueryItem(name: "pin", value: pin) + ] + + let request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems)) + + // Timer to poll AD API for key + authTask = Task { + var count = 0 + + while count < 20 { + if Task.isCancelled { + throw ADError.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(ADResponse.self, from: data).data + + // If there's an API key from the response, end the task successfully + if let apiKeyResponse = rawResponse { + keychain.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey") + + return + } else { + try await Task.sleep(seconds: 5) + count += 1 + } + } + + throw ADError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.") + } + + if case let .failure(error) = await authTask?.result { + throw error + } + } + + // Clears tokens. No endpoint to deregister a device + public func deleteTokens() { + keychain.delete("AllDebrid.ApiKey") + } + + // 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("AllDebrid.ApiKey") else { + throw ADError.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 ADError.FailedRequest(description: "No HTTP response given") + } + + if response.statusCode >= 200, response.statusCode <= 299 { + return data + } else if response.statusCode == 401 { + deleteTokens() + 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).") + } + } + + // Builds a URL for further requests + private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL { + guard var components = URLComponents(string: urlString) else { + throw ADError.InvalidUrl + } + + components.queryItems = [ + URLQueryItem(name: "agent", value: appName) + ] + queryItems + + if let url = components.url { + return url + } else { + throw ADError.InvalidUrl + } + } + + // Adds a magnet link to the user's AD account + public func addMagnet(magnetLink: String) async throws -> Int { + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload")) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var bodyComponents = URLComponents() + bodyComponents.queryItems = [ + URLQueryItem(name: "magnets[]", value: magnetLink) + ] + + request.httpBody = bodyComponents.query?.data(using: .utf8) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data + + if let magnet = rawResponse.magnets[safe: 0] { + return magnet.id + } else { + throw ADError.InvalidResponse + } + } + + public func fetchMagnetStatus(magnetId: Int, selectedIndex: Int?) async throws -> String { + let queryItems = [ + URLQueryItem(name: "id", value: String(magnetId)) + ] + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems)) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data + + // Better to fetch no link at all than the wrong link + if let linkWrapper = rawResponse.magnets.links[safe: selectedIndex ?? -1] { + return linkWrapper.link + } else { + throw ADError.EmptyTorrents + } + } + + public func unlockLink(lockedLink: String) async throws -> String { + let queryItems = [ + URLQueryItem(name: "link", value: lockedLink) + ] + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems)) + print(request) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data + + return rawResponse.link + } + + public func instantAvailability(hashes: [String]) async throws -> [IA] { + let queryItems = hashes.map { URLQueryItem(name: "magnets[]", value: $0) } + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/instant", queryItems: queryItems)) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data + + let filteredMagnets = rawResponse.magnets.filter { $0.instant == true && $0.files != nil } + let availableHashes = filteredMagnets.map { magnetResp in + // Force unwrap is OK here since the filter caught any nil values + let files = magnetResp.files!.enumerated().map { index, magnetFile in + IAFile(id: index, fileName: magnetFile.name) + } + + return IA( + hash: magnetResp.hash, + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: files + ) + } + + return availableHashes + } +} diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index 92bd60a..c49f65d 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -161,7 +161,7 @@ public class RealDebrid { } // Wrapper request function which matches the responses and returns data - @discardableResult public func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { + @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { guard let token = await fetchToken() else { throw RDError.InvalidToken } @@ -186,7 +186,7 @@ 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 -> [RealDebrid.IA] { + public func instantAvailability(magnetHashes: [String]) async throws -> [IA] { var availableHashes: [RealDebrid.IA] = [] var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnetHashes.joined(separator: "/"))")!) diff --git a/Ferrite/Extensions/Array.swift b/Ferrite/Extensions/Array.swift new file mode 100644 index 0000000..b4ca668 --- /dev/null +++ b/Ferrite/Extensions/Array.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/Extensions/View.swift b/Ferrite/Extensions/View.swift index b47adf9..032567f 100644 --- a/Ferrite/Extensions/View.swift +++ b/Ferrite/Extensions/View.swift @@ -23,6 +23,16 @@ extension View { )) } + // From https://github.com/siteline/SwiftUI-Introspect/pull/129 + public func introspectSearchController(customize: @escaping (UISearchController) -> Void) -> some View { + introspectNavigationController { navigationController in + let navigationBar = navigationController.navigationBar + if let searchController = navigationBar.topItem?.searchController { + customize(searchController) + } + } + } + // MARK: Modifiers func conditionalContextMenu(id: some Hashable, diff --git a/Ferrite/Models/AllDebridModels.swift b/Ferrite/Models/AllDebridModels.swift new file mode 100644 index 0000000..a253027 --- /dev/null +++ b/Ferrite/Models/AllDebridModels.swift @@ -0,0 +1,157 @@ +// +// AllDebridModels.swift +// Ferrite +// +// Created by Brian Dashore on 11/25/22. +// + +import Foundation + +public extension AllDebrid { + // MARK: - Errors + + // TODO: Hybridize debrid errors in one structure + enum ADError: Error { + case InvalidUrl + case InvalidPostBody + case InvalidResponse + case InvalidToken + case EmptyData + case EmptyTorrents + case FailedRequest(description: String) + case AuthQuery(description: String) + } + + // MARK: - Generic AllDebrid response + + // Uses a generic parametr for whatever underlying response is present + struct ADResponse: Codable { + let status: String + let data: ADData + } + + // MARK: - PinResponse + + struct PinResponse: Codable { + let pin, check: String + let expiresIn: Int + let userURL, baseURL, checkURL: String + + enum CodingKeys: String, CodingKey { + case pin, check + case expiresIn = "expires_in" + case userURL = "user_url" + case baseURL = "base_url" + case checkURL = "check_url" + } + } + + // MARK: - ApiKeyResponse + + struct ApiKeyResponse: Codable { + let apikey: String + let activated: Bool + let expiresIn: Int + + enum CodingKeys: String, CodingKey { + case apikey, activated + case expiresIn = "expires_in" + } + } + + // MARK: - AddMagnetResponse + + struct AddMagnetResponse: Codable { + let magnets: [AddMagnetData] + } + + // MARK: - AddMagnetData + + internal struct AddMagnetData: Codable { + let magnet, hash, name, filenameOriginal: String + let size: Int + let ready: Bool + let id: Int + + enum CodingKeys: String, CodingKey { + case magnet, hash, name + case filenameOriginal = "filename_original" + case size, ready, id + } + } + + // MARK: - MagnetStatusResponse + + struct MagnetStatusResponse: Codable { + let magnets: MagnetStatusData + } + + // MARK: - MagnetStatusData + + internal struct MagnetStatusData: Codable { + let id: Int + let filename: String + let size: Int + let hash, status: String + let statusCode, downloaded, uploaded, seeders: Int + let downloadSpeed, processingPerc, uploadSpeed, uploadDate: Int + let completionDate: Int + let links: [MagnetStatusLink] + let type: String + let notified: Bool + let version: Int + } + + // MARK: - MagnetStatusLink + + // Abridged for required parameters + internal struct MagnetStatusLink: Codable { + let link: String + let filename: String + let size: Int + } + + // MARK: - UnlockLinkResponse + + // Abridged for required parameters + struct UnlockLinkResponse: Codable { + let link: String + } + + // MARK: - InstantAvailabilityResponse + + struct InstantAvailabilityResponse: Codable { + let magnets: [InstantAvailabilityMagnet] + } + + // MARK: - IAMagnetResponse + + internal struct InstantAvailabilityMagnet: Codable { + let magnet, hash: String + let instant: Bool + let files: [InstantAvailabilityFile]? + } + + // MARK: - IAFileResponse + + internal struct InstantAvailabilityFile: Codable { + let name: String + + enum CodingKeys: String, CodingKey { + case name = "n" + } + } + + // MARK: - InstantAvailablity client side structures + + struct IA: Codable, Hashable { + let hash: String + let expiryTimeStamp: Double + var files: [IAFile] + } + + struct IAFile: Codable, Hashable { + let id: Int + let fileName: String + } +} diff --git a/Ferrite/Models/DebridManagerModels.swift b/Ferrite/Models/DebridManagerModels.swift new file mode 100644 index 0000000..1280222 --- /dev/null +++ b/Ferrite/Models/DebridManagerModels.swift @@ -0,0 +1,32 @@ +// +// DebridManagerModels.swift +// Ferrite +// +// Created by Brian Dashore on 11/27/22. +// + +import Foundation + +// MARK: - Universal IA enum (IA = InstantAvailability) + +public enum IAStatus: Codable, Hashable, Sendable { + case full + case partial + case none +} + +// MARK: - Enum for debrid differentiation. 0 is nil + +public enum DebridType: Int, Codable, Hashable, CaseIterable { + case realDebrid = 1 + case allDebrid = 2 + + func toString(abbreviated: Bool = false) -> String { + switch self { + case .realDebrid: + return abbreviated ? "RD" : "RealDebrid" + case .allDebrid: + return abbreviated ? "AD" : "AllDebrid" + } + } +} diff --git a/Ferrite/Models/GithubModels.swift b/Ferrite/Models/GithubModels.swift index 242d5e0..08e007e 100644 --- a/Ferrite/Models/GithubModels.swift +++ b/Ferrite/Models/GithubModels.swift @@ -7,8 +7,8 @@ import Foundation -extension Github { - public struct Release: Codable, Hashable, Sendable { +public extension Github { + struct Release: Codable, Hashable, Sendable { let htmlUrl: String let tagName: String diff --git a/Ferrite/Models/RealDebridModels.swift b/Ferrite/Models/RealDebridModels.swift index 114a202..3a9e1a0 100644 --- a/Ferrite/Models/RealDebridModels.swift +++ b/Ferrite/Models/RealDebridModels.swift @@ -8,9 +8,11 @@ import Foundation -extension RealDebrid { +public extension RealDebrid { // MARK: - Errors - public enum RDError: Error { + + // TODO: Hybridize debrid errors in one structure + enum RDError: Error { case InvalidUrl case InvalidPostBody case InvalidResponse @@ -23,7 +25,7 @@ extension RealDebrid { // MARK: - device code endpoint - public struct DeviceCodeResponse: Codable, Sendable { + struct DeviceCodeResponse: Codable, Sendable { let deviceCode, userCode: String let interval, expiresIn: Int let verificationURL, directVerificationURL: String @@ -40,7 +42,7 @@ extension RealDebrid { // MARK: - device credentials endpoint - public struct DeviceCredentialsResponse: Codable, Sendable { + struct DeviceCredentialsResponse: Codable, Sendable { let clientID, clientSecret: String? enum CodingKeys: String, CodingKey { @@ -51,7 +53,7 @@ extension RealDebrid { // MARK: - token endpoint - public struct TokenResponse: Codable, Sendable { + struct TokenResponse: Codable, Sendable { let accessToken: String let expiresIn: Int let refreshToken, tokenType: String @@ -67,7 +69,7 @@ extension RealDebrid { // MARK: - instantAvailability endpoint // Thanks Skitty! - public struct InstantAvailabilityResponse: Codable, Sendable { + struct InstantAvailabilityResponse: Codable, Sendable { var data: InstantAvailabilityData? public init(from decoder: Decoder) throws { @@ -79,55 +81,49 @@ extension RealDebrid { } } - struct InstantAvailabilityData: Codable, Sendable { + internal struct InstantAvailabilityData: Codable, Sendable { var rd: [[String: InstantAvailabilityInfo]] } - struct InstantAvailabilityInfo: Codable, Sendable { + internal struct InstantAvailabilityInfo: Codable, Sendable { var filename: String var filesize: Int } // MARK: - Instant Availability client side structures - public struct IA: Codable, Hashable, Sendable { + struct IA: Codable, Hashable, Sendable { let hash: String let expiryTimeStamp: Double var files: [IAFile] = [] var batches: [IABatch] = [] } - public struct IABatch: Codable, Hashable, Sendable { + struct IABatch: Codable, Hashable, Sendable { let files: [IABatchFile] } - public struct IABatchFile: Codable, Hashable, Sendable { + struct IABatchFile: Codable, Hashable, Sendable { let id: Int let fileName: String } - public struct IAFile: Codable, Hashable, Sendable { + struct IAFile: Codable, Hashable, Sendable { let name: String let batchIndex: Int let batchFileIndex: Int } - public enum IAStatus: Codable, Hashable, Sendable { - case full - case partial - case none - } - // MARK: - addMagnet endpoint - public struct AddMagnetResponse: Codable, Sendable { + struct AddMagnetResponse: Codable, Sendable { let id: String let uri: String } // MARK: - torrentInfo endpoint - struct TorrentInfoResponse: Codable, Sendable { + internal struct TorrentInfoResponse: Codable, Sendable { let id, filename, originalFilename, hash: String let bytes, originalBytes: Int let host: String @@ -148,13 +144,13 @@ extension RealDebrid { } } - struct TorrentInfoFile: Codable, Sendable { + internal struct TorrentInfoFile: Codable, Sendable { let id: Int let path: String let bytes, selected: Int } - public struct UserTorrentsResponse: Codable, Sendable { + struct UserTorrentsResponse: Codable, Sendable { let id, filename, hash: String let bytes: Int let host: String @@ -167,7 +163,7 @@ extension RealDebrid { // MARK: - unrestrictLink endpoint - struct UnrestrictLinkResponse: Codable, Sendable { + internal struct UnrestrictLinkResponse: Codable, Sendable { let id, filename: String let mimeType: String? let filesize: Int @@ -187,7 +183,7 @@ extension RealDebrid { // MARK: - User downloads list - public struct UserDownloadsResponse: Codable, Sendable { + struct UserDownloadsResponse: Codable, Sendable { let id, filename: String let mimeType: String? let filesize: Int diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 175694a..6338d02 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -13,39 +13,84 @@ public class DebridManager: ObservableObject { // Linked classes var toastModel: ToastViewModel? let realDebrid: RealDebrid = .init() + let allDebrid: AllDebrid = .init() // UI Variables @Published var showWebView: Bool = false @Published var showLoadingProgress: Bool = false // Service agnostic variables - var currentDebridTask: Task? - - // RealDebrid auth variables - @Published var realDebridEnabled: Bool = false { + @Published var enabledDebrids: Set = [] { didSet { - UserDefaults.standard.set(realDebridEnabled, forKey: "RealDebrid.Enabled") + UserDefaults.standard.set(enabledDebrids.rawValue, forKey: "Debrid.EnabledArray") } } + @Published var selectedDebridType: DebridType? { + didSet { + UserDefaults.standard.set(selectedDebridType?.rawValue ?? 0, forKey: "Debrid.PreferredService") + } + } + + var currentDebridTask: Task? + var downloadUrl: String = "" + var authUrl: String = "" + + // RealDebrid auth variables @Published var realDebridAuthProcessing: Bool = false - var realDebridAuthUrl: String = "" // RealDebrid fetch variables @Published var realDebridIAValues: [RealDebrid.IA] = [] - var realDebridDownloadUrl: String = "" @Published var showDeleteAlert: Bool = false - // TODO: Switch to an individual item based sheet system to remove these variables var selectedRealDebridItem: RealDebrid.IA? var selectedRealDebridFile: RealDebrid.IAFile? var selectedRealDebridID: String? + // AllDebrid auth variables + @Published var allDebridAuthProcessing: Bool = false + + // AllDebrid fetch variables + @Published var allDebridIAValues: [AllDebrid.IA] = [] + + var selectedAllDebridItem: AllDebrid.IA? + var selectedAllDebridFile: AllDebrid.IAFile? + init() { - realDebridEnabled = UserDefaults.standard.bool(forKey: "RealDebrid.Enabled") + if let rawDebridList = UserDefaults.standard.string(forKey: "Debrid.EnabledArray"), + let serializedDebridList = Set(rawValue: rawDebridList) + { + enabledDebrids = serializedDebridList + } + + // If a UserDefaults integer isn't set, it's usually 0 + let rawPreferredService = UserDefaults.standard.integer(forKey: "Debrid.PreferredService") + selectedDebridType = DebridType(rawValue: rawPreferredService) + + // If a user has one logged in service, automatically set the preferred service to that one + if enabledDebrids.count == 1 { + selectedDebridType = enabledDebrids.first + } } + // TODO: Remove this after v0.6.0 + // Login cleanup function that's automatically run to switch to the new login system + public func cleanupOldLogins() async { + let realDebridEnabled = UserDefaults.standard.bool(forKey: "RealDebrid.Enabled") + if realDebridEnabled { + enabledDebrids.insert(.realDebrid) + UserDefaults.standard.set(false, forKey: "RealDebrid.Enabled") + } + + let allDebridEnabled = UserDefaults.standard.bool(forKey: "AllDebrid.Enabled") + if allDebridEnabled { + enabledDebrids.insert(.allDebrid) + UserDefaults.standard.set(false, forKey: "AllDebrid.Enabled") + } + } + + // Common function to populate hashes for debrid services public func populateDebridHashes(_ resultHashes: [String]) async { do { let now = Date() @@ -53,76 +98,135 @@ public class DebridManager: ObservableObject { // 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 }) { + if let IAIndex = realDebridIAValues.firstIndex(where: { $0.hash == 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) { + if now.timeIntervalSince1970 > allDebridIAValues[IAIndex].expiryTimeStamp { + allDebridIAValues.remove(at: IAIndex) + return true + } else { + return false + } } else { return true } } if !sendHashes.isEmpty { - let fetchedIAValues = try await realDebrid.instantAvailability(magnetHashes: sendHashes) + if enabledDebrids.contains(.realDebrid) { + let fetchedRealDebridIA = try await realDebrid.instantAvailability(magnetHashes: sendHashes) + realDebridIAValues += fetchedRealDebridIA + } - realDebridIAValues += fetchedIAValues + if enabledDebrids.contains(.allDebrid) { + let fetchedAllDebridIA = try await allDebrid.instantAvailability(hashes: sendHashes) + allDebridIAValues += fetchedAllDebridIA + } } } catch { let error = error as NSError if error.code != -999 { - toastModel?.updateToastDescription("RealDebrid hash error: \(error)") + toastModel?.updateToastDescription("Hash population error: \(error)") } - print("RealDebrid hash error: \(error)") + print("Hash population error: \(error)") } } - public func matchSearchResult(result: SearchResult?) -> RealDebrid.IAStatus { + // Common function to match search results with a provided debrid service + public func matchSearchResult(result: SearchResult?) -> IAStatus { guard let result else { return .none } - guard let debridMatch = realDebridIAValues.first(where: { result.magnetHash == $0.hash }) else { - return .none - } + switch selectedDebridType { + case .realDebrid: + guard let realDebridMatch = realDebridIAValues.first(where: { result.magnetHash == $0.hash }) else { + return .none + } - if debridMatch.batches.isEmpty { - return .full - } else { - return .partial + if realDebridMatch.batches.isEmpty { + return .full + } else { + return .partial + } + case .allDebrid: + guard let allDebridMatch = allDebridIAValues.first(where: { result.magnetHash == $0.hash }) else { + return .none + } + + if allDebridMatch.files.count > 1 { + return .partial + } else { + return .full + } + case .none: + return .none } } - public func setSelectedRdResult(result: SearchResult) -> Bool { + public func selectDebridResult(result: SearchResult) -> Bool { guard let magnetHash = result.magnetHash else { toastModel?.updateToastDescription("Could not find the torrent magnet hash") return false } - if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.hash }) { - selectedRealDebridItem = realDebridItem - return true - } else { - toastModel?.updateToastDescription("Could not find the associated RealDebrid entry for magnet hash \(magnetHash)") + switch selectedDebridType { + case .realDebrid: + if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.hash }) { + selectedRealDebridItem = realDebridItem + return true + } else { + toastModel?.updateToastDescription("Could not find the associated RealDebrid entry for magnet hash \(magnetHash)") + return false + } + case .allDebrid: + if let allDebridItem = allDebridIAValues.first(where: { magnetHash == $0.hash }) { + selectedAllDebridItem = allDebridItem + return true + } else { + toastModel?.updateToastDescription("Could not find the associated AllDebrid entry for magnet hash \(magnetHash)") + return false + } + case .none: return false } } - public func authenticateRd() async { + // MARK: - Authentication UI linked functions + + // Common function to delegate what debrid service to authenticate with + public func authenticateDebrid(debridType: DebridType) async { + switch debridType { + case .realDebrid: + await authenticateRd() + enabledDebrids.insert(.realDebrid) + 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 + } + } + + private func authenticateRd() async { do { realDebridAuthProcessing = true let verificationResponse = try await realDebrid.getVerificationInfo() - realDebridAuthUrl = verificationResponse.directVerificationURL + authUrl = verificationResponse.directVerificationURL showWebView.toggle() try await realDebrid.getDeviceCredentials(deviceCode: verificationResponse.deviceCode) - - realDebridEnabled = true } catch { toastModel?.updateToastDescription("RealDebrid authentication error: \(error)") realDebrid.authTask?.cancel() @@ -131,10 +235,44 @@ public class DebridManager: ObservableObject { } } - public func logoutRd() async { + private func authenticateAd() async { + do { + allDebridAuthProcessing = true + let pinResponse = try await allDebrid.getPinInfo() + + authUrl = pinResponse.userURL + showWebView.toggle() + + try await allDebrid.getApiKey(checkID: pinResponse.check, pin: pinResponse.pin) + } catch { + toastModel?.updateToastDescription("AllDebrid authentication error: \(error)") + allDebrid.authTask?.cancel() + + print("AllDebrid authentication error: \(error)") + } + } + + // MARK: - Logout UI linked functions + + // Common function to delegate what debrid service to logout of + public func logoutDebrid(debridType: DebridType) async { + switch debridType { + case .realDebrid: + await logoutRd() + case .allDebrid: + logoutAd() + } + + // Automatically resets the preferred debrid service if it was set to the logged out service + if selectedDebridType == debridType { + selectedDebridType = nil + } + } + + private func logoutRd() async { do { try await realDebrid.deleteTokens() - realDebridEnabled = false + enabledDebrids.remove(.realDebrid) realDebridAuthProcessing = false } catch { toastModel?.updateToastDescription("RealDebrid logout error: \(error)") @@ -143,7 +281,18 @@ public class DebridManager: ObservableObject { } } - public func fetchRdDownload(searchResult: SearchResult) async { + private func logoutAd() { + allDebrid.deleteTokens() + enabledDebrids.remove(.allDebrid) + allDebridAuthProcessing = false + + toastModel?.updateToastDescription("Please manually delete the AllDebrid API key", newToastType: .info) + } + + // MARK: - Debrid fetch UI linked functions + + // Common function to delegate what debrid service to fetch from + public func fetchDebridDownload(searchResult: SearchResult) async { defer { currentDebridTask = nil showLoadingProgress = false @@ -153,11 +302,24 @@ public class DebridManager: ObservableObject { guard let magnetLink = searchResult.magnetLink else { toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.") - print("RealDebrid error: Invalid magnet link") + print("Debrid error: Invalid magnet link") return } + switch selectedDebridType { + case .realDebrid: + await fetchRdDownload(magnetLink: magnetLink) + case .allDebrid: + await fetchAdDownload(magnetLink: magnetLink) + case .none: + break + } + } + + func fetchRdDownload(magnetLink: String) async { + print("Called RD Download function!") + do { var fileIds: [Int] = [] @@ -178,11 +340,11 @@ public class DebridManager: ObservableObject { { let existingLinks = try await realDebrid.userDownloads().filter { $0.link == torrentLink } if let existingLink = existingLinks[safe: 0]?.download { - realDebridDownloadUrl = existingLink + downloadUrl = existingLink } else { let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink) - realDebridDownloadUrl = downloadLink + downloadUrl = downloadLink } } else { @@ -192,10 +354,13 @@ public class DebridManager: ObservableObject { if let realDebridId = selectedRealDebridID { try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds) - let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: selectedRealDebridFile?.batchFileIndex ?? 0) + let torrentLink = try await realDebrid.torrentInfo( + debridID: realDebridId, + selectedIndex: selectedRealDebridFile?.batchFileIndex ?? 0 + ) let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink) - realDebridDownloadUrl = downloadLink + downloadUrl = downloadLink } else { toastModel?.updateToastDescription("Could not cache this torrent. Aborting.") } @@ -223,11 +388,32 @@ public class DebridManager: ObservableObject { } } - public func deleteRdTorrent() async { + func deleteRdTorrent() async { if let realDebridId = selectedRealDebridID { try? await realDebrid.deleteTorrent(debridID: realDebridId) } selectedRealDebridID = nil } + + func fetchAdDownload(magnetLink: String) async { + do { + let magnetID = try await allDebrid.addMagnet(magnetLink: magnetLink) + let lockedLink = try await allDebrid.fetchMagnetStatus( + magnetId: magnetID, + selectedIndex: selectedAllDebridFile?.id ?? 0 + ) + let unlockedLink = try await allDebrid.unlockLink(lockedLink: lockedLink) + + downloadUrl = unlockedLink + } catch { + let error = error as NSError + switch error.code { + case -999: + toastModel?.updateToastDescription("Download cancelled", newToastType: .info) + default: + toastModel?.updateToastDescription("AllDebrid download error: \(error)") + } + } + } } diff --git a/Ferrite/ViewModels/ScrapingViewModel.swift b/Ferrite/ViewModels/ScrapingViewModel.swift index 5554516..92b8c75 100644 --- a/Ferrite/ViewModels/ScrapingViewModel.swift +++ b/Ferrite/ViewModels/ScrapingViewModel.swift @@ -12,8 +12,6 @@ import SwiftUI import SwiftyJSON class ScrapingViewModel: ObservableObject { - @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false - // Link the toast view model for single-directional communication var toastModel: ToastViewModel? let byteCountFormatter: ByteCountFormatter = .init() diff --git a/Ferrite/Views/BatchChoiceView.swift b/Ferrite/Views/BatchChoiceView.swift deleted file mode 100644 index 0a70ac0..0000000 --- a/Ferrite/Views/BatchChoiceView.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// BatchChoiceView.swift -// Ferrite -// -// Created by Brian Dashore on 7/24/22. -// - -import SwiftUI - -struct BatchChoiceView: View { - @EnvironmentObject var debridManager: DebridManager - @EnvironmentObject var scrapingModel: ScrapingViewModel - @EnvironmentObject var navModel: NavigationViewModel - - let backgroundContext = PersistenceController.shared.backgroundContext - - var body: some View { - NavView { - List { - ForEach(debridManager.selectedRealDebridItem?.files ?? [], id: \.self) { file in - Button(file.name) { - debridManager.selectedRealDebridFile = file - - if let searchResult = navModel.selectedSearchResult { - debridManager.currentDebridTask = Task { - await debridManager.fetchRdDownload(searchResult: searchResult) - - if !debridManager.realDebridDownloadUrl.isEmpty { - // The download may complete before this sheet dismisses - try? await Task.sleep(seconds: 1) - navModel.selectedBatchTitle = file.name - navModel.addToHistory(name: searchResult.title, source: searchResult.source, url: debridManager.realDebridDownloadUrl, subName: file.name) - navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl) - } - - debridManager.selectedRealDebridFile = nil - debridManager.selectedRealDebridItem = nil - } - } - - navModel.currentChoiceSheet = nil - } - .backport.tint(.primary) - } - } - .listStyle(.insetGrouped) - .navigationTitle("Select a file") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { - navModel.currentChoiceSheet = nil - - Task { - try? await Task.sleep(seconds: 1) - debridManager.selectedRealDebridItem = nil - } - } - } - } - } - } -} - -struct BatchChoiceView_Previews: PreviewProvider { - static var previews: some View { - BatchChoiceView() - } -} diff --git a/Ferrite/Views/ComponentViews/Debrid/DebridChoiceView.swift b/Ferrite/Views/ComponentViews/Debrid/DebridChoiceView.swift new file mode 100644 index 0000000..f38ddf5 --- /dev/null +++ b/Ferrite/Views/ComponentViews/Debrid/DebridChoiceView.swift @@ -0,0 +1,37 @@ +// +// DebridChoiceView.swift +// Ferrite +// +// Created by Brian Dashore on 11/26/22. +// + +import SwiftUI + +struct DebridChoiceView: View { + @EnvironmentObject var debridManager: DebridManager + + var body: some View { + Menu { + Picker("", selection: $debridManager.selectedDebridType) { + Text("None") + .tag(nil as DebridType?) + + ForEach(DebridType.allCases, id: \.self) { (debridType: DebridType) in + if debridManager.enabledDebrids.contains(debridType) { + Text(debridType.toString()) + .tag(DebridType?.some(debridType)) + } + } + } + } label: { + Text(debridManager.selectedDebridType?.toString(abbreviated: true) ?? "Debrid") + } + .animation(.none) + } +} + +struct DebridChoiceView_Previews: PreviewProvider { + static var previews: some View { + DebridChoiceView() + } +} diff --git a/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift b/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift new file mode 100644 index 0000000..6d1bdcc --- /dev/null +++ b/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift @@ -0,0 +1,36 @@ +// +// DebridLabelView.swift +// Ferrite +// +// Created by Brian Dashore on 11/27/22. +// + +import SwiftUI + +struct DebridLabelView: View { + @EnvironmentObject var debridManager: DebridManager + + var result: SearchResult + + let debridAbbreviation: String + + var body: some View { + Text(debridAbbreviation) + .fontWeight(.bold) + .padding(2) + .background { + Group { + switch debridManager.matchSearchResult(result: result) { + case .full: + Color.green + case .partial: + Color.orange + case .none: + Color.red + } + } + .cornerRadius(4) + .opacity(0.5) + } + } +} diff --git a/Ferrite/Views/LibraryViews/BookmarksView.swift b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift similarity index 95% rename from Ferrite/Views/LibraryViews/BookmarksView.swift rename to Ferrite/Views/ComponentViews/Library/BookmarksView.swift index d668b62..85cf526 100644 --- a/Ferrite/Views/LibraryViews/BookmarksView.swift +++ b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift @@ -13,8 +13,6 @@ struct BookmarksView: View { @EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var debridManager: DebridManager - @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false - let backgroundContext = PersistenceController.shared.backgroundContext var bookmarks: FetchedResults @@ -54,7 +52,7 @@ struct BookmarksView: View { } } .onAppear { - if realDebridEnabled { + if debridManager.enabledDebrids.count > 0 { viewTask = Task { let hashes = bookmarks.compactMap(\.magnetHash) await debridManager.populateDebridHashes(hashes) diff --git a/Ferrite/Views/LibraryViews/HistoryActionsView.swift b/Ferrite/Views/ComponentViews/Library/HistoryActionsView.swift similarity index 100% rename from Ferrite/Views/LibraryViews/HistoryActionsView.swift rename to Ferrite/Views/ComponentViews/Library/HistoryActionsView.swift diff --git a/Ferrite/Views/LibraryViews/HistoryButtonView.swift b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift similarity index 95% rename from Ferrite/Views/LibraryViews/HistoryButtonView.swift rename to Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift index 9399b16..c05f1fd 100644 --- a/Ferrite/Views/LibraryViews/HistoryButtonView.swift +++ b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift @@ -22,11 +22,11 @@ struct HistoryButtonView: View { if let url = entry.url { if url.starts(with: "https://") { Task { - debridManager.realDebridDownloadUrl = url + debridManager.downloadUrl = url navModel.runDebridAction(urlString: url) if navModel.currentChoiceSheet != .magnet { - debridManager.realDebridDownloadUrl = "" + debridManager.downloadUrl = "" } } } else { diff --git a/Ferrite/Views/LibraryViews/HistoryView.swift b/Ferrite/Views/ComponentViews/Library/HistoryView.swift similarity index 100% rename from Ferrite/Views/LibraryViews/HistoryView.swift rename to Ferrite/Views/ComponentViews/Library/HistoryView.swift diff --git a/Ferrite/Views/SearchResultViews/SearchResultButtonView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift similarity index 88% rename from Ferrite/Views/SearchResultViews/SearchResultButtonView.swift rename to Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift index 9d9d984..5943e48 100644 --- a/Ferrite/Views/SearchResultViews/SearchResultButtonView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift @@ -13,12 +13,11 @@ struct SearchResultButtonView: View { @EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var debridManager: DebridManager - @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false - var result: SearchResult @State private var runOnce = false @State var existingBookmark: Bookmark? = nil + @State private var showConfirmation = false var body: some View { Button { @@ -28,22 +27,22 @@ struct SearchResultButtonView: View { switch debridManager.matchSearchResult(result: result) { case .full: - if debridManager.setSelectedRdResult(result: result) { + if debridManager.selectDebridResult(result: result) { debridManager.currentDebridTask = Task { - await debridManager.fetchRdDownload(searchResult: result) + await debridManager.fetchDebridDownload(searchResult: result) - if !debridManager.realDebridDownloadUrl.isEmpty { - navModel.addToHistory(name: result.title, source: result.source, url: debridManager.realDebridDownloadUrl) - navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl) + if !debridManager.downloadUrl.isEmpty { + navModel.addToHistory(name: result.title, source: result.source, url: debridManager.downloadUrl) + navModel.runDebridAction(urlString: debridManager.downloadUrl) if navModel.currentChoiceSheet != .magnet { - debridManager.realDebridDownloadUrl = "" + debridManager.downloadUrl = "" } } } } case .partial: - if debridManager.setSelectedRdResult(result: result) { + if debridManager.selectDebridResult(result: result) { navModel.currentChoiceSheet = .batch } case .none: @@ -58,7 +57,7 @@ struct SearchResultButtonView: View { .fixedSize(horizontal: false, vertical: true) .lineLimit(4) - SearchResultRDView(result: result) + SearchResultInfoView(result: result) } .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) } diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift new file mode 100644 index 0000000..10072bd --- /dev/null +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift @@ -0,0 +1,43 @@ +// +// SearchResultRDView.swift +// Ferrite +// +// Created by Brian Dashore on 7/26/22. +// + +import SwiftUI + +struct SearchResultInfoView: View { + @EnvironmentObject var debridManager: DebridManager + + var result: SearchResult + + var body: some View { + HStack { + Text(result.source) + + Spacer() + + if let seeders = result.seeders { + Text("S: \(seeders)") + } + + if let leechers = result.leechers { + Text("L: \(leechers)") + } + + if let size = result.size { + Text(size) + } + + if debridManager.selectedDebridType == .realDebrid { + DebridLabelView(result: result, debridAbbreviation: "RD") + } + + if debridManager.selectedDebridType == .allDebrid { + DebridLabelView(result: result, debridAbbreviation: "AD") + } + } + .font(.caption) + } +} diff --git a/Ferrite/Views/SettingsViews/BackupsView.swift b/Ferrite/Views/ComponentViews/Settings/BackupsView.swift similarity index 100% rename from Ferrite/Views/SettingsViews/BackupsView.swift rename to Ferrite/Views/ComponentViews/Settings/BackupsView.swift diff --git a/Ferrite/Views/SettingsViews/DefaultActionsPickerViews.swift b/Ferrite/Views/ComponentViews/Settings/DefaultActionsPickerViews.swift similarity index 100% rename from Ferrite/Views/SettingsViews/DefaultActionsPickerViews.swift rename to Ferrite/Views/ComponentViews/Settings/DefaultActionsPickerViews.swift diff --git a/Ferrite/Views/SettingsViews/SettingsAppVersionView.swift b/Ferrite/Views/ComponentViews/Settings/SettingsAppVersionView.swift similarity index 100% rename from Ferrite/Views/SettingsViews/SettingsAppVersionView.swift rename to Ferrite/Views/ComponentViews/Settings/SettingsAppVersionView.swift diff --git a/Ferrite/Views/SettingsViews/SettingsSourceListView.swift b/Ferrite/Views/ComponentViews/Settings/SettingsSourceListView.swift similarity index 100% rename from Ferrite/Views/SettingsViews/SettingsSourceListView.swift rename to Ferrite/Views/ComponentViews/Settings/SettingsSourceListView.swift diff --git a/Ferrite/Views/SettingsViews/SourceListEditorView.swift b/Ferrite/Views/ComponentViews/Settings/SourceListEditorView.swift similarity index 100% rename from Ferrite/Views/SettingsViews/SourceListEditorView.swift rename to Ferrite/Views/ComponentViews/Settings/SourceListEditorView.swift diff --git a/Ferrite/Views/SourceViews/Buttons/InstalledSourceButtonView.swift b/Ferrite/Views/ComponentViews/Source/Buttons/InstalledSourceButtonView.swift similarity index 100% rename from Ferrite/Views/SourceViews/Buttons/InstalledSourceButtonView.swift rename to Ferrite/Views/ComponentViews/Source/Buttons/InstalledSourceButtonView.swift diff --git a/Ferrite/Views/SourceViews/Buttons/SourceCatalogButtonView.swift b/Ferrite/Views/ComponentViews/Source/Buttons/SourceCatalogButtonView.swift similarity index 100% rename from Ferrite/Views/SourceViews/Buttons/SourceCatalogButtonView.swift rename to Ferrite/Views/ComponentViews/Source/Buttons/SourceCatalogButtonView.swift diff --git a/Ferrite/Views/SourceViews/Buttons/SourceUpdateButtonView.swift b/Ferrite/Views/ComponentViews/Source/Buttons/SourceUpdateButtonView.swift similarity index 100% rename from Ferrite/Views/SourceViews/Buttons/SourceUpdateButtonView.swift rename to Ferrite/Views/ComponentViews/Source/Buttons/SourceUpdateButtonView.swift diff --git a/Ferrite/Views/SourceViews/SourceSettingsView.swift b/Ferrite/Views/ComponentViews/Source/SourceSettingsView.swift similarity index 100% rename from Ferrite/Views/SourceViews/SourceSettingsView.swift rename to Ferrite/Views/ComponentViews/Source/SourceSettingsView.swift diff --git a/Ferrite/Views/ContentView.swift b/Ferrite/Views/ContentView.swift index 9a117fa..3d7c495 100644 --- a/Ferrite/Views/ContentView.swift +++ b/Ferrite/Views/ContentView.swift @@ -14,8 +14,6 @@ struct ContentView: View { @EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var sourceManager: SourceManager - @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false - @FetchRequest( entity: Source.entity(), sortDescriptors: [] @@ -64,6 +62,7 @@ struct ContentView: View { Image(systemName: "chevron.down") } .foregroundColor(.primary) + .animation(.none) Spacer() } @@ -73,6 +72,7 @@ struct ContentView: View { SearchResultsView() } .navigationTitle("Search") + .navigationBarTitleDisplayMode(navModel.isEditingSearch || navModel.isSearching ? .inline : .automatic) .navigationSearchBar { SearchBar("Search", text: $scrapingModel.searchText, @@ -86,8 +86,9 @@ struct ContentView: View { let sources = sourceManager.fetchInstalledSources() await scrapingModel.scanSources(sources: sources) - if realDebridEnabled, !scrapingModel.searchResults.isEmpty { + if debridManager.enabledDebrids.count > 0, !scrapingModel.searchResults.isEmpty { debridManager.realDebridIAValues = [] + debridManager.allDebridIAValues = [] await debridManager.populateDebridHashes( scrapingModel.searchResults.compactMap(\.magnetHash) @@ -106,6 +107,14 @@ struct ContentView: View { scrapingModel.searchText = "" } } + .introspectSearchController { searchController in + searchController.hidesNavigationBarDuringPresentation = false + } + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + DebridChoiceView() + } + } } } } diff --git a/Ferrite/Views/LibraryView.swift b/Ferrite/Views/LibraryView.swift index 39c475f..87e3e1f 100644 --- a/Ferrite/Views/LibraryView.swift +++ b/Ferrite/Views/LibraryView.swift @@ -71,7 +71,10 @@ struct LibraryView: View { HStack { EditButton() - if selectedSegment == .history { + switch selectedSegment { + case .bookmarks: + DebridChoiceView() + case .history: HistoryActionsView() } } diff --git a/Ferrite/Views/SearchResultViews/SearchResultRDView.swift b/Ferrite/Views/SearchResultViews/SearchResultRDView.swift deleted file mode 100644 index 8c51280..0000000 --- a/Ferrite/Views/SearchResultViews/SearchResultRDView.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// SearchResultRDView.swift -// Ferrite -// -// Created by Brian Dashore on 7/26/22. -// - -import SwiftUI - -struct SearchResultRDView: View { - @EnvironmentObject var debridManager: DebridManager - - @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false - - var result: SearchResult - - var body: some View { - HStack { - Text(result.source) - - Spacer() - - if let seeders = result.seeders { - Text("S: \(seeders)") - } - - if let leechers = result.leechers { - Text("L: \(leechers)") - } - - if let size = result.size { - Text(size) - } - - if realDebridEnabled { - Text("RD") - .fontWeight(.bold) - .padding(2) - .background { - Group { - switch debridManager.matchSearchResult(result: result) { - case .full: - Color.green - case .partial: - Color.orange - case .none: - Color.red - } - } - .cornerRadius(4) - .opacity(0.5) - } - } - } - .font(.caption) - } -} diff --git a/Ferrite/Views/SearchResultsView.swift b/Ferrite/Views/SearchResultsView.swift index 110813e..df775a7 100644 --- a/Ferrite/Views/SearchResultsView.swift +++ b/Ferrite/Views/SearchResultsView.swift @@ -10,6 +10,7 @@ import SwiftUI struct SearchResultsView: View { @EnvironmentObject var scrapingModel: ScrapingViewModel @EnvironmentObject var navModel: NavigationViewModel + @EnvironmentObject var debridManager: DebridManager var body: some View { List { diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index 080445f..b39b316 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -24,22 +24,36 @@ struct SettingsView: View { Form { Section(header: InlineHeader("Debrid Services")) { HStack { - Text("Real Debrid") + Text("RealDebrid") Spacer() Button { Task { - if debridManager.realDebridEnabled { - await debridManager.logoutRd() + if debridManager.enabledDebrids.contains(.realDebrid) { + await debridManager.logoutDebrid(debridType: .realDebrid) } else if !debridManager.realDebridAuthProcessing { - await debridManager.authenticateRd() + await debridManager.authenticateDebrid(debridType: .realDebrid) } } } label: { - Text(debridManager.realDebridEnabled ? "Logout" : (debridManager.realDebridAuthProcessing ? "Processing" : "Login")) - .foregroundColor(debridManager.realDebridEnabled ? .red : .blue) - .onChange(of: debridManager.realDebridEnabled) { changed in - print("Debrid enabled changed to \(changed)") + Text(debridManager.enabledDebrids.contains(.realDebrid) ? "Logout" : (debridManager.realDebridAuthProcessing ? "Processing" : "Login")) + .foregroundColor(debridManager.enabledDebrids.contains(.realDebrid) ? .red : .blue) + } + } + + HStack { + Text("AllDebrid") + Spacer() + Button { + Task { + if debridManager.enabledDebrids.contains(.allDebrid) { + await debridManager.logoutDebrid(debridType: .allDebrid) + } else if !debridManager.realDebridAuthProcessing { + await debridManager.authenticateDebrid(debridType: .allDebrid) } + } + } label: { + Text(debridManager.enabledDebrids.contains(.allDebrid) ? "Logout" : (debridManager.allDebridAuthProcessing ? "Processing" : "Login")) + .foregroundColor(debridManager.enabledDebrids.contains(.allDebrid) ? .red : .blue) } } } @@ -49,7 +63,7 @@ struct SettingsView: View { } Section(header: Text("Default actions")) { - if debridManager.realDebridEnabled { + if debridManager.enabledDebrids.count > 0 { NavigationLink( destination: DebridActionPickerView(), label: { @@ -118,7 +132,7 @@ struct SettingsView: View { } } .sheet(isPresented: $debridManager.showWebView) { - LoginWebView(url: URL(string: debridManager.realDebridAuthUrl)!) + LoginWebView(url: URL(string: debridManager.authUrl)!) } .navigationTitle("Settings") } diff --git a/Ferrite/Views/SheetViews/BatchChoiceView.swift b/Ferrite/Views/SheetViews/BatchChoiceView.swift new file mode 100644 index 0000000..9385702 --- /dev/null +++ b/Ferrite/Views/SheetViews/BatchChoiceView.swift @@ -0,0 +1,100 @@ +// +// BatchChoiceView.swift +// Ferrite +// +// Created by Brian Dashore on 7/24/22. +// + +import SwiftUI + +struct BatchChoiceView: View { + @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var scrapingModel: ScrapingViewModel + @EnvironmentObject var navModel: NavigationViewModel + + let backgroundContext = PersistenceController.shared.backgroundContext + + var body: some View { + NavView { + List { + switch debridManager.selectedDebridType { + case .realDebrid: + ForEach(debridManager.selectedRealDebridItem?.files ?? [], id: \.self) { file in + Button(file.name) { + debridManager.selectedRealDebridFile = file + + queueCommonDownload(fileName: file.name) + } + } + case .allDebrid: + ForEach(debridManager.selectedAllDebridItem?.files ?? [], id: \.self) { file in + Button(file.fileName) { + debridManager.selectedAllDebridFile = file + + queueCommonDownload(fileName: file.fileName) + } + } + case .none: + EmptyView() + } + } + .backport.tint(.primary) + .listStyle(.insetGrouped) + .inlinedList() + .navigationTitle("Select a file") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + navModel.currentChoiceSheet = nil + + Task { + try? await Task.sleep(seconds: 1) + debridManager.selectedRealDebridItem = nil + } + } + } + } + } + } + + // Common function to communicate betwen VMs and queue/display a download + func queueCommonDownload(fileName: String) { + if let searchResult = navModel.selectedSearchResult { + debridManager.currentDebridTask = Task { + await debridManager.fetchDebridDownload(searchResult: searchResult) + + if !debridManager.downloadUrl.isEmpty { + try? await Task.sleep(seconds: 1) + navModel.selectedBatchTitle = fileName + navModel.addToHistory( + name: searchResult.title, + source: searchResult.source, + url: debridManager.downloadUrl, + subName: fileName + ) + navModel.runDebridAction(urlString: debridManager.downloadUrl) + } + + switch debridManager.selectedDebridType { + case .realDebrid: + debridManager.selectedAllDebridFile = nil + debridManager.selectedAllDebridItem = nil + case .allDebrid: + debridManager.selectedRealDebridFile = nil + debridManager.selectedRealDebridItem = nil + case .none: + break + } + } + } + + navModel.currentChoiceSheet = nil + } +} + +struct BatchChoiceView_Previews: PreviewProvider { + static var previews: some View { + BatchChoiceView() + } +} diff --git a/Ferrite/Views/MagnetChoiceView.swift b/Ferrite/Views/SheetViews/MagnetChoiceView.swift similarity index 93% rename from Ferrite/Views/MagnetChoiceView.swift rename to Ferrite/Views/SheetViews/MagnetChoiceView.swift index 1ad0c77..dd3c1d3 100644 --- a/Ferrite/Views/MagnetChoiceView.swift +++ b/Ferrite/Views/SheetViews/MagnetChoiceView.swift @@ -37,22 +37,22 @@ struct MagnetChoiceView: View { } } - if !debridManager.realDebridDownloadUrl.isEmpty { + if !debridManager.downloadUrl.isEmpty { Section(header: "Real Debrid options") { ListRowButtonView("Play on Outplayer", systemImage: "arrow.up.forward.app.fill") { - navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .outplayer) + navModel.runDebridAction(urlString: debridManager.downloadUrl, .outplayer) } ListRowButtonView("Play on VLC", systemImage: "arrow.up.forward.app.fill") { - navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .vlc) + navModel.runDebridAction(urlString: debridManager.downloadUrl, .vlc) } ListRowButtonView("Play on Infuse", systemImage: "arrow.up.forward.app.fill") { - navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .infuse) + navModel.runDebridAction(urlString: debridManager.downloadUrl, .infuse) } ListRowButtonView("Copy download URL", systemImage: "doc.on.doc.fill") { - UIPasteboard.general.string = debridManager.realDebridDownloadUrl + UIPasteboard.general.string = debridManager.downloadUrl showLinkCopyAlert.toggle() } .backport.alert( @@ -63,7 +63,7 @@ struct MagnetChoiceView: View { ) ListRowButtonView("Share download URL", systemImage: "square.and.arrow.up.fill") { - if let url = URL(string: debridManager.realDebridDownloadUrl) { + if let url = URL(string: debridManager.downloadUrl) { navModel.activityItems = [url] navModel.showLocalActivitySheet.toggle() } @@ -108,14 +108,14 @@ struct MagnetChoiceView: View { } } .onDisappear { - debridManager.realDebridDownloadUrl = "" + debridManager.downloadUrl = "" } .navigationTitle("Link actions") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { - debridManager.realDebridDownloadUrl = "" + debridManager.downloadUrl = "" presentationMode.wrappedValue.dismiss() } -- 2.45.2 From 17867db40caa191bd956bb9eeea1083cd54349c2 Mon Sep 17 00:00:00 2001 From: kingbri Date: Mon, 5 Dec 2022 16:01:26 -0500 Subject: [PATCH 03/19] 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) } -- 2.45.2 From 32e5e21d3c6cd934118cadc5b323b2ef32885c79 Mon Sep 17 00:00:00 2001 From: kingbri Date: Tue, 6 Dec 2022 10:35:18 -0500 Subject: [PATCH 04/19] DebridManager: Fix magnet links not loading I mistakenly added an and condition which caused all debrid services to fail unless the user is using Premiumize. Signed-off-by: kingbri --- Ferrite/ViewModels/DebridManager.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 5db4338..d80a7f4 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -438,18 +438,20 @@ public class DebridManager: ObservableObject { showLoadingProgress = true // Premiumize doesn't need a magnet link - guard let magnetLink = searchResult.magnetLink, selectedDebridType == .premiumize else { + guard searchResult.magnetLink != nil || selectedDebridType == .premiumize else { toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.") print("Debrid error: Invalid magnet link") return } + // Force unwrap is OK for debrid types that aren't ignored since the magnet link was already checked + // Do not force unwrap for Premiumize! switch selectedDebridType { case .realDebrid: - await fetchRdDownload(magnetLink: magnetLink) + await fetchRdDownload(magnetLink: searchResult.magnetLink!) case .allDebrid: - await fetchAdDownload(magnetLink: magnetLink) + await fetchAdDownload(magnetLink: searchResult.magnetLink!) case .premiumize: fetchPmDownload() case .none: -- 2.45.2 From 55226e5628734c67843efb92490283faf37d1ccc Mon Sep 17 00:00:00 2001 From: kingbri Date: Tue, 6 Dec 2022 12:06:09 -0500 Subject: [PATCH 05/19] Build: Add scripts for commit and nightly information This automatically embeds the commit hash and if the build is an actions nightly or not which will help with debugging crashes. Signed-off-by: kingbri --- .github/workflows/nightly.yml | 11 ++++++----- Ferrite.xcodeproj/project.pbxproj | 30 +++++++++++++++++++++++++++++- Ferrite/Classes/Application.swift | 7 ++++++- Ferrite/Extensions/Bundle.swift | 18 ++++++++++++++++++ Ferrite/Views/AboutView.swift | 5 +++++ 5 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 Ferrite/Extensions/Bundle.swift diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c4f281d..1cb3eb7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -14,18 +14,19 @@ jobs: with: xcode-version: latest - name: Get commit SHA - id: commitinfo - run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)" + run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - name: Build run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO + env: + IS_NIGHTLY: YES - name: Package ipa run: | mkdir Payload cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload - zip -r Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa Payload + zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload - name: Upload artifacts uses: actions/upload-artifact@v2 with: - name: Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa - path: Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa + name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa + path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa if-no-files-found: error diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index 55d366e..70faf40 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; }; 0C42B5962932F2D5008057A0 /* DebridChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */; }; 0C42B5982932F6DD008057A0 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Set.swift */; }; + 0C445C62293F9A0B0060744D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C445C61293F9A0B0060744D /* Bundle.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 */; }; @@ -133,6 +134,7 @@ 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 /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = ""; }; + 0C445C61293F9A0B0060744D /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.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 = ""; }; @@ -410,6 +412,8 @@ 0CA148C8288903F000DE2211 /* Extensions */ = { isa = PBXGroup; children = ( + 0CD72E16293D9928001A7EA4 /* Array.swift */, + 0C445C61293F9A0B0060744D /* Bundle.swift */, 0CA148C9288903F000DE2211 /* Collection.swift */, 0CA148CA288903F000DE2211 /* Data.swift */, 0CA429F728C5098D000D0610 /* DateFormatter.swift */, @@ -420,7 +424,6 @@ 0C7ED14228D65518009E29AD /* FileManager.swift */, 0C42B5972932F6DD008057A0 /* Set.swift */, 0C7C128528DAA3CD00381CD1 /* URL.swift */, - 0CD72E16293D9928001A7EA4 /* Array.swift */, ); path = Extensions; sourceTree = ""; @@ -523,6 +526,7 @@ 0CAF1C64286F5C0E00296F86 /* Sources */, 0CAF1C65286F5C0E00296F86 /* Frameworks */, 0CAF1C66286F5C0E00296F86 /* Resources */, + 0C445C60293F99360060744D /* Insert environment vars into Plist */, ); buildRules = ( ); @@ -599,6 +603,29 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 0C445C60293F99360060744D /* Insert environment vars into Plist */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + $BUILT_PRODUCTS_DIR/$INFOPLIST_PATH, + ); + name = "Insert environment vars into Plist"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n# From https://stackoverflow.com/questions/26514866/how-can-i-include-the-git-commit-hash-in-xcode\n\nINFO_PLIST=\"${TARGET_BUILD_DIR}\"/\"${INFOPLIST_PATH}\"\n\n# MARK: Adds commit hash to plist\ncommitValue=$(/usr/libexec/PlistBuddy -c 'print :GitCommitHash' \"${INFO_PLIST}\" 2>/dev/null)\n\n# Check if value is empty\nif [ -z \"$commitValue\" ] \nthen\n /usr/libexec/PlistBuddy -c \"Add :GitCommitHash string\" \"${INFO_PLIST}\"\nfi\n\n/usr/libexec/PlistBuddy -c \"Set :GitCommitHash `git rev-parse --short HEAD`\" \"${INFO_PLIST}\"\n\n# MARK: Adds the git build type to plist (examples: nightly, stable)\nbuildTypeValue=$(/usr/libexec/PlistBuddy -c 'print :IsNightly' \"${INFO_PLIST}\" 2>/dev/null)\n\n# Check if value is empty\nif [ -z \"$buildTypeValue\" ] \nthen\n /usr/libexec/PlistBuddy -c \"Add :IsNightly bool\" \"${INFO_PLIST}\"\nfi\n\n/usr/libexec/PlistBuddy -c \"Set :IsNightly ${IS_NIGHTLY}\" \"${INFO_PLIST}\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 0CAF1C64286F5C0E00296F86 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -647,6 +674,7 @@ 0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */, 0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */, 0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */, + 0C445C62293F9A0B0060744D /* Bundle.swift in Sources */, 0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */, 0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */, 0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */, diff --git a/Ferrite/Classes/Application.swift b/Ferrite/Classes/Application.swift index 6ebdbab..cf9832f 100644 --- a/Ferrite/Classes/Application.swift +++ b/Ferrite/Classes/Application.swift @@ -20,11 +20,16 @@ public class Application { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0" } + // Debug = development, Nightly = actions, Release = stable var buildType: String { #if DEBUG return "Debug" #else - return "Release" + if Bundle.main.isNightly { + return "Nightly" + } else { + return "Release" + } #endif } } diff --git a/Ferrite/Extensions/Bundle.swift b/Ferrite/Extensions/Bundle.swift new file mode 100644 index 0000000..4fd6eeb --- /dev/null +++ b/Ferrite/Extensions/Bundle.swift @@ -0,0 +1,18 @@ +// +// Bundle.swift +// Ferrite +// +// Created by Brian Dashore on 12/6/22. +// + +import Foundation + +extension Bundle { + var commitHash: String? { + infoDictionary?["GitCommitHash"] as? String + } + + var isNightly: Bool { + infoDictionary?["IsNightly"] as? Bool ?? false + } +} diff --git a/Ferrite/Views/AboutView.swift b/Ferrite/Views/AboutView.swift index 1b1bed5..35b2b78 100644 --- a/Ferrite/Views/AboutView.swift +++ b/Ferrite/Views/AboutView.swift @@ -14,6 +14,11 @@ struct AboutView: View { ListRowTextView(leftText: "Version", rightText: Application.shared.appVersion) ListRowTextView(leftText: "Build number", rightText: Application.shared.appBuild) ListRowTextView(leftText: "Build type", rightText: Application.shared.buildType) + + if let commitHash = Bundle.main.commitHash { + ListRowTextView(leftText: "Commit", rightText: commitHash) + } + ListRowLinkView(text: "Discord server", link: "https://discord.gg/sYQxnuD7Fj") ListRowLinkView(text: "GitHub repository", link: "https://github.com/bdashore3/Ferrite") } header: { -- 2.45.2 From 04e4503c86431e7931b72f121616d63aa36aaff7 Mon Sep 17 00:00:00 2001 From: kingbri Date: Fri, 9 Dec 2022 14:15:39 -0500 Subject: [PATCH 06/19] Premiumize: Split cache check requests by 100 Premiumize has a dynamic upper limit of 107-110 (from testing) hashes per request. To play it safe, cache requests are split by 100 and executed in parallel as 2 (or more) requests. Signed-off-by: kingbri --- Ferrite/API/PremiumizeWrapper.swift | 24 +++++++++++++++++++++++- Ferrite/ViewModels/DebridManager.swift | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Ferrite/API/PremiumizeWrapper.swift b/Ferrite/API/PremiumizeWrapper.swift index d0a0023..8764c3d 100644 --- a/Ferrite/API/PremiumizeWrapper.swift +++ b/Ferrite/API/PremiumizeWrapper.swift @@ -77,13 +77,35 @@ public class Premiumize { } } + // Function to divide and execute cache endpoint requests in parallel + // Calls this for 100 hashes at a time due to API limits + public func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] { + let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in + for chunk in magnets.chunked(into: 100) { + group.addTask { + try await self.checkCache(magnets: chunk) + } + } + + var chunkedMagnets: [Magnet] = [] + for try await magnetArray in group { + chunkedMagnets += magnetArray + } + + return chunkedMagnets + } + + return availableMagnets + } + // Parent function for initial checking of the cache - public func checkCache(magnets: [Magnet]) async throws -> [Magnet] { + 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) diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index d80a7f4..8cb3c78 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -153,7 +153,7 @@ public class DebridManager: ObservableObject { } if enabledDebrids.contains(.premiumize) { - let availableMagnets = try await premiumize.checkCache(magnets: sendMagnets) + let availableMagnets = try await premiumize.divideCacheRequests(magnets: sendMagnets) // Split DDL requests into chunks of 10 for chunk in availableMagnets.chunked(into: 10) { -- 2.45.2 From 47ef72bf13a9e50a97a9cd65447f5771fe1b589c Mon Sep 17 00:00:00 2001 From: kingbri Date: Wed, 14 Dec 2022 16:33:18 -0500 Subject: [PATCH 07/19] MagnetChoiceView: Fix titles Titles weren't cleared on sheet dismiss which caused conflicts between batches and single files. Fix this. Signed-off-by: kingbri --- Ferrite/ViewModels/NavigationViewModel.swift | 4 ++-- .../ComponentViews/Library/HistoryButtonView.swift | 4 ++-- .../SearchResult/SearchResultButtonView.swift | 2 +- Ferrite/Views/SheetViews/MagnetChoiceView.swift | 12 ++++++++---- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Ferrite/ViewModels/NavigationViewModel.swift b/Ferrite/ViewModels/NavigationViewModel.swift index 1767d93..e1a7cf3 100644 --- a/Ferrite/ViewModels/NavigationViewModel.swift +++ b/Ferrite/ViewModels/NavigationViewModel.swift @@ -35,8 +35,8 @@ class NavigationViewModel: ObservableObject { @Published var selectedSearchResult: SearchResult? // For giving information in magnet choice sheet - @Published var selectedTitle: String? - @Published var selectedBatchTitle: String? + @Published var selectedTitle: String = "" + @Published var selectedBatchTitle: String = "" @Published var hideNavigationBar = false diff --git a/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift index c05f1fd..777b0ed 100644 --- a/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift +++ b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift @@ -16,8 +16,8 @@ struct HistoryButtonView: View { var body: some View { Button { - navModel.selectedTitle = entry.name - navModel.selectedBatchTitle = entry.subName + navModel.selectedTitle = entry.name ?? "" + navModel.selectedBatchTitle = entry.subName ?? "" if let url = entry.url { if url.starts(with: "https://") { diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift index 5943e48..9f4995e 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift @@ -23,7 +23,7 @@ struct SearchResultButtonView: View { Button { if debridManager.currentDebridTask == nil { navModel.selectedSearchResult = result - navModel.selectedTitle = result.title + navModel.selectedTitle = result.title ?? "" switch debridManager.matchSearchResult(result: result) { case .full: diff --git a/Ferrite/Views/SheetViews/MagnetChoiceView.swift b/Ferrite/Views/SheetViews/MagnetChoiceView.swift index 0bf9cfb..dfef583 100644 --- a/Ferrite/Views/SheetViews/MagnetChoiceView.swift +++ b/Ferrite/Views/SheetViews/MagnetChoiceView.swift @@ -25,12 +25,12 @@ struct MagnetChoiceView: View { Form { Section(header: "Now Playing") { VStack(alignment: .leading, spacing: 5) { - Text(navModel.selectedTitle ?? "No title") + Text(navModel.selectedTitle) .font(.callout) - .lineLimit(navModel.selectedBatchTitle == nil ? .max : 1) + .lineLimit(navModel.selectedBatchTitle.isEmpty ? .max : 1) - if let batchTitle = navModel.selectedBatchTitle { - Text(batchTitle) + if !navModel.selectedBatchTitle.isEmpty { + Text(navModel.selectedBatchTitle) .foregroundColor(.gray) .font(.subheadline) } @@ -109,6 +109,8 @@ struct MagnetChoiceView: View { } .onDisappear { debridManager.downloadUrl = "" + navModel.selectedTitle = "" + navModel.selectedBatchTitle = "" } .navigationTitle("Link actions") .navigationBarTitleDisplayMode(.inline) @@ -116,6 +118,8 @@ struct MagnetChoiceView: View { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { debridManager.downloadUrl = "" + navModel.selectedTitle = "" + navModel.selectedBatchTitle = "" presentationMode.wrappedValue.dismiss() } -- 2.45.2 From 15ad8c55818f97199fb8c7d8e972d602560e61bf Mon Sep 17 00:00:00 2001 From: kingbri Date: Sat, 31 Dec 2022 12:33:51 -0500 Subject: [PATCH 08/19] Bookmarks: Fix context menu removal NotificationCenter posts to other instances of the same button caused the existing bookmark reference to be nullified for every other button. Add a comparison between object IDs to make sure the correct instances are being nullified. Signed-off-by: kingbri --- .../SearchResult/SearchResultButtonView.swift | 57 +++++++++++-------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift index 9f4995e..debe4a1 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift @@ -64,32 +64,34 @@ struct SearchResultButtonView: View { .disableInteraction(navModel.currentChoiceSheet != nil) .backport.tint(.primary) .conditionalContextMenu(id: existingBookmark) { - if let bookmark = existingBookmark { - Button { - PersistenceController.shared.delete(bookmark, context: backgroundContext) + ZStack { + if let bookmark = existingBookmark { + Button { + PersistenceController.shared.delete(bookmark, context: backgroundContext) - // When the entity is deleted, let other instances know to remove that reference - NotificationCenter.default.post(name: .didDeleteBookmark, object: nil) - } label: { - Text("Remove bookmark") - Image(systemName: "bookmark.slash.fill") - } - } else { - Button { - let newBookmark = Bookmark(context: backgroundContext) - newBookmark.title = result.title - newBookmark.source = result.source - newBookmark.magnetHash = result.magnetHash - newBookmark.magnetLink = result.magnetLink - newBookmark.seeders = result.seeders - newBookmark.leechers = result.leechers + // When the entity is deleted, let other instances know to remove that reference + NotificationCenter.default.post(name: .didDeleteBookmark, object: existingBookmark) + } label: { + Text("Remove bookmark") + Image(systemName: "bookmark.slash.fill") + } + } else { + Button { + let newBookmark = Bookmark(context: backgroundContext) + newBookmark.title = result.title + newBookmark.source = result.source + newBookmark.magnetHash = result.magnetHash + newBookmark.magnetLink = result.magnetLink + newBookmark.seeders = result.seeders + newBookmark.leechers = result.leechers - existingBookmark = newBookmark + existingBookmark = newBookmark - PersistenceController.shared.save(backgroundContext) - } label: { - Text("Bookmark") - Image(systemName: "bookmark") + PersistenceController.shared.save(backgroundContext) + } label: { + Text("Bookmark") + Image(systemName: "bookmark") + } } } } @@ -106,8 +108,13 @@ struct SearchResultButtonView: View { AlertButton(role: .cancel) ] ) - .onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { _ in - existingBookmark = nil + .onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in + // If the instance contains the deleted bookmark, remove it. + if let deletedBookmark = notification.object as? Bookmark, + let bookmark = existingBookmark, + deletedBookmark.objectID == bookmark.objectID { + existingBookmark = nil + } } .onAppear { // Only run a exists request if a bookmark isn't passed to the view -- 2.45.2 From b0850d43d7e424f3e311aeb32a40c04754702aac Mon Sep 17 00:00:00 2001 From: kingbri Date: Sat, 31 Dec 2022 12:35:28 -0500 Subject: [PATCH 09/19] Search: Fix inline navigation title Transitioning the navigation title to inline while editing search broke for older OS versions. Make a more bulletproof solution to inline the navigation title when the user commits a search as that's a definitive event to listen to. Signed-off-by: kingbri --- Ferrite/Views/ContentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Ferrite/Views/ContentView.swift b/Ferrite/Views/ContentView.swift index 48ae107..145ea41 100644 --- a/Ferrite/Views/ContentView.swift +++ b/Ferrite/Views/ContentView.swift @@ -72,7 +72,7 @@ struct ContentView: View { SearchResultsView() } .navigationTitle("Search") - .navigationBarTitleDisplayMode(navModel.isEditingSearch || navModel.isSearching ? .inline : .automatic) + .navigationBarTitleDisplayMode(navModel.isSearching ? .inline : .large) .navigationSearchBar { SearchBar("Search", text: $scrapingModel.searchText, -- 2.45.2 From 9b7bc55a25a24352e1ca64a685efa450e6d715e7 Mon Sep 17 00:00:00 2001 From: kingbri Date: Mon, 2 Jan 2023 11:29:30 -0500 Subject: [PATCH 10/19] Library: Add support for RealDebrid cloud RealDebrid saves a user's unrestricted links and "torrents" (magnet links in this case). Add the ability to see and queue a user's RD library in Ferrite itself. This required a further abstraction of the debrid manager to allow for more types other than search results to be passed to various functions. Deleting an item from RD's cloud list deletes the item from RD as well. NOTE: This does not track download progress, but it does show if a magnet is currently being downloaded or not. Signed-off-by: kingbri --- Ferrite.xcodeproj/project.pbxproj | 16 ++ Ferrite/API/RealDebridWrapper.swift | 7 + .../PersistenceController.swift | 6 +- Ferrite/Extensions/String.swift | 11 +- Ferrite/Models/BackupModels.swift | 8 +- Ferrite/Models/DebridManagerModels.swift | 2 +- Ferrite/Models/RealDebridModels.swift | 4 +- Ferrite/ViewModels/BackupManager.swift | 2 +- Ferrite/ViewModels/DebridManager.swift | 146 ++++++++++++------ Ferrite/ViewModels/NavigationViewModel.swift | 5 + .../Debrid/DebridLabelView.swift | 45 +++--- .../Library/BookmarksView.swift | 6 +- .../Library/Cloud/RealDebridCloudView.swift | 143 +++++++++++++++++ .../Library/DebridCloudView.swift | 31 ++++ .../SearchResult/SearchResultButtonView.swift | 27 +++- .../SearchResult/SearchResultInfoView.swift | 12 +- Ferrite/Views/ContentView.swift | 8 +- Ferrite/Views/LibraryView.swift | 14 +- .../Views/SheetViews/BatchChoiceView.swift | 41 ++--- .../Views/SheetViews/MagnetChoiceView.swift | 46 +++--- 20 files changed, 434 insertions(+), 146 deletions(-) create mode 100644 Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift create mode 100644 Ferrite/Views/ComponentViews/Library/DebridCloudView.swift diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index 70faf40..3748eb0 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */; }; 0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */; }; 0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */; }; + 0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */; }; + 0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */; }; 0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */; }; 0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */; }; 0C32FB532890D19D002BD219 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB522890D19D002BD219 /* AboutView.swift */; }; @@ -122,6 +124,8 @@ 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesView.swift; sourceTree = ""; }; 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAppVersionView.swift; sourceTree = ""; }; 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryButtonView.swift; sourceTree = ""; }; + 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = ""; }; + 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridCloudView.swift; sourceTree = ""; }; 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataClass.swift"; sourceTree = ""; }; 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataProperties.swift"; sourceTree = ""; }; 0C32FB522890D19D002BD219 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -306,6 +310,14 @@ path = Models; sourceTree = ""; }; + 0C2886D52960C4F800D6FC16 /* Cloud */ = { + isa = PBXGroup; + children = ( + 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */, + ); + path = Cloud; + sourceTree = ""; + }; 0C44E2A628D4DDC6007711AE /* Classes */ = { isa = PBXGroup; children = ( @@ -482,7 +494,9 @@ 0CA3B23528C265FD00616D3A /* Library */ = { isa = PBXGroup; children = ( + 0C2886D52960C4F800D6FC16 /* Cloud */, 0CA3B23828C2660D00616D3A /* BookmarksView.swift */, + 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */, 0CA3B23628C2660700616D3A /* HistoryView.swift */, 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */, 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */, @@ -651,6 +665,7 @@ 0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */, 0CE66B3A28E640D200F69346 /* Backport.swift in Sources */, 0C42B5962932F2D5008057A0 /* DebridChoiceView.swift in Sources */, + 0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */, 0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */, 0C794B6B289DACF100DD1CC8 /* SourceCatalogButtonView.swift in Sources */, 0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */, @@ -722,6 +737,7 @@ 0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */, 0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */, 0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */, + 0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */, 0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */, 0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */, 0CA148E0288903F000DE2211 /* FerriteApp.swift in Sources */, diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index ad6d06f..0f3f9bc 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -358,4 +358,11 @@ public class RealDebrid { return rawResponse } + + public func deleteDownload(debridID: String) async throws { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(debridID)")!) + request.httpMethod = "DELETE" + + try await performRequest(request: &request, requestName: #function) + } } diff --git a/Ferrite/DataManagement/PersistenceController.swift b/Ferrite/DataManagement/PersistenceController.swift index 6983d0a..fc389a2 100644 --- a/Ferrite/DataManagement/PersistenceController.swift +++ b/Ferrite/DataManagement/PersistenceController.swift @@ -112,9 +112,11 @@ struct PersistenceController { newBookmark.magnetLink = bookmarkJson.magnetLink newBookmark.seeders = bookmarkJson.seeders newBookmark.leechers = bookmarkJson.leechers + + save(backgroundContext) } - func createHistory(entryJson: HistoryEntryJson, date: Double?) { + func createHistory(_ entryJson: HistoryEntryJson, date: Double? = nil) { let historyDate = date.map { Date(timeIntervalSince1970: $0) } ?? Date() let historyDateString = DateFormatter.historyDateFormatter.string(from: historyDate) @@ -153,6 +155,8 @@ struct PersistenceController { newHistoryEntry.parentHistory?.dateString = historyDateString newHistoryEntry.parentHistory?.date = historyDate + + save(backgroundContext) } func getHistoryPredicate(range: HistoryDeleteRange) -> NSPredicate? { diff --git a/Ferrite/Extensions/String.swift b/Ferrite/Extensions/String.swift index 20a1917..bbdb0ce 100644 --- a/Ferrite/Extensions/String.swift +++ b/Ferrite/Extensions/String.swift @@ -4,12 +4,21 @@ // // Created by Brian Dashore on 8/31/22. // -// From https://stackoverflow.com/a/59307884 // import Foundation extension String { + // From https://www.hackingwithswift.com/example-code/strings/how-to-capitalize-the-first-letter-of-a-string + func capitalizingFirstLetter() -> String { + return prefix(1).capitalized + dropFirst() + } + + mutating func capitalizeFirstLetter() { + self = self.capitalizingFirstLetter() + } + + // From https://stackoverflow.com/a/59307884 private func compare(toVersion targetVersion: String) -> ComparisonResult { let versionDelimiter = "." var result: ComparisonResult = .orderedSame diff --git a/Ferrite/Models/BackupModels.swift b/Ferrite/Models/BackupModels.swift index 94b3b27..37b0f10 100644 --- a/Ferrite/Models/BackupModels.swift +++ b/Ferrite/Models/BackupModels.swift @@ -26,10 +26,10 @@ struct HistoryJson: Codable { } struct HistoryEntryJson: Codable { - let name: String - let subName: String? - let url: String - let timeStamp: Double? + var name: String? = nil + var subName: String? = nil + var url: String? = nil + var timeStamp: Double? = nil let source: String? } diff --git a/Ferrite/Models/DebridManagerModels.swift b/Ferrite/Models/DebridManagerModels.swift index 0cdf395..278d1c0 100644 --- a/Ferrite/Models/DebridManagerModels.swift +++ b/Ferrite/Models/DebridManagerModels.swift @@ -36,6 +36,6 @@ public enum DebridType: Int, Codable, Hashable, CaseIterable { // 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 link: String? let hash: String } diff --git a/Ferrite/Models/RealDebridModels.swift b/Ferrite/Models/RealDebridModels.swift index 3a9e1a0..2223959 100644 --- a/Ferrite/Models/RealDebridModels.swift +++ b/Ferrite/Models/RealDebridModels.swift @@ -150,7 +150,7 @@ public extension RealDebrid { let bytes, selected: Int } - struct UserTorrentsResponse: Codable, Sendable { + struct UserTorrentsResponse: Codable, Hashable, Sendable { let id, filename, hash: String let bytes: Int let host: String @@ -183,7 +183,7 @@ public extension RealDebrid { // MARK: - User downloads list - struct UserDownloadsResponse: Codable, Sendable { + struct UserDownloadsResponse: Codable, Hashable, Sendable { let id, filename: String let mimeType: String? let filesize: Int diff --git a/Ferrite/ViewModels/BackupManager.swift b/Ferrite/ViewModels/BackupManager.swift index 045ecf9..5e7c516 100644 --- a/Ferrite/ViewModels/BackupManager.swift +++ b/Ferrite/ViewModels/BackupManager.swift @@ -123,7 +123,7 @@ public class BackupManager: ObservableObject { if let storedHistories = backup.history { for storedHistory in storedHistories { for storedEntry in storedHistory.entries { - PersistenceController.shared.createHistory(entryJson: storedEntry, date: storedHistory.date) + PersistenceController.shared.createHistory(storedEntry, date: storedHistory.date) } } } diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 8cb3c78..020335a 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -50,6 +50,11 @@ public class DebridManager: ObservableObject { var selectedRealDebridFile: RealDebrid.IAFile? var selectedRealDebridID: String? + // RealDebrid cloud variables + @Published var realDebridCloudTorrents: [RealDebrid.UserTorrentsResponse] = [] + @Published var realDebridCloudDownloads: [RealDebrid.UserDownloadsResponse] = [] + var realDebridCloudTTL: Double = 0.0 + // AllDebrid auth variables @Published var allDebridAuthProcessing: Bool = false @@ -107,6 +112,30 @@ public class DebridManager: ObservableObject { } } + // Cleans all cached IA values in the event of a full IA refresh + public func clearIAValues() { + realDebridIAValues = [] + allDebridIAValues = [] + premiumizeIAValues = [] + } + + // Clears all selected files and items + public func clearSelectedDebridItems() { + switch selectedDebridType { + case .realDebrid: + selectedRealDebridFile = nil + selectedRealDebridItem = nil + case .allDebrid: + selectedAllDebridFile = nil + selectedAllDebridItem = nil + case .premiumize: + selectedPremiumizeFile = nil + selectedPremiumizeItem = nil + case .none: + break + } + } + // Common function to populate hashes for debrid services public func populateDebridIA(_ resultMagnets: [Magnet]) async { do { @@ -153,7 +182,16 @@ public class DebridManager: ObservableObject { } if enabledDebrids.contains(.premiumize) { - let availableMagnets = try await premiumize.divideCacheRequests(magnets: sendMagnets) + // Only strip magnets that don't have an associated link for PM + let strippedResultMagnets: [Magnet] = resultMagnets.compactMap { + if let magnetLink = $0.link { + return Magnet(link: magnetLink, hash: $0.hash) + } else { + return nil + } + } + + let availableMagnets = try await premiumize.divideCacheRequests(magnets: strippedResultMagnets) // Split DDL requests into chunks of 10 for chunk in availableMagnets.chunked(into: 10) { @@ -174,15 +212,15 @@ public class DebridManager: ObservableObject { } } - // Common function to match search results with a provided debrid service - public func matchSearchResult(result: SearchResult?) -> IAStatus { - guard let result else { + // Common function to match a magnet hash with a provided debrid service + public func matchMagnetHash(_ magnetHash: String?) -> IAStatus { + guard let magnetHash else { return .none } switch selectedDebridType { case .realDebrid: - guard let realDebridMatch = realDebridIAValues.first(where: { result.magnetHash == $0.hash }) else { + guard let realDebridMatch = realDebridIAValues.first(where: { magnetHash == $0.hash }) else { return .none } @@ -192,7 +230,7 @@ public class DebridManager: ObservableObject { return .partial } case .allDebrid: - guard let allDebridMatch = allDebridIAValues.first(where: { result.magnetHash == $0.hash }) else { + guard let allDebridMatch = allDebridIAValues.first(where: { magnetHash == $0.hash }) else { return .none } @@ -202,7 +240,7 @@ public class DebridManager: ObservableObject { return .full } case .premiumize: - guard let premiumizeMatch = premiumizeIAValues.first(where: { result.magnetHash == $0.hash }) else { + guard let premiumizeMatch = premiumizeIAValues.first(where: { magnetHash == $0.hash }) else { return .none } @@ -216,8 +254,8 @@ public class DebridManager: ObservableObject { } } - public func selectDebridResult(result: SearchResult) -> Bool { - guard let magnetHash = result.magnetHash else { + public func selectDebridResult(magnetHash: String?) -> Bool { + guard let magnetHash = magnetHash else { toastModel?.updateToastDescription("Could not find the torrent magnet hash") return false } @@ -429,7 +467,7 @@ public class DebridManager: ObservableObject { // MARK: - Debrid fetch UI linked functions // Common function to delegate what debrid service to fetch from - public func fetchDebridDownload(searchResult: SearchResult) async { + public func fetchDebridDownload(magnetLink: String?) async { defer { currentDebridTask = nil showLoadingProgress = false @@ -437,21 +475,11 @@ public class DebridManager: ObservableObject { showLoadingProgress = true - // Premiumize doesn't need a magnet link - guard searchResult.magnetLink != nil || selectedDebridType == .premiumize else { - toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.") - print("Debrid error: Invalid magnet link") - - return - } - - // Force unwrap is OK for debrid types that aren't ignored since the magnet link was already checked - // Do not force unwrap for Premiumize! switch selectedDebridType { case .realDebrid: - await fetchRdDownload(magnetLink: searchResult.magnetLink!) + await fetchRdDownload(magnetLink: magnetLink) case .allDebrid: - await fetchAdDownload(magnetLink: searchResult.magnetLink!) + await fetchAdDownload(magnetLink: magnetLink) case .premiumize: fetchPmDownload() case .none: @@ -459,38 +487,32 @@ public class DebridManager: ObservableObject { } } - func fetchRdDownload(magnetLink: String) async { + func fetchRdDownload(magnetLink: String?) async { do { - var fileIds: [Int] = [] - - if let iaFile = selectedRealDebridFile { - guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else { - return - } - - fileIds = iaBatchFromFile.files.map(\.id) - } + // Bypass the TTL since a download needs to be queried + await fetchRdCloud(bypassTTL: true) // If there's an existing torrent, check for a download link. Otherwise check for an unrestrict link - let existingTorrents = try await realDebrid.userTorrents().filter { $0.hash == selectedRealDebridItem?.hash } + let existingTorrents = realDebridCloudTorrents.filter { $0.hash == selectedRealDebridItem?.hash && $0.status == "downloaded" } // If the links match from a user's downloads, no need to re-run a download if let existingTorrent = existingTorrents[safe: 0], let torrentLink = existingTorrent.links[safe: selectedRealDebridFile?.batchFileIndex ?? 0] { - let existingLinks = try await realDebrid.userDownloads().filter { $0.link == torrentLink } - if let existingLink = existingLinks[safe: 0]?.download { - downloadUrl = existingLink - } else { - let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink) - - downloadUrl = downloadLink - } - - } else { + try await checkRdUserDownloads(userTorrentLink: torrentLink) + } else if let magnetLink = magnetLink { // Add a magnet after all the cache checks fail selectedRealDebridID = try await realDebrid.addMagnet(magnetLink: magnetLink) + var fileIds: [Int] = [] + if let iaFile = selectedRealDebridFile { + guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else { + return + } + + fileIds = iaBatchFromFile.files.map(\.id) + } + if let realDebridId = selectedRealDebridID { try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds) @@ -504,6 +526,9 @@ public class DebridManager: ObservableObject { } else { toastModel?.updateToastDescription("Could not cache this torrent. Aborting.") } + } else { + toastModel?.updateToastDescription("Could not fetch your file from RealDebrid's cache or API") + print("RealDebrid error: No magnet link or cached file found") } } catch { switch error { @@ -528,6 +553,21 @@ public class DebridManager: ObservableObject { } } + // Refreshes torrents and downloads from a RD user's account + public func fetchRdCloud(bypassTTL: Bool = false) async { + if bypassTTL || Date().timeIntervalSince1970 > realDebridCloudTTL { + do { + realDebridCloudTorrents = try await realDebrid.userTorrents() + realDebridCloudDownloads = try await realDebrid.userDownloads() + + // 5 minutes + realDebridCloudTTL = Date().timeIntervalSince1970 + 300 + } catch { + toastModel?.updateToastDescription("RealDebrid cloud fetch error: \(error)") + } + } + } + func deleteRdTorrent() async { if let realDebridId = selectedRealDebridID { try? await realDebrid.deleteTorrent(debridID: realDebridId) @@ -536,7 +576,25 @@ public class DebridManager: ObservableObject { selectedRealDebridID = nil } - func fetchAdDownload(magnetLink: String) async { + func checkRdUserDownloads(userTorrentLink: String) async throws { + let existingLinks = realDebridCloudDownloads.filter { $0.link == userTorrentLink } + if let existingLink = existingLinks[safe: 0]?.download { + downloadUrl = existingLink + } else { + let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: userTorrentLink) + + downloadUrl = downloadLink + } + } + + func fetchAdDownload(magnetLink: String?) async { + guard let magnetLink = magnetLink else { + toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.") + print("AllDebrid error: Invalid magnet link") + + return + } + do { let magnetID = try await allDebrid.addMagnet(magnetLink: magnetLink) let lockedLink = try await allDebrid.fetchMagnetStatus( diff --git a/Ferrite/ViewModels/NavigationViewModel.swift b/Ferrite/ViewModels/NavigationViewModel.swift index e1a7cf3..04d707c 100644 --- a/Ferrite/ViewModels/NavigationViewModel.swift +++ b/Ferrite/ViewModels/NavigationViewModel.swift @@ -33,6 +33,9 @@ class NavigationViewModel: ObservableObject { @Published var isSearching: Bool = false @Published var selectedSearchResult: SearchResult? + @Published var selectedMagnetLink: String? + @Published var selectedHistoryInfo: HistoryEntryJson? + @Published var resultFromCloud: Bool = false // For giving information in magnet choice sheet @Published var selectedTitle: String = "" @@ -124,6 +127,7 @@ class NavigationViewModel: ObservableObject { } } + /* public func addToHistory(name: String?, source: String?, url: String?, subName: String? = nil) { let backgroundContext = PersistenceController.shared.backgroundContext @@ -141,4 +145,5 @@ class NavigationViewModel: ObservableObject { PersistenceController.shared.save(backgroundContext) } + */ } diff --git a/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift b/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift index 6d1bdcc..21740b1 100644 --- a/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift +++ b/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift @@ -10,27 +10,36 @@ import SwiftUI struct DebridLabelView: View { @EnvironmentObject var debridManager: DebridManager - var result: SearchResult - - let debridAbbreviation: String + @State var cloudLinks: [String] = [] + var magnetHash: String? var body: some View { - Text(debridAbbreviation) - .fontWeight(.bold) - .padding(2) - .background { - Group { - switch debridManager.matchSearchResult(result: result) { - case .full: - Color.green - case .partial: - Color.orange - case .none: - Color.red + if let selectedDebridType = debridManager.selectedDebridType { + Text(selectedDebridType.toString(abbreviated: true)) + .fontWeight(.bold) + .padding(2) + .background { + Group { + if cloudLinks.isEmpty { + switch debridManager.matchMagnetHash(magnetHash) { + case .full: + Color.green + case .partial: + Color.orange + case .none: + Color.red + } + } else if cloudLinks.count == 1 { + Color.green + } else if cloudLinks.count > 1 { + Color.orange + } else { + Color.red + } } + .cornerRadius(4) + .opacity(0.5) } - .cornerRadius(4) - .opacity(0.5) - } + } } } diff --git a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift index 10ad01d..9167a78 100644 --- a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift +++ b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift @@ -31,7 +31,7 @@ struct BookmarksView: View { if let bookmark = bookmarks[safe: index] { PersistenceController.shared.delete(bookmark, context: backgroundContext) - NotificationCenter.default.post(name: .didDeleteBookmark, object: nil) + NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark) } } } @@ -55,8 +55,8 @@ struct BookmarksView: View { if debridManager.enabledDebrids.count > 0 { viewTask = Task { let magnets = bookmarks.compactMap { - if let magnetLink = $0.magnetLink, let magnetHash = $0.magnetHash { - return Magnet(link: magnetLink, hash: magnetHash) + if let magnetHash = $0.magnetHash { + return Magnet(link: $0.magnetLink, hash: magnetHash) } else { return nil } diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift new file mode 100644 index 0000000..3397479 --- /dev/null +++ b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift @@ -0,0 +1,143 @@ +// +// RealDebridCloudView.swift +// Ferrite +// +// Created by Brian Dashore on 12/31/22. +// + +import SwiftUI + +struct RealDebridCloudView: View { + @EnvironmentObject var navModel: NavigationViewModel + @EnvironmentObject var debridManager: DebridManager + + @State private var viewTask: Task? + + var body: some View { + Group { + DisclosureGroup("Downloads") { + ForEach(debridManager.realDebridCloudDownloads, id: \.self) { downloadResponse in + Button(downloadResponse.filename) { + navModel.resultFromCloud = true + navModel.selectedTitle = downloadResponse.filename + debridManager.downloadUrl = downloadResponse.link + + PersistenceController.shared.createHistory( + HistoryEntryJson( + name: downloadResponse.filename, + url: downloadResponse.link, + source: DebridType.realDebrid.toString() + ) + ) + + navModel.runDebridAction(urlString: debridManager.downloadUrl) + } + .backport.tint(.primary) + } + .onDelete { offsets in + for index in offsets { + if let downloadResponse = debridManager.realDebridCloudDownloads[safe: index] { + Task { + do { + try await debridManager.realDebrid.deleteDownload(debridID: downloadResponse.id) + + // Bypass TTL to get current RD values + await debridManager.fetchRdCloud(bypassTTL: true) + } catch { + print(error) + } + } + } + } + } + } + + DisclosureGroup("Torrents") { + ForEach(debridManager.realDebridCloudTorrents, id: \.self) { torrentResponse in + Button { + Task { + if torrentResponse.status == "downloaded" && !torrentResponse.links.isEmpty { + navModel.resultFromCloud = true + navModel.selectedTitle = torrentResponse.filename + + var historyInfo = HistoryEntryJson( + name: torrentResponse.filename, + source: DebridType.realDebrid.toString() + ) + + if torrentResponse.links.count == 1 { + if let downloadLink = torrentResponse.links[safe: 0] { + do { + try await debridManager.checkRdUserDownloads(userTorrentLink: downloadLink) + navModel.selectedTitle = torrentResponse.filename + historyInfo.url = downloadLink + + PersistenceController.shared.createHistory(historyInfo) + navModel.currentChoiceSheet = .magnet + } catch { + debridManager.toastModel?.updateToastDescription("RealDebrid cloud fetch error: \(error)") + } + } + } else { + debridManager.clearIAValues() + await debridManager.populateDebridIA([Magnet(link: nil, hash: torrentResponse.hash)]) + + if debridManager.selectDebridResult(magnetHash: torrentResponse.hash) { + navModel.selectedHistoryInfo = historyInfo + navModel.currentChoiceSheet = .batch + } + } + } + } + } label: { + VStack(alignment: .leading, spacing: 10) { + Text(torrentResponse.filename) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(4) + + HStack { + Text(torrentResponse.status.capitalizingFirstLetter()) + Spacer() + DebridLabelView(cloudLinks: torrentResponse.links) + } + .font(.caption) + } + } + .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) + .backport.tint(.primary) + } + .onDelete { offsets in + for index in offsets { + if let torrentResponse = debridManager.realDebridCloudTorrents[safe: index] { + Task { + do { + try await debridManager.realDebrid.deleteTorrent(debridID: torrentResponse.id) + + // Bypass TTL to get current RD values + await debridManager.fetchRdCloud(bypassTTL: true) + } catch { + print(error) + } + } + } + } + } + } + } + .onAppear { + viewTask = Task { + await debridManager.fetchRdCloud() + } + } + .onDisappear { + viewTask?.cancel() + } + } +} + +struct RealDebridCloudView_Previews: PreviewProvider { + static var previews: some View { + RealDebridCloudView() + } +} diff --git a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift new file mode 100644 index 0000000..54125f0 --- /dev/null +++ b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift @@ -0,0 +1,31 @@ +// +// DebridCloudView.swift +// Ferrite +// +// Created by Brian Dashore on 12/31/22. +// + +import SwiftUI + +struct DebridCloudView: View { + @EnvironmentObject var debridManager: DebridManager + + var body: some View { + List { + switch debridManager.selectedDebridType { + case .realDebrid: + RealDebridCloudView() + case .allDebrid, .premiumize, .none: + EmptyView() + } + } + .inlinedList() + .listStyle(.insetGrouped) + } +} + +struct DebridCloudView_Previews: PreviewProvider { + static var previews: some View { + DebridCloudView() + } +} diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift index debe4a1..ce424b3 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift @@ -24,15 +24,23 @@ struct SearchResultButtonView: View { if debridManager.currentDebridTask == nil { navModel.selectedSearchResult = result navModel.selectedTitle = result.title ?? "" + navModel.resultFromCloud = false - switch debridManager.matchSearchResult(result: result) { + switch debridManager.matchMagnetHash(result.magnetHash) { case .full: - if debridManager.selectDebridResult(result: result) { + if debridManager.selectDebridResult(magnetHash: result.magnetHash) { debridManager.currentDebridTask = Task { - await debridManager.fetchDebridDownload(searchResult: result) + await debridManager.fetchDebridDownload(magnetLink: result.magnetLink) if !debridManager.downloadUrl.isEmpty { - navModel.addToHistory(name: result.title, source: result.source, url: debridManager.downloadUrl) + PersistenceController.shared.createHistory( + HistoryEntryJson( + name: result.title, + url: debridManager.downloadUrl, + source: result.source + ) + ) + navModel.runDebridAction(urlString: debridManager.downloadUrl) if navModel.currentChoiceSheet != .magnet { @@ -42,11 +50,18 @@ struct SearchResultButtonView: View { } } case .partial: - if debridManager.selectDebridResult(result: result) { + if debridManager.selectDebridResult(magnetHash: result.magnetHash) { navModel.currentChoiceSheet = .batch } case .none: - navModel.addToHistory(name: result.title, source: result.source, url: result.magnetLink) + PersistenceController.shared.createHistory( + HistoryEntryJson( + name: result.title, + url: result.magnetLink, + source: result.source + ) + ) + navModel.runMagnetAction(magnetString: result.magnetLink) } } diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift index cceec97..86d1751 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift @@ -30,17 +30,7 @@ struct SearchResultInfoView: View { Text(size) } - if debridManager.selectedDebridType == .realDebrid { - DebridLabelView(result: result, debridAbbreviation: "RD") - } - - if debridManager.selectedDebridType == .allDebrid { - DebridLabelView(result: result, debridAbbreviation: "AD") - } - - if debridManager.selectedDebridType == .premiumize { - DebridLabelView(result: result, debridAbbreviation: "PM") - } + DebridLabelView(magnetHash: result.magnetHash) } .font(.caption) } diff --git a/Ferrite/Views/ContentView.swift b/Ferrite/Views/ContentView.swift index 145ea41..10c15d0 100644 --- a/Ferrite/Views/ContentView.swift +++ b/Ferrite/Views/ContentView.swift @@ -87,12 +87,12 @@ struct ContentView: View { await scrapingModel.scanSources(sources: sources) if debridManager.enabledDebrids.count > 0, !scrapingModel.searchResults.isEmpty { - debridManager.realDebridIAValues = [] - debridManager.allDebridIAValues = [] + debridManager.clearIAValues() + // Remove magnets that don't have a hash let magnets = scrapingModel.searchResults.compactMap { - if let magnetLink = $0.magnetLink, let magnetHash = $0.magnetHash { - return Magnet(link: magnetLink, hash: magnetHash) + if let magnetHash = $0.magnetHash { + return Magnet(link: $0.magnetLink, hash: magnetHash) } else { return nil } diff --git a/Ferrite/Views/LibraryView.swift b/Ferrite/Views/LibraryView.swift index 87e3e1f..4824e79 100644 --- a/Ferrite/Views/LibraryView.swift +++ b/Ferrite/Views/LibraryView.swift @@ -11,9 +11,11 @@ struct LibraryView: View { enum LibraryPickerSegment { case bookmarks case history + case debridCloud } @EnvironmentObject var navModel: NavigationViewModel + @EnvironmentObject var debridManager: DebridManager @FetchRequest( entity: Bookmark.entity(), @@ -40,6 +42,10 @@ struct LibraryView: View { Picker("Segments", selection: $selectedSegment) { Text("Bookmarks").tag(LibraryPickerSegment.bookmarks) Text("History").tag(LibraryPickerSegment.history) + + if !debridManager.enabledDebrids.isEmpty { + Text("Cloud").tag(LibraryPickerSegment.debridCloud) + } } .pickerStyle(.segmented) .padding() @@ -49,6 +55,8 @@ struct LibraryView: View { BookmarksView(bookmarks: bookmarks) case .history: HistoryView(history: history) + case .debridCloud: + DebridCloudView() } Spacer() @@ -63,6 +71,10 @@ struct LibraryView: View { if history.isEmpty { EmptyInstructionView(title: "No History", message: "Start watching to build history") } + case .debridCloud: + if debridManager.selectedDebridType != .realDebrid { + EmptyInstructionView(title: "Cloud Unavailable", message: "Listing is not available for this service") + } } } .navigationTitle("Library") @@ -72,7 +84,7 @@ struct LibraryView: View { EditButton() switch selectedSegment { - case .bookmarks: + case .bookmarks, .debridCloud: DebridChoiceView() case .history: HistoryActionsView() diff --git a/Ferrite/Views/SheetViews/BatchChoiceView.swift b/Ferrite/Views/SheetViews/BatchChoiceView.swift index d527ba6..90ecd01 100644 --- a/Ferrite/Views/SheetViews/BatchChoiceView.swift +++ b/Ferrite/Views/SheetViews/BatchChoiceView.swift @@ -58,7 +58,8 @@ struct BatchChoiceView: View { Task { try? await Task.sleep(seconds: 1) - debridManager.selectedRealDebridItem = nil + + debridManager.clearSelectedDebridItems() } } } @@ -68,36 +69,22 @@ struct BatchChoiceView: View { // Common function to communicate betwen VMs and queue/display a download func queueCommonDownload(fileName: String) { - if let searchResult = navModel.selectedSearchResult { - debridManager.currentDebridTask = Task { - await debridManager.fetchDebridDownload(searchResult: searchResult) + debridManager.currentDebridTask = Task { + await debridManager.fetchDebridDownload(magnetLink: navModel.resultFromCloud ? nil : navModel.selectedMagnetLink) - if !debridManager.downloadUrl.isEmpty { - try? await Task.sleep(seconds: 1) - navModel.selectedBatchTitle = fileName - navModel.addToHistory( - name: searchResult.title, - source: searchResult.source, - url: debridManager.downloadUrl, - subName: fileName - ) - navModel.runDebridAction(urlString: debridManager.downloadUrl) + if !debridManager.downloadUrl.isEmpty { + try? await Task.sleep(seconds: 1) + navModel.selectedBatchTitle = fileName + + if var selectedHistoryInfo = navModel.selectedHistoryInfo { + selectedHistoryInfo.url = debridManager.downloadUrl + PersistenceController.shared.createHistory(selectedHistoryInfo) } - switch debridManager.selectedDebridType { - case .realDebrid: - 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 - } + navModel.runDebridAction(urlString: debridManager.downloadUrl) } + + debridManager.clearSelectedDebridItems() } navModel.currentChoiceSheet = nil diff --git a/Ferrite/Views/SheetViews/MagnetChoiceView.swift b/Ferrite/Views/SheetViews/MagnetChoiceView.swift index dfef583..aaeaf31 100644 --- a/Ferrite/Views/SheetViews/MagnetChoiceView.swift +++ b/Ferrite/Views/SheetViews/MagnetChoiceView.swift @@ -71,30 +71,31 @@ struct MagnetChoiceView: View { } } - Section(header: "Magnet options") { - ListRowButtonView("Copy magnet", systemImage: "doc.on.doc.fill") { - UIPasteboard.general.string = navModel.selectedSearchResult?.magnetLink - showMagnetCopyAlert.toggle() - } - .backport.alert( - isPresented: $showMagnetCopyAlert, - title: "Copied", - message: "Magnet link copied successfully", - buttons: [AlertButton("OK")] - ) - - ListRowButtonView("Share magnet", systemImage: "square.and.arrow.up.fill") { - if let result = navModel.selectedSearchResult, - let magnetLink = result.magnetLink, - let url = URL(string: magnetLink) - { - navModel.activityItems = [url] - navModel.showLocalActivitySheet.toggle() + if !navModel.resultFromCloud { + Section(header: "Magnet options") { + ListRowButtonView("Copy magnet", systemImage: "doc.on.doc.fill") { + UIPasteboard.general.string = navModel.selectedMagnetLink + showMagnetCopyAlert.toggle() } - } + .backport.alert( + isPresented: $showMagnetCopyAlert, + title: "Copied", + message: "Magnet link copied successfully", + buttons: [AlertButton("OK")] + ) - ListRowButtonView("Open in WebTor", systemImage: "arrow.up.forward.app.fill") { - navModel.runMagnetAction(magnetString: navModel.selectedSearchResult?.magnetLink, .webtor) + ListRowButtonView("Share magnet", systemImage: "square.and.arrow.up.fill") { + if let magnetLink = navModel.selectedMagnetLink, + let url = URL(string: magnetLink) + { + navModel.activityItems = [url] + navModel.showLocalActivitySheet.toggle() + } + } + + ListRowButtonView("Open in WebTor", systemImage: "arrow.up.forward.app.fill") { + navModel.runMagnetAction(magnetString: navModel.selectedMagnetLink, .webtor) + } } } } @@ -111,6 +112,7 @@ struct MagnetChoiceView: View { debridManager.downloadUrl = "" navModel.selectedTitle = "" navModel.selectedBatchTitle = "" + navModel.resultFromCloud = false } .navigationTitle("Link actions") .navigationBarTitleDisplayMode(.inline) -- 2.45.2 From 9f54397b77b7dbb1f6f2032d119dfd8218a9ad4f Mon Sep 17 00:00:00 2001 From: kingbri Date: Tue, 3 Jan 2023 14:22:20 -0500 Subject: [PATCH 11/19] Library: Add support for Premiumize cloud Add the ability to view a user's Premiumize files in Ferrite. Files can be deleted from a user's account directly in Ferrite's list. Signed-off-by: kingbri --- Ferrite.xcodeproj/project.pbxproj | 4 + Ferrite/API/PremiumizeWrapper.swift | 56 ++++++++++- Ferrite/Models/PremiumizeModels.swift | 36 ++++++- Ferrite/ViewModels/DebridManager.swift | 96 +++++++++++++++---- .../Library/Cloud/PremiumizeCloudView.swift | 70 ++++++++++++++ .../Library/Cloud/RealDebridCloudView.swift | 18 +--- .../Library/DebridCloudView.swift | 23 +++-- Ferrite/Views/LibraryView.swift | 2 +- 8 files changed, 258 insertions(+), 47 deletions(-) create mode 100644 Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index 3748eb0..2159b51 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -100,6 +100,7 @@ 0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */; }; 0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA429F728C5098D000D0610 /* DateFormatter.swift */; }; 0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; }; + 0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAF9318296399190050812A /* PremiumizeCloudView.swift */; }; 0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516228C5A57300DCA721 /* ConditionalId.swift */; }; 0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516428C5A5D700DCA721 /* InlinedList.swift */; }; 0CB6516828C5A5EC00DCA721 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 0CB6516728C5A5EC00DCA721 /* Introspect */; }; @@ -205,6 +206,7 @@ 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = ""; }; 0CA429F728C5098D000D0610 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = ""; }; 0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0CAF9318296399190050812A /* PremiumizeCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeCloudView.swift; sourceTree = ""; }; 0CB6516228C5A57300DCA721 /* ConditionalId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalId.swift; sourceTree = ""; }; 0CB6516428C5A5D700DCA721 /* InlinedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinedList.swift; sourceTree = ""; }; 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineHeader.swift; sourceTree = ""; }; @@ -314,6 +316,7 @@ isa = PBXGroup; children = ( 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */, + 0CAF9318296399190050812A /* PremiumizeCloudView.swift */, ); path = Cloud; sourceTree = ""; @@ -679,6 +682,7 @@ 0CD72E17293D9928001A7EA4 /* Array.swift in Sources */, 0C44E2A828D4DDDC007711AE /* Application.swift in Sources */, 0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */, + 0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */, 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */, 0C95D8D828A55B03005E22B3 /* DefaultActionsPickerViews.swift in Sources */, 0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */, diff --git a/Ferrite/API/PremiumizeWrapper.swift b/Ferrite/API/PremiumizeWrapper.swift index 8764c3d..3c2f72f 100644 --- a/Ferrite/API/PremiumizeWrapper.swift +++ b/Ferrite/API/PremiumizeWrapper.swift @@ -71,7 +71,7 @@ public class Premiumize { 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.") + 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).") } @@ -177,4 +177,58 @@ public class Premiumize { throw PMError.EmptyData } } + + func createTransfer(magnetLink: String) async throws { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var bodyComponents = URLComponents() + bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnetLink)] + + request.httpBody = bodyComponents.query?.data(using: .utf8) + + try await performRequest(request: &request, requestName: #function) + } + + func userItems() async throws -> [UserItem] { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/listall")!) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(AllItemsResponse.self, from: data) + + if rawResponse.files.isEmpty { + throw PMError.EmptyData + } + + return rawResponse.files + } + + func itemDetails(itemID: String) async throws -> ItemDetailsResponse { + var urlComponents = URLComponents(string: "\(baseApiUrl)/item/details")! + urlComponents.queryItems = [URLQueryItem(name: "id", value: itemID)] + 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(ItemDetailsResponse.self, from: data) + + return rawResponse + } + + func deleteItem(itemID: String) async throws { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/delete")!) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var bodyComponents = URLComponents() + bodyComponents.queryItems = [URLQueryItem(name: "id", value: itemID)] + + request.httpBody = bodyComponents.query?.data(using: .utf8) + + try await performRequest(request: &request, requestName: #function) + } } diff --git a/Ferrite/Models/PremiumizeModels.swift b/Ferrite/Models/PremiumizeModels.swift index 4a1daca..8beaa9c 100644 --- a/Ferrite/Models/PremiumizeModels.swift +++ b/Ferrite/Models/PremiumizeModels.swift @@ -39,7 +39,7 @@ public extension Premiumize { let filesize: Int } - // MARK: - Content + // MARK: Content struct DDLData: Codable { let path: String @@ -65,4 +65,38 @@ public extension Premiumize { let name: String let streamUrlString: String } + + // MARK: - AllItemsResponse (listall endpoint) + struct AllItemsResponse: Codable { + let status: String + let files: [UserItem] + } + + // MARK: User Items + // Abridged for required parameters + struct UserItem: Codable { + let id: String + let name: String + let mimeType: String + + enum CodingKeys: String, CodingKey { + case id, name + case mimeType = "mime_type" + } + } + + // MARK: - ItemDetailsResponse + + // Abridged for required parameters + struct ItemDetailsResponse: Codable { + let id: String + let name: String + let link: String + let mimeType: String + + enum CodingKeys: String, CodingKey { + case id, name, link + case mimeType = "mime_type" + } + } } diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 020335a..9ceef73 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -73,6 +73,10 @@ public class DebridManager: ObservableObject { var selectedPremiumizeItem: Premiumize.IA? var selectedPremiumizeFile: Premiumize.IAFile? + // Premiumize cloud variables + @Published var premiumizeCloudItems: [Premiumize.UserItem] = [] + var premiumizeCloudTTL: Double = 0.0 + init() { if let rawDebridList = UserDefaults.standard.string(forKey: "Debrid.EnabledArray"), let serializedDebridList = Set(rawValue: rawDebridList) @@ -481,7 +485,7 @@ public class DebridManager: ObservableObject { case .allDebrid: await fetchAdDownload(magnetLink: magnetLink) case .premiumize: - fetchPmDownload() + await fetchPmDownload() case .none: break } @@ -544,7 +548,7 @@ public class DebridManager: ObservableObject { toastModel?.updateToastDescription("RealDebrid download error: \(error)") } - await deleteRdTorrent() + await deleteRdTorrent(torrentID: selectedRealDebridID) } showLoadingProgress = false @@ -564,16 +568,36 @@ public class DebridManager: ObservableObject { realDebridCloudTTL = Date().timeIntervalSince1970 + 300 } catch { toastModel?.updateToastDescription("RealDebrid cloud fetch error: \(error)") + print("RealDebrid cloud fetch error: \(error)") } } } - func deleteRdTorrent() async { - if let realDebridId = selectedRealDebridID { - try? await realDebrid.deleteTorrent(debridID: realDebridId) - } + func deleteRdDownload(downloadID: String) async { + do { + try await realDebrid.deleteDownload(debridID: downloadID) - selectedRealDebridID = nil + // Bypass TTL to get current RD values + await fetchRdCloud(bypassTTL: true) + } catch { + toastModel?.updateToastDescription("RealDebrid download delete error: \(error)") + print("RealDebrid download delete error: \(error)") + } + } + + func deleteRdTorrent(torrentID: String? = nil) async { + do { + if let torrentID = torrentID { + try await realDebrid.deleteTorrent(debridID: torrentID) + } else if let selectedTorrentID = selectedRealDebridID { + try await realDebrid.deleteTorrent(debridID: selectedTorrentID) + } else { + throw RealDebrid.RDError.FailedRequest(description: "No torrent ID was provided") + } + } catch { + toastModel?.updateToastDescription("RealDebrid torrent delete error: \(error)") + print("RealDebrid torrent delete error: \(error)") + } } func checkRdUserDownloads(userTorrentLink: String) async throws { @@ -615,21 +639,53 @@ 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 + func fetchPmDownload(cloudItemId: String? = nil) async { + do { + if let cloudItemId = cloudItemId { + downloadUrl = try await premiumize.itemDetails(itemID: cloudItemId).link + } else if let premiumizeFile = selectedPremiumizeFile { + downloadUrl = premiumizeFile.streamUrlString + } else if + let premiumizeItem = selectedPremiumizeItem, + let firstFile = premiumizeItem.files[safe: 0] + { + downloadUrl = firstFile.streamUrlString + } else { + throw Premiumize.PMError.FailedRequest(description: "There were no items or files found!") + } + } catch { + toastModel?.updateToastDescription("Premiumize download error: \(error)") + print("Premiumize download error: \(error)") } + } - 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") + // Refreshes items and fetches from a PM user account + public func fetchPmCloud(bypassTTL: Bool = false) async { + if bypassTTL || Date().timeIntervalSince1970 > premiumizeCloudTTL { + do { + let userItems = try await premiumize.userItems() + withAnimation { + premiumizeCloudItems = userItems + } + + // 5 minutes + premiumizeCloudTTL = Date().timeIntervalSince1970 + 300 + } catch { + toastModel?.updateToastDescription("Premiumize cloud fetch error: \(error)") + print("Premiumize cloud fetch error: \(error)") + } + } + } + + public func deletePmItem(id: String) async { + do { + try await premiumize.deleteItem(itemID: id) + + // Bypass TTL to get current RD values + await fetchPmCloud(bypassTTL: true) + } catch { + toastModel?.updateToastDescription("Premiumize cloud delete error: \(error)") + print("Premiumize cloud delete error: \(error)") } } } diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift new file mode 100644 index 0000000..f8d7969 --- /dev/null +++ b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift @@ -0,0 +1,70 @@ +// +// PremiumizeCloudView.swift +// Ferrite +// +// Created by Brian Dashore on 1/2/23. +// + +import SwiftUI +import SwiftUIX + +struct PremiumizeCloudView: View { + @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var navModel: NavigationViewModel + + @State private var viewTask: Task? + + @State private var searchText: String = "" + + var body: some View { + DisclosureGroup("Items") { + ForEach(debridManager.premiumizeCloudItems, id: \.id) { item in + Button(item.name) { + Task { + navModel.resultFromCloud = true + navModel.selectedTitle = item.name + + await debridManager.fetchPmDownload(cloudItemId: item.id) + + if !debridManager.downloadUrl.isEmpty { + PersistenceController.shared.createHistory( + HistoryEntryJson( + name: item.name, + url: debridManager.downloadUrl, + source: "Premiumize" + ) + ) + + navModel.runDebridAction(urlString: debridManager.downloadUrl) + } + } + } + .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) + .backport.tint(.black) + } + .onDelete { offsets in + for index in offsets { + if let item = debridManager.premiumizeCloudItems[safe: index] { + Task { + await debridManager.deletePmItem(id: item.id) + } + } + } + } + } + .onAppear { + viewTask = Task { + await debridManager.fetchPmCloud() + } + } + .onDisappear { + viewTask?.cancel() + } + } +} + +struct PremiumizeCloudView_Previews: PreviewProvider { + static var previews: some View { + PremiumizeCloudView() + } +} diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift index 3397479..1dd1413 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift @@ -38,14 +38,7 @@ struct RealDebridCloudView: View { for index in offsets { if let downloadResponse = debridManager.realDebridCloudDownloads[safe: index] { Task { - do { - try await debridManager.realDebrid.deleteDownload(debridID: downloadResponse.id) - - // Bypass TTL to get current RD values - await debridManager.fetchRdCloud(bypassTTL: true) - } catch { - print(error) - } + await debridManager.deleteRdDownload(downloadID: downloadResponse.id) } } } @@ -111,14 +104,7 @@ struct RealDebridCloudView: View { for index in offsets { if let torrentResponse = debridManager.realDebridCloudTorrents[safe: index] { Task { - do { - try await debridManager.realDebrid.deleteTorrent(debridID: torrentResponse.id) - - // Bypass TTL to get current RD values - await debridManager.fetchRdCloud(bypassTTL: true) - } catch { - print(error) - } + await debridManager.deleteRdTorrent(torrentID: torrentResponse.id) } } } diff --git a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift index 54125f0..0ee250c 100644 --- a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift @@ -6,21 +6,28 @@ // import SwiftUI +import SwiftUIX struct DebridCloudView: View { @EnvironmentObject var debridManager: DebridManager var body: some View { - List { - switch debridManager.selectedDebridType { - case .realDebrid: - RealDebridCloudView() - case .allDebrid, .premiumize, .none: - EmptyView() + NavView { + VStack { + List { + switch debridManager.selectedDebridType { + case .realDebrid: + RealDebridCloudView() + case .premiumize: + PremiumizeCloudView() + case .allDebrid, .none: + EmptyView() + } + } + .inlinedList() + .listStyle(.grouped) } } - .inlinedList() - .listStyle(.insetGrouped) } } diff --git a/Ferrite/Views/LibraryView.swift b/Ferrite/Views/LibraryView.swift index 4824e79..3d2e3fe 100644 --- a/Ferrite/Views/LibraryView.swift +++ b/Ferrite/Views/LibraryView.swift @@ -72,7 +72,7 @@ struct LibraryView: View { EmptyInstructionView(title: "No History", message: "Start watching to build history") } case .debridCloud: - if debridManager.selectedDebridType != .realDebrid { + if debridManager.selectedDebridType == nil || debridManager.selectedDebridType == .allDebrid { EmptyInstructionView(title: "Cloud Unavailable", message: "Listing is not available for this service") } } -- 2.45.2 From 5a4e98f10d1273746a835aa90e20470557c0afa8 Mon Sep 17 00:00:00 2001 From: kingbri Date: Wed, 4 Jan 2023 12:49:20 -0500 Subject: [PATCH 12/19] Ferrite: Switch to a Magnet struct Magnets are expressed in two different ways: a hash and a link. Both of these mean the same thing with a magnet link giving more information if required. However, there was a disconnect if a hash was present or a link was present and required many steps to check which was available. Unify magnets by creating a parent structure that attempts to extract the hash or create a link in the event that either parameter isn't provided. Replace everything except bookmarks (to prevent CoreData complaints and unnecessary abstraction) to use the new Magnet system. Signed-off-by: kingbri --- Ferrite/API/AllDebridWrapper.swift | 8 +- Ferrite/API/PremiumizeWrapper.swift | 12 ++- Ferrite/API/RealDebridWrapper.swift | 12 ++- .../Classes/Bookmark+CoreDataClass.swift | 3 +- Ferrite/Models/AllDebridModels.swift | 2 +- Ferrite/Models/BackupModels.swift | 12 ++- Ferrite/Models/DebridManagerModels.swift | 62 ++++++++++++- Ferrite/Models/PremiumizeModels.swift | 2 +- Ferrite/Models/RealDebridModels.swift | 2 +- Ferrite/Models/SearchModels.swift | 3 +- Ferrite/ViewModels/BackupManager.swift | 5 +- Ferrite/ViewModels/DebridManager.swift | 52 +++++------ Ferrite/ViewModels/NavigationViewModel.swift | 33 ++----- Ferrite/ViewModels/ScrapingViewModel.swift | 91 ++----------------- .../Debrid/DebridLabelView.swift | 6 +- .../Library/BookmarksView.swift | 2 +- .../Library/Cloud/RealDebridCloudView.swift | 9 +- .../Library/HistoryButtonView.swift | 2 +- .../SearchResult/SearchResultButtonView.swift | 22 ++--- .../SearchResult/SearchResultInfoView.swift | 2 +- Ferrite/Views/ContentView.swift | 4 +- .../Views/SheetViews/BatchChoiceView.swift | 2 +- .../Views/SheetViews/MagnetChoiceView.swift | 6 +- 23 files changed, 175 insertions(+), 179 deletions(-) diff --git a/Ferrite/API/AllDebridWrapper.swift b/Ferrite/API/AllDebridWrapper.swift index 8a868c6..3f76226 100644 --- a/Ferrite/API/AllDebridWrapper.swift +++ b/Ferrite/API/AllDebridWrapper.swift @@ -125,7 +125,11 @@ public class AllDebrid { } // Adds a magnet link to the user's AD account - public func addMagnet(magnetLink: String) async throws -> Int { + public func addMagnet(magnet: Magnet) async throws -> Int { + guard let magnetLink = magnet.link else { + throw ADError.FailedRequest(description: "The magnet link is invalid") + } + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload")) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") @@ -192,7 +196,7 @@ public class AllDebrid { } return IA( - hash: magnetResp.hash, + magnet: Magnet(hash: magnetResp.hash, link: magnetResp.magnet), expiryTimeStamp: Date().timeIntervalSince1970 + 300, files: files ) diff --git a/Ferrite/API/PremiumizeWrapper.swift b/Ferrite/API/PremiumizeWrapper.swift index 3c2f72f..ccd4207 100644 --- a/Ferrite/API/PremiumizeWrapper.swift +++ b/Ferrite/API/PremiumizeWrapper.swift @@ -148,6 +148,10 @@ public class Premiumize { // Grabs DDL links func fetchDDL(magnet: Magnet) async throws -> IA { + if magnet.hash == nil { + throw PMError.EmptyData + } + var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") @@ -169,7 +173,7 @@ public class Premiumize { } return IA( - hash: magnet.hash, + magnet: magnet, expiryTimeStamp: Date().timeIntervalSince1970 + 300, files: files ) @@ -178,7 +182,11 @@ public class Premiumize { } } - func createTransfer(magnetLink: String) async throws { + func createTransfer(magnet: Magnet) async throws { + guard let magnetLink = magnet.link else { + throw PMError.FailedRequest(description: "The magnet link is invalid") + } + var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index 0f3f9bc..9b92a58 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -188,7 +188,7 @@ public class RealDebrid { // Currently does not work for batch links public func instantAvailability(magnets: [Magnet]) async throws -> [IA] { var availableHashes: [RealDebrid.IA] = [] - var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnets.map(\.hash).joined(separator: "/"))")!) + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnets.compactMap(\.hash).joined(separator: "/"))")!) let data = try await performRequest(request: &request, requestName: #function) @@ -241,7 +241,7 @@ public class RealDebrid { // TTL: 5 minutes availableHashes.append( RealDebrid.IA( - hash: hash, + magnet: Magnet(hash: hash, link: nil), expiryTimeStamp: Date().timeIntervalSince1970 + 300, files: files, batches: batches @@ -250,7 +250,7 @@ public class RealDebrid { } else { availableHashes.append( RealDebrid.IA( - hash: hash, + magnet: Magnet(hash: hash, link: nil), expiryTimeStamp: Date().timeIntervalSince1970 + 300 ) ) @@ -261,7 +261,11 @@ public class RealDebrid { } // Adds a magnet link to the user's RD account - public func addMagnet(magnetLink: String) async throws -> String { + public func addMagnet(magnet: Magnet) async throws -> String { + guard let magnetLink = magnet.link else { + throw RDError.FailedRequest(description: "The magnet link is invalid") + } + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") diff --git a/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift b/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift index dcd0f86..c12c191 100644 --- a/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift +++ b/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift @@ -16,8 +16,7 @@ public class Bookmark: NSManagedObject { title: title, source: source, size: size, - magnetLink: magnetLink, - magnetHash: magnetHash, + magnet: Magnet(hash: magnetHash, link: magnetLink), seeders: seeders, leechers: leechers ) diff --git a/Ferrite/Models/AllDebridModels.swift b/Ferrite/Models/AllDebridModels.swift index a253027..b37d130 100644 --- a/Ferrite/Models/AllDebridModels.swift +++ b/Ferrite/Models/AllDebridModels.swift @@ -145,7 +145,7 @@ public extension AllDebrid { // MARK: - InstantAvailablity client side structures struct IA: Codable, Hashable { - let hash: String + let magnet: Magnet let expiryTimeStamp: Double var files: [IAFile] } diff --git a/Ferrite/Models/BackupModels.swift b/Ferrite/Models/BackupModels.swift index 37b0f10..210eea8 100644 --- a/Ferrite/Models/BackupModels.swift +++ b/Ferrite/Models/BackupModels.swift @@ -8,6 +8,7 @@ import Foundation public struct Backup: Codable { + let version: Int var bookmarks: [BookmarkJson]? var history: [HistoryJson]? var sourceNames: [String]? @@ -16,7 +17,16 @@ public struct Backup: Codable { // MARK: - CoreData translation -typealias BookmarkJson = SearchResult +// Don't typealias to search result as this is a reflection of CoreData's struct +struct BookmarkJson: Codable { + let title: String? + let source: String + let size: String? + let magnetLink: String? + let magnetHash: String? + let seeders: String? + let leechers: String? +} // Date is an epoch timestamp struct HistoryJson: Codable { diff --git a/Ferrite/Models/DebridManagerModels.swift b/Ferrite/Models/DebridManagerModels.swift index 278d1c0..00cd554 100644 --- a/Ferrite/Models/DebridManagerModels.swift +++ b/Ferrite/Models/DebridManagerModels.swift @@ -6,6 +6,7 @@ // import Foundation +import Base32 // MARK: - Universal IA enum (IA = InstantAvailability) @@ -36,6 +37,63 @@ public enum DebridType: Int, Codable, Hashable, CaseIterable { // 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 + var hash: String? + var link: String? + + init(hash: String?, link: String?, title: String? = nil, trackers: [String]? = nil) { + if let hash = hash, link == nil { + self.hash = parseHash(hash) + self.link = generateLink(hash: hash, title: title, trackers: trackers) + } else if let link = link, hash == nil { + self.link = link + self.hash = parseHash(extractHash(link: link)) + } else { + self.hash = parseHash(hash) + self.link = link + } + } + + func generateLink(hash: String, title: String?, trackers: [String]?) -> String { + var magnetLinkArray = ["magnet:?xt=urn:btih:", hash] + + if let title, let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { + magnetLinkArray.append("&dn=\(encodedTitle)") + } + + if let trackers { + for trackerUrl in trackers { + if URL(string: trackerUrl) != nil, + let encodedUrlString = trackerUrl.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) + { + magnetLinkArray.append("&tr=\(encodedUrlString)") + } + } + } + + return magnetLinkArray.joined() + } + + func extractHash(link: String) -> String? { + if let firstSplit = link.split(separator: ":")[safe: 3], + let tempHash = firstSplit.split(separator: "&")[safe: 0] + { + return String(tempHash) + } else { + return nil + } + } + + // Is this a Base32hex hash? + func parseHash(_ magnetHash: String?) -> String? { + guard let magnetHash else { + return nil + } + + if magnetHash.count == 32 { + let decryptedMagnetHash = base32DecodeToData(String(magnetHash)) + return decryptedMagnetHash?.hexEncodedString() + } else { + return String(magnetHash).lowercased() + } + } } diff --git a/Ferrite/Models/PremiumizeModels.swift b/Ferrite/Models/PremiumizeModels.swift index 8beaa9c..08547e6 100644 --- a/Ferrite/Models/PremiumizeModels.swift +++ b/Ferrite/Models/PremiumizeModels.swift @@ -56,7 +56,7 @@ public extension Premiumize { // MARK: - InstantAvailability client side structures struct IA: Codable, Hashable { - let hash: String + let magnet: Magnet let expiryTimeStamp: Double let files: [IAFile] } diff --git a/Ferrite/Models/RealDebridModels.swift b/Ferrite/Models/RealDebridModels.swift index 2223959..393acb6 100644 --- a/Ferrite/Models/RealDebridModels.swift +++ b/Ferrite/Models/RealDebridModels.swift @@ -93,7 +93,7 @@ public extension RealDebrid { // MARK: - Instant Availability client side structures struct IA: Codable, Hashable, Sendable { - let hash: String + let magnet: Magnet let expiryTimeStamp: Double var files: [IAFile] = [] var batches: [IABatch] = [] diff --git a/Ferrite/Models/SearchModels.swift b/Ferrite/Models/SearchModels.swift index 8ebbe63..d09e0fa 100644 --- a/Ferrite/Models/SearchModels.swift +++ b/Ferrite/Models/SearchModels.swift @@ -11,8 +11,7 @@ public struct SearchResult: Codable, Hashable, Sendable { let title: String? let source: String let size: String? - let magnetLink: String? - let magnetHash: String? + let magnet: Magnet let seeders: String? let leechers: String? } diff --git a/Ferrite/ViewModels/BackupManager.swift b/Ferrite/ViewModels/BackupManager.swift index 5e7c516..bf7e534 100644 --- a/Ferrite/ViewModels/BackupManager.swift +++ b/Ferrite/ViewModels/BackupManager.swift @@ -8,6 +8,9 @@ import Foundation public class BackupManager: ObservableObject { + // Constant variable for backup versions + let latestBackupVersion: Int = 1 + var toastModel: ToastViewModel? @Published var showRestoreAlert = false @@ -18,7 +21,7 @@ public class BackupManager: ObservableObject { @Published var selectedBackupUrl: URL? func createBackup() { - var backup = Backup() + var backup = Backup(version: latestBackupVersion) let backgroundContext = PersistenceController.shared.backgroundContext let bookmarkRequest = Bookmark.fetchRequest() diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 9ceef73..cea77c4 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -148,21 +148,21 @@ public class DebridManager: ObservableObject { // If a hash isn't found in the IA, update it // If the hash is expired, remove it and update it let sendMagnets = resultMagnets.filter { magnet in - if let IAIndex = realDebridIAValues.firstIndex(where: { $0.hash == magnet.hash }), enabledDebrids.contains(.realDebrid) { + if let IAIndex = realDebridIAValues.firstIndex(where: { $0.magnet.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 == magnet.hash }), enabledDebrids.contains(.allDebrid) { + } else if let IAIndex = allDebridIAValues.firstIndex(where: { $0.magnet.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) { + } else if let IAIndex = premiumizeIAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }), enabledDebrids.contains(.premiumize) { if now.timeIntervalSince1970 > premiumizeIAValues[IAIndex].expiryTimeStamp { premiumizeIAValues.remove(at: IAIndex) return true @@ -189,7 +189,7 @@ public class DebridManager: ObservableObject { // Only strip magnets that don't have an associated link for PM let strippedResultMagnets: [Magnet] = resultMagnets.compactMap { if let magnetLink = $0.link { - return Magnet(link: magnetLink, hash: $0.hash) + return Magnet(hash: $0.hash, link: magnetLink) } else { return nil } @@ -217,14 +217,14 @@ public class DebridManager: ObservableObject { } // Common function to match a magnet hash with a provided debrid service - public func matchMagnetHash(_ magnetHash: String?) -> IAStatus { - guard let magnetHash else { + public func matchMagnetHash(_ magnet: Magnet) -> IAStatus { + guard let magnetHash = magnet.hash else { return .none } switch selectedDebridType { case .realDebrid: - guard let realDebridMatch = realDebridIAValues.first(where: { magnetHash == $0.hash }) else { + guard let realDebridMatch = realDebridIAValues.first(where: { magnetHash == $0.magnet.hash }) else { return .none } @@ -234,7 +234,7 @@ public class DebridManager: ObservableObject { return .partial } case .allDebrid: - guard let allDebridMatch = allDebridIAValues.first(where: { magnetHash == $0.hash }) else { + guard let allDebridMatch = allDebridIAValues.first(where: { magnetHash == $0.magnet.hash }) else { return .none } @@ -244,7 +244,7 @@ public class DebridManager: ObservableObject { return .full } case .premiumize: - guard let premiumizeMatch = premiumizeIAValues.first(where: { magnetHash == $0.hash }) else { + guard let premiumizeMatch = premiumizeIAValues.first(where: { magnetHash == $0.magnet.hash }) else { return .none } @@ -258,15 +258,15 @@ public class DebridManager: ObservableObject { } } - public func selectDebridResult(magnetHash: String?) -> Bool { - guard let magnetHash = magnetHash else { + public func selectDebridResult(magnet: Magnet) -> Bool { + guard let magnetHash = magnet.hash else { toastModel?.updateToastDescription("Could not find the torrent magnet hash") return false } switch selectedDebridType { case .realDebrid: - if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.hash }) { + if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.magnet.hash }) { selectedRealDebridItem = realDebridItem return true } else { @@ -274,7 +274,7 @@ public class DebridManager: ObservableObject { return false } case .allDebrid: - if let allDebridItem = allDebridIAValues.first(where: { magnetHash == $0.hash }) { + if let allDebridItem = allDebridIAValues.first(where: { magnetHash == $0.magnet.hash }) { selectedAllDebridItem = allDebridItem return true } else { @@ -282,7 +282,7 @@ public class DebridManager: ObservableObject { return false } case .premiumize: - if let premiumizeItem = premiumizeIAValues.first(where: { magnetHash == $0.hash }) { + if let premiumizeItem = premiumizeIAValues.first(where: { magnetHash == $0.magnet.hash }) { selectedPremiumizeItem = premiumizeItem return true } else { @@ -471,7 +471,7 @@ public class DebridManager: ObservableObject { // MARK: - Debrid fetch UI linked functions // Common function to delegate what debrid service to fetch from - public func fetchDebridDownload(magnetLink: String?) async { + public func fetchDebridDownload(magnet: Magnet?) async { defer { currentDebridTask = nil showLoadingProgress = false @@ -481,9 +481,9 @@ public class DebridManager: ObservableObject { switch selectedDebridType { case .realDebrid: - await fetchRdDownload(magnetLink: magnetLink) + await fetchRdDownload(magnet: magnet) case .allDebrid: - await fetchAdDownload(magnetLink: magnetLink) + await fetchAdDownload(magnet: magnet) case .premiumize: await fetchPmDownload() case .none: @@ -491,22 +491,22 @@ public class DebridManager: ObservableObject { } } - func fetchRdDownload(magnetLink: String?) async { + func fetchRdDownload(magnet: Magnet?) async { do { // Bypass the TTL since a download needs to be queried await fetchRdCloud(bypassTTL: true) // If there's an existing torrent, check for a download link. Otherwise check for an unrestrict link - let existingTorrents = realDebridCloudTorrents.filter { $0.hash == selectedRealDebridItem?.hash && $0.status == "downloaded" } + let existingTorrents = realDebridCloudTorrents.filter { $0.hash == selectedRealDebridItem?.magnet.hash && $0.status == "downloaded" } // If the links match from a user's downloads, no need to re-run a download if let existingTorrent = existingTorrents[safe: 0], let torrentLink = existingTorrent.links[safe: selectedRealDebridFile?.batchFileIndex ?? 0] { try await checkRdUserDownloads(userTorrentLink: torrentLink) - } else if let magnetLink = magnetLink { + } else if let magnet { // Add a magnet after all the cache checks fail - selectedRealDebridID = try await realDebrid.addMagnet(magnetLink: magnetLink) + selectedRealDebridID = try await realDebrid.addMagnet(magnet: magnet) var fileIds: [Int] = [] if let iaFile = selectedRealDebridFile { @@ -611,16 +611,16 @@ public class DebridManager: ObservableObject { } } - func fetchAdDownload(magnetLink: String?) async { - guard let magnetLink = magnetLink else { - toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.") - print("AllDebrid error: Invalid magnet link") + func fetchAdDownload(magnet: Magnet?) async { + guard let magnet else { + toastModel?.updateToastDescription("Could not run your action because the magnet is invalid.") + print("AllDebrid error: Invalid magnet") return } do { - let magnetID = try await allDebrid.addMagnet(magnetLink: magnetLink) + let magnetID = try await allDebrid.addMagnet(magnet: magnet) let lockedLink = try await allDebrid.fetchMagnetStatus( magnetId: magnetID, selectedIndex: selectedAllDebridFile?.id ?? 0 diff --git a/Ferrite/ViewModels/NavigationViewModel.swift b/Ferrite/ViewModels/NavigationViewModel.swift index 04d707c..66aae92 100644 --- a/Ferrite/ViewModels/NavigationViewModel.swift +++ b/Ferrite/ViewModels/NavigationViewModel.swift @@ -32,8 +32,7 @@ class NavigationViewModel: ObservableObject { @Published var isEditingSearch: Bool = false @Published var isSearching: Bool = false - @Published var selectedSearchResult: SearchResult? - @Published var selectedMagnetLink: String? + @Published var selectedMagnet: Magnet? @Published var selectedHistoryInfo: HistoryEntryJson? @Published var resultFromCloud: Bool = false @@ -96,16 +95,18 @@ class NavigationViewModel: ObservableObject { } } - public func runMagnetAction(magnetString: String?, _ action: DefaultMagnetActionType? = nil) { - let selectedAction = action ?? defaultMagnetAction - - guard let magnetLink = magnetString else { + public func runMagnetAction(magnet: Magnet?, _ action: DefaultMagnetActionType? = nil) { + // Fall back to selected magnet if the provided magnet is nil + let magnet = magnet ?? selectedMagnet + guard let magnetLink = magnet?.link else { toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.") print("Magnet action error: The magnet link is invalid.") return } + let selectedAction = action ?? defaultMagnetAction + switch selectedAction { case .none: currentChoiceSheet = .magnet @@ -126,24 +127,4 @@ class NavigationViewModel: ObservableObject { } } } - - /* - public func addToHistory(name: String?, source: String?, url: String?, subName: String? = nil) { - let backgroundContext = PersistenceController.shared.backgroundContext - - // The timeStamp and date are nil because the create function will make them automatically - PersistenceController.shared.createHistory( - entryJson: HistoryEntryJson( - name: name ?? "", - subName: subName, - url: url ?? "", - timeStamp: nil, - source: source - ), - date: nil - ) - - PersistenceController.shared.save(backgroundContext) - } - */ } diff --git a/Ferrite/ViewModels/ScrapingViewModel.swift b/Ferrite/ViewModels/ScrapingViewModel.swift index 92b8c75..e1fdab2 100644 --- a/Ferrite/ViewModels/ScrapingViewModel.swift +++ b/Ferrite/ViewModels/ScrapingViewModel.swift @@ -353,7 +353,7 @@ class ScrapingViewModel: ObservableObject { source: source, existingSearchResult: searchResult ), - let magnetLink = newSearchResult.magnetLink, + let magnetLink = newSearchResult.magnet.link, magnetLink.starts(with: "magnet:"), !tempResults.contains(newSearchResult) { @@ -362,7 +362,7 @@ class ScrapingViewModel: ObservableObject { } } else if let searchResult, - let magnetLink = searchResult.magnetLink, + let magnetLink = searchResult.magnet.link, magnetLink.starts(with: "magnet:"), !tempResults.contains(searchResult) { @@ -374,18 +374,16 @@ class ScrapingViewModel: ObservableObject { } public func parseJsonResult(_ result: JSON, jsonParser: SourceJsonParser, source: Source, existingSearchResult: SearchResult? = nil) -> SearchResult? { - var magnetHash: String? = existingSearchResult?.magnetHash - + var magnetHash: String? = existingSearchResult?.magnet.hash if let magnetHashParser = jsonParser.magnetHash { let rawHash = result[magnetHashParser.query.components(separatedBy: ".")].rawValue if !(rawHash is NSNull) { - magnetHash = fetchMagnetHash(existingHash: String(describing: rawHash)) + magnetHash = String(describing: rawHash) } } var title: String? = existingSearchResult?.title - if let titleParser = jsonParser.title { if let existingTitle = existingSearchResult?.title, let discriminatorQuery = titleParser.discriminator @@ -401,21 +399,13 @@ class ScrapingViewModel: ObservableObject { } } - var link: String? = existingSearchResult?.magnetLink - - if let magnetLinkParser = jsonParser.magnetLink, existingSearchResult?.magnetLink == nil { + var link: String? = existingSearchResult?.magnet.link + if let magnetLinkParser = jsonParser.magnetLink, link == nil { let rawLink = result[magnetLinkParser.query.components(separatedBy: ".")].rawValue link = rawLink is NSNull ? nil : String(describing: rawLink) - } else if let magnetHash { - link = generateMagnetLink(magnetHash: magnetHash, title: title, trackers: source.trackers) - } - - if magnetHash == nil, let href = link { - magnetHash = fetchMagnetHash(magnetLink: href) } var size: String? = existingSearchResult?.size - if let sizeParser = jsonParser.size, existingSearchResult?.size == nil { let rawSize = result[sizeParser.query.components(separatedBy: ".")].rawValue size = rawSize is NSNull ? nil : String(describing: rawSize) @@ -444,8 +434,7 @@ class ScrapingViewModel: ObservableObject { title: title, source: source.name, size: size, - magnetLink: link, - magnetHash: magnetHash, + magnet: Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers), seeders: seeders, leechers: leechers ) @@ -476,15 +465,13 @@ class ScrapingViewModel: ObservableObject { // Parse magnet link or translate hash var magnetHash: String? if let magnetHashParser = rssParser.magnetHash { - let tempHash = try? runRssComplexQuery( + magnetHash = try? runRssComplexQuery( item: item, query: magnetHashParser.query, attribute: magnetHashParser.attribute, discriminator: magnetHashParser.discriminator, regexString: magnetHashParser.regex ) - - magnetHash = fetchMagnetHash(existingHash: tempHash) } var title: String? @@ -507,8 +494,6 @@ class ScrapingViewModel: ObservableObject { discriminator: magnetLinkParser.discriminator, regexString: magnetLinkParser.regex ) - } else if let magnetHash { - link = generateMagnetLink(magnetHash: magnetHash, title: title, trackers: source.trackers) } else { continue } @@ -517,10 +502,6 @@ class ScrapingViewModel: ObservableObject { continue } - if magnetHash == nil { - magnetHash = fetchMagnetHash(magnetLink: href) - } - var size: String? if let sizeParser = rssParser.size { size = try? runRssComplexQuery( @@ -564,8 +545,7 @@ class ScrapingViewModel: ObservableObject { title: title ?? "No title", source: source.name, size: size ?? "", - magnetLink: href, - magnetHash: magnetHash, + magnet: Magnet(hash: magnetHash, link: href, title: title, trackers: source.trackers), seeders: seeders, leechers: leechers ) @@ -673,9 +653,6 @@ class ScrapingViewModel: ObservableObject { continue } - // Fetches the magnet hash - let magnetHash = fetchMagnetHash(magnetLink: href) - // Fetches the episode/movie title var title: String? if let titleParser = htmlParser.title { @@ -743,8 +720,7 @@ class ScrapingViewModel: ObservableObject { title: title ?? "No title", source: source.name, size: size ?? "", - magnetLink: href, - magnetHash: magnetHash, + magnet: Magnet(hash: nil, link: href), seeders: seeders, leechers: leechers ) @@ -786,31 +762,6 @@ class ScrapingViewModel: ObservableObject { } } - // Fetches and possibly converts the magnet hash value to sha1 - public func fetchMagnetHash(magnetLink: String? = nil, existingHash: String? = nil) -> String? { - var magnetHash: String - - if let existingHash { - magnetHash = existingHash - } else if - let magnetLink, - let firstSplit = magnetLink.split(separator: ":")[safe: 3], - let tempHash = firstSplit.split(separator: "&")[safe: 0] - { - magnetHash = String(tempHash) - } else { - return nil - } - - // Is this a Base32hex hash? - if magnetHash.count == 32 { - let decryptedMagnetHash = base32DecodeToData(String(magnetHash)) - return decryptedMagnetHash?.hexEncodedString() - } else { - return String(magnetHash).lowercased() - } - } - func parseSizeString(sizeString: String) -> String? { // Test if the string can be a full integer guard let size = Int(sizeString) else { @@ -833,28 +784,6 @@ class ScrapingViewModel: ObservableObject { } } - public func generateMagnetLink(magnetHash: String, title: String?, trackers: [String]?) -> String { - var magnetLinkArray = ["magnet:?xt=urn:btih:"] - - magnetLinkArray.append(magnetHash) - - if let title, let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { - magnetLinkArray.append("&dn=\(encodedTitle)") - } - - if let trackers { - for trackerUrl in trackers { - if URL(string: trackerUrl) != nil, - let encodedUrlString = trackerUrl.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) - { - magnetLinkArray.append("&tr=\(encodedUrlString)") - } - } - } - - return magnetLinkArray.joined() - } - func cleanApiCreds(api: SourceApi) async { let backgroundContext = PersistenceController.shared.backgroundContext diff --git a/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift b/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift index 21740b1..e6134d7 100644 --- a/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift +++ b/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift @@ -11,7 +11,7 @@ struct DebridLabelView: View { @EnvironmentObject var debridManager: DebridManager @State var cloudLinks: [String] = [] - var magnetHash: String? + var magnet: Magnet? var body: some View { if let selectedDebridType = debridManager.selectedDebridType { @@ -20,8 +20,8 @@ struct DebridLabelView: View { .padding(2) .background { Group { - if cloudLinks.isEmpty { - switch debridManager.matchMagnetHash(magnetHash) { + if let magnet, cloudLinks.isEmpty { + switch debridManager.matchMagnetHash(magnet) { case .full: Color.green case .partial: diff --git a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift index 9167a78..ef8743e 100644 --- a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift +++ b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift @@ -56,7 +56,7 @@ struct BookmarksView: View { viewTask = Task { let magnets = bookmarks.compactMap { if let magnetHash = $0.magnetHash { - return Magnet(link: $0.magnetLink, hash: magnetHash) + return Magnet(hash: magnetHash, link: $0.magnetLink) } else { return nil } diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift index 1dd1413..fb365e6 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift @@ -20,12 +20,12 @@ struct RealDebridCloudView: View { Button(downloadResponse.filename) { navModel.resultFromCloud = true navModel.selectedTitle = downloadResponse.filename - debridManager.downloadUrl = downloadResponse.link + debridManager.downloadUrl = downloadResponse.download PersistenceController.shared.createHistory( HistoryEntryJson( name: downloadResponse.filename, - url: downloadResponse.link, + url: downloadResponse.download, source: DebridType.realDebrid.toString() ) ) @@ -73,9 +73,10 @@ struct RealDebridCloudView: View { } } else { debridManager.clearIAValues() - await debridManager.populateDebridIA([Magnet(link: nil, hash: torrentResponse.hash)]) + let magnet = Magnet(hash: torrentResponse.hash, link: nil) + await debridManager.populateDebridIA([magnet]) - if debridManager.selectDebridResult(magnetHash: torrentResponse.hash) { + if debridManager.selectDebridResult(magnet: magnet) { navModel.selectedHistoryInfo = historyInfo navModel.currentChoiceSheet = .batch } diff --git a/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift index 777b0ed..3a52d2d 100644 --- a/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift +++ b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift @@ -30,7 +30,7 @@ struct HistoryButtonView: View { } } } else { - navModel.runMagnetAction(magnetString: url) + navModel.runMagnetAction(magnet: Magnet(hash: nil, link: url)) } } else { toastModel.updateToastDescription("URL invalid. Cannot load this history entry. Please delete it.") diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift index ce424b3..cec3138 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift @@ -22,15 +22,15 @@ struct SearchResultButtonView: View { var body: some View { Button { if debridManager.currentDebridTask == nil { - navModel.selectedSearchResult = result + navModel.selectedMagnet = result.magnet navModel.selectedTitle = result.title ?? "" navModel.resultFromCloud = false - switch debridManager.matchMagnetHash(result.magnetHash) { + switch debridManager.matchMagnetHash(result.magnet) { case .full: - if debridManager.selectDebridResult(magnetHash: result.magnetHash) { + if debridManager.selectDebridResult(magnet: result.magnet) { debridManager.currentDebridTask = Task { - await debridManager.fetchDebridDownload(magnetLink: result.magnetLink) + await debridManager.fetchDebridDownload(magnet: result.magnet) if !debridManager.downloadUrl.isEmpty { PersistenceController.shared.createHistory( @@ -50,19 +50,19 @@ struct SearchResultButtonView: View { } } case .partial: - if debridManager.selectDebridResult(magnetHash: result.magnetHash) { + if debridManager.selectDebridResult(magnet: result.magnet) { navModel.currentChoiceSheet = .batch } case .none: PersistenceController.shared.createHistory( HistoryEntryJson( name: result.title, - url: result.magnetLink, + url: result.magnet.link, source: result.source ) ) - navModel.runMagnetAction(magnetString: result.magnetLink) + navModel.runMagnetAction(magnet: result.magnet) } } } label: { @@ -95,8 +95,8 @@ struct SearchResultButtonView: View { let newBookmark = Bookmark(context: backgroundContext) newBookmark.title = result.title newBookmark.source = result.source - newBookmark.magnetHash = result.magnetHash - newBookmark.magnetLink = result.magnetLink + newBookmark.magnetHash = result.magnet.hash + newBookmark.magnetLink = result.magnet.link newBookmark.seeders = result.seeders newBookmark.leechers = result.leechers @@ -139,8 +139,8 @@ struct SearchResultButtonView: View { format: "title == %@ AND source == %@ AND magnetLink == %@ AND magnetHash = %@", result.title ?? "", result.source, - result.magnetLink ?? "", - result.magnetHash ?? "" + result.magnet.link ?? "", + result.magnet.hash ?? "" ) bookmarkRequest.fetchLimit = 1 diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift index 86d1751..d42371d 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift @@ -30,7 +30,7 @@ struct SearchResultInfoView: View { Text(size) } - DebridLabelView(magnetHash: result.magnetHash) + DebridLabelView(magnet: result.magnet) } .font(.caption) } diff --git a/Ferrite/Views/ContentView.swift b/Ferrite/Views/ContentView.swift index 10c15d0..b691ba5 100644 --- a/Ferrite/Views/ContentView.swift +++ b/Ferrite/Views/ContentView.swift @@ -91,8 +91,8 @@ struct ContentView: View { // Remove magnets that don't have a hash let magnets = scrapingModel.searchResults.compactMap { - if let magnetHash = $0.magnetHash { - return Magnet(link: $0.magnetLink, hash: magnetHash) + if let magnetHash = $0.magnet.hash { + return Magnet(hash: magnetHash, link: $0.magnet.link) } else { return nil } diff --git a/Ferrite/Views/SheetViews/BatchChoiceView.swift b/Ferrite/Views/SheetViews/BatchChoiceView.swift index 90ecd01..fa1f094 100644 --- a/Ferrite/Views/SheetViews/BatchChoiceView.swift +++ b/Ferrite/Views/SheetViews/BatchChoiceView.swift @@ -70,7 +70,7 @@ struct BatchChoiceView: View { // Common function to communicate betwen VMs and queue/display a download func queueCommonDownload(fileName: String) { debridManager.currentDebridTask = Task { - await debridManager.fetchDebridDownload(magnetLink: navModel.resultFromCloud ? nil : navModel.selectedMagnetLink) + await debridManager.fetchDebridDownload(magnet: navModel.resultFromCloud ? nil : navModel.selectedMagnet) if !debridManager.downloadUrl.isEmpty { try? await Task.sleep(seconds: 1) diff --git a/Ferrite/Views/SheetViews/MagnetChoiceView.swift b/Ferrite/Views/SheetViews/MagnetChoiceView.swift index aaeaf31..1690ddb 100644 --- a/Ferrite/Views/SheetViews/MagnetChoiceView.swift +++ b/Ferrite/Views/SheetViews/MagnetChoiceView.swift @@ -74,7 +74,7 @@ struct MagnetChoiceView: View { if !navModel.resultFromCloud { Section(header: "Magnet options") { ListRowButtonView("Copy magnet", systemImage: "doc.on.doc.fill") { - UIPasteboard.general.string = navModel.selectedMagnetLink + UIPasteboard.general.string = navModel.selectedMagnet?.link showMagnetCopyAlert.toggle() } .backport.alert( @@ -85,7 +85,7 @@ struct MagnetChoiceView: View { ) ListRowButtonView("Share magnet", systemImage: "square.and.arrow.up.fill") { - if let magnetLink = navModel.selectedMagnetLink, + if let magnetLink = navModel.selectedMagnet?.link, let url = URL(string: magnetLink) { navModel.activityItems = [url] @@ -94,7 +94,7 @@ struct MagnetChoiceView: View { } ListRowButtonView("Open in WebTor", systemImage: "arrow.up.forward.app.fill") { - navModel.runMagnetAction(magnetString: navModel.selectedMagnetLink, .webtor) + navModel.runMagnetAction(magnet: navModel.selectedMagnet, .webtor) } } } -- 2.45.2 From 025d3797dc48439dce4391498bd2559895e7aa4f Mon Sep 17 00:00:00 2001 From: kingbri Date: Wed, 4 Jan 2023 14:33:38 -0500 Subject: [PATCH 13/19] Premiumize: Perform a transfer if a link is present This is required for PM's cloud since transfers will also add the files to a user's cloud rather than just fetching the DDL link. Signed-off-by: kingbri --- Ferrite/ViewModels/DebridManager.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index cea77c4..f1f7232 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -653,6 +653,11 @@ public class DebridManager: ObservableObject { } else { throw Premiumize.PMError.FailedRequest(description: "There were no items or files found!") } + + // Add a PM transfer if the item exists + if let premiumizeItem = selectedPremiumizeItem { + try await premiumize.createTransfer(magnet: premiumizeItem.magnet) + } } catch { toastModel?.updateToastDescription("Premiumize download error: \(error)") print("Premiumize download error: \(error)") -- 2.45.2 From 90ed4f83539d0fdbd8052b8dadd4efd519928c89 Mon Sep 17 00:00:00 2001 From: kingbri Date: Thu, 5 Jan 2023 10:59:21 -0500 Subject: [PATCH 14/19] Debrid: Unify errors Error handling can be tedious with debrid because network errors to ignore are present (such as -999). Create a function to properly display the errors to the user and log them via print statments. Signed-off-by: kingbri --- Ferrite/ViewModels/DebridManager.swift | 96 +++++++++++--------------- Ferrite/Views/SettingsView.swift | 4 +- 2 files changed, 42 insertions(+), 58 deletions(-) diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index f1f7232..77267d5 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -116,6 +116,21 @@ public class DebridManager: ObservableObject { } } + // Wrapper function to match error descriptions + // Error can be suppressed to end user but must be printed in logs + func sendDebridError(_ error: Error, prefix: String, presentError: Bool = true, cancelString: String? = nil) async { + let error = error as NSError + if presentError { + if let cancelString, error.code == -999 { + toastModel?.updateToastDescription(cancelString, newToastType: .info) + } else if error.code != -999 { + toastModel?.updateToastDescription("\(prefix): \(error)") + } + } + + print("\(prefix): \(error)") + } + // Cleans all cached IA values in the event of a full IA refresh public func clearIAValues() { realDebridIAValues = [] @@ -206,13 +221,7 @@ public class DebridManager: ObservableObject { } } } catch { - let error = error as NSError - - if error.code != -999 { - toastModel?.updateToastDescription("Hash population error: \(error)") - } - - print("Hash population error: \(error)") + await sendDebridError(error, prefix: "Hash population error") } } @@ -313,7 +322,6 @@ public class DebridManager: ObservableObject { // 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 } @@ -358,11 +366,9 @@ public class DebridManager: ObservableObject { return true } catch { - toastModel?.updateToastDescription("RealDebrid authentication error: \(error)") + await sendDebridError(error, prefix: "RealDebrid authentication error") + realDebrid.authTask?.cancel() - - print("RealDebrid authentication error: \(error)") - return false } } @@ -381,11 +387,9 @@ public class DebridManager: ObservableObject { return true } catch { - toastModel?.updateToastDescription("AllDebrid authentication error: \(error)") + await sendDebridError(error, prefix: "AllDebrid authentication error") + allDebrid.authTask?.cancel() - - print("AllDebrid authentication error: \(error)") - return false } } @@ -397,15 +401,14 @@ public class DebridManager: ObservableObject { validateAuthUrl(tempAuthUrl, useAuthSession: true) } catch { - toastModel?.updateToastDescription("Premiumize authentication error: \(error)") - completeDebridAuth(.premiumize, success: false) + await sendDebridError(error, prefix: "Premiumize authentication error") - print("Premiumize authentication error (auth): \(error)") + completeDebridAuth(.premiumize, success: false) } } // Currently handles Premiumize callback - public func handleCallback(url: URL?, error: Error?) { + public func handleCallback(url: URL?, error: Error?) async { do { if let error { throw Premiumize.PMError.AuthQuery(description: "OAuth callback Error: \(error)") @@ -419,10 +422,9 @@ public class DebridManager: ObservableObject { throw Premiumize.PMError.AuthQuery(description: "The callback URL was invalid") } } catch { - toastModel?.updateToastDescription("Premiumize authentication error: \(error)") - completeDebridAuth(.premiumize, success: false) + await sendDebridError(error, prefix: "Premiumize authentication error (callback)") - print("Premiumize authentication error (callback): \(error)") + completeDebridAuth(.premiumize, success: false) } } @@ -450,9 +452,7 @@ public class DebridManager: ObservableObject { try await realDebrid.deleteTokens() enabledDebrids.remove(.realDebrid) } catch { - toastModel?.updateToastDescription("RealDebrid logout error: \(error)") - - print("RealDebrid logout error: \(error)") + await sendDebridError(error, prefix: "RealDebrid logout error") } } @@ -539,21 +539,12 @@ public class DebridManager: ObservableObject { case RealDebrid.RDError.EmptyTorrents: showDeleteAlert.toggle() default: - let error = error as NSError + await sendDebridError(error, prefix: "RealDebrid download error", cancelString: "Download cancelled") - switch error.code { - case -999: - toastModel?.updateToastDescription("Download cancelled", newToastType: .info) - default: - toastModel?.updateToastDescription("RealDebrid download error: \(error)") - } - - await deleteRdTorrent(torrentID: selectedRealDebridID) + await deleteRdTorrent(torrentID: selectedRealDebridID, presentError: false) } showLoadingProgress = false - - print("RealDebrid download error: \(error)") } } @@ -567,8 +558,7 @@ public class DebridManager: ObservableObject { // 5 minutes realDebridCloudTTL = Date().timeIntervalSince1970 + 300 } catch { - toastModel?.updateToastDescription("RealDebrid cloud fetch error: \(error)") - print("RealDebrid cloud fetch error: \(error)") + await sendDebridError(error, prefix: "RealDebrid cloud fetch error") } } } @@ -580,12 +570,11 @@ public class DebridManager: ObservableObject { // Bypass TTL to get current RD values await fetchRdCloud(bypassTTL: true) } catch { - toastModel?.updateToastDescription("RealDebrid download delete error: \(error)") - print("RealDebrid download delete error: \(error)") + await sendDebridError(error, prefix: "RealDebrid download delete error") } } - func deleteRdTorrent(torrentID: String? = nil) async { + func deleteRdTorrent(torrentID: String? = nil, presentError: Bool = true) async { do { if let torrentID = torrentID { try await realDebrid.deleteTorrent(debridID: torrentID) @@ -595,8 +584,7 @@ public class DebridManager: ObservableObject { throw RealDebrid.RDError.FailedRequest(description: "No torrent ID was provided") } } catch { - toastModel?.updateToastDescription("RealDebrid torrent delete error: \(error)") - print("RealDebrid torrent delete error: \(error)") + await sendDebridError(error, prefix: "RealDebrid torrent delete error", presentError: presentError) } } @@ -629,13 +617,7 @@ public class DebridManager: ObservableObject { downloadUrl = unlockedLink } catch { - let error = error as NSError - switch error.code { - case -999: - toastModel?.updateToastDescription("Download cancelled", newToastType: .info) - default: - toastModel?.updateToastDescription("AllDebrid download error: \(error)") - } + await sendDebridError(error, prefix: "AllDebrid download error", cancelString: "Download cancelled") } } @@ -659,8 +641,7 @@ public class DebridManager: ObservableObject { try await premiumize.createTransfer(magnet: premiumizeItem.magnet) } } catch { - toastModel?.updateToastDescription("Premiumize download error: \(error)") - print("Premiumize download error: \(error)") + await sendDebridError(error, prefix: "Premiumize download error", cancelString: "Download or transfer cancelled") } } @@ -676,8 +657,10 @@ public class DebridManager: ObservableObject { // 5 minutes premiumizeCloudTTL = Date().timeIntervalSince1970 + 300 } catch { - toastModel?.updateToastDescription("Premiumize cloud fetch error: \(error)") - print("Premiumize cloud fetch error: \(error)") + let error = error as NSError + if error.code != -999 { + await sendDebridError(error, prefix: "Premiumize cloud fetch error") + } } } } @@ -689,8 +672,7 @@ public class DebridManager: ObservableObject { // Bypass TTL to get current RD values await fetchPmCloud(bypassTTL: true) } catch { - toastModel?.updateToastDescription("Premiumize cloud delete error: \(error)") - print("Premiumize cloud delete error: \(error)") + await sendDebridError(error, prefix: "Premiumize cloud delete error") } } } diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index a985c5a..a1cb620 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -157,7 +157,9 @@ struct SettingsView: View { url: debridManager.authUrl ?? URL(string: "https://google.com")!, callbackURLScheme: "ferrite" ) { callbackURL, error in - debridManager.handleCallback(url: callbackURL, error: error) + Task { + await debridManager.handleCallback(url: callbackURL, error: error) + } } .prefersEphemeralWebBrowserSession(false) } -- 2.45.2 From 2258036f7b9a9a5fd949aacdfd2bc7d87bc0d23b Mon Sep 17 00:00:00 2001 From: kingbri Date: Fri, 6 Jan 2023 16:03:31 -0500 Subject: [PATCH 15/19] Debrid: Add support for AllDebrid cloud and cleanup This commit adds support for viewing a user's AllDebrid magnet list. AllDebrid does not save unlocked links, but they do save which magnets a user has queried. Also clean up various functions in DebridManager. Signed-off-by: kingbri --- Ferrite.xcodeproj/project.pbxproj | 4 + Ferrite/API/AllDebridWrapper.swift | 26 +++- Ferrite/Models/AllDebridModels.swift | 16 ++- Ferrite/ViewModels/DebridManager.swift | 117 +++++++++++++----- .../Library/Cloud/AllDebridCloudView.swift | 93 ++++++++++++++ .../Library/Cloud/PremiumizeCloudView.swift | 6 +- .../Library/Cloud/RealDebridCloudView.swift | 31 +++-- .../Library/DebridCloudView.swift | 5 +- .../SearchResult/SearchResultButtonView.swift | 4 + Ferrite/Views/LibraryView.swift | 2 +- Ferrite/Views/SettingsView.swift | 2 +- .../Views/SheetViews/BatchChoiceView.swift | 1 + 12 files changed, 243 insertions(+), 64 deletions(-) create mode 100644 Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index 2159b51..a4a2d25 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */; }; 0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; }; 0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; }; + 0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */; }; 0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; }; 0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; }; 0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */; }; @@ -148,6 +149,7 @@ 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = ""; }; 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = ""; }; 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = ""; }; + 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridCloudView.swift; sourceTree = ""; }; 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubWrapper.swift; sourceTree = ""; }; 0C68135128BC1A7C00FAD890 /* GithubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubModels.swift; sourceTree = ""; }; 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridWrapper.swift; sourceTree = ""; }; @@ -317,6 +319,7 @@ children = ( 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */, 0CAF9318296399190050812A /* PremiumizeCloudView.swift */, + 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */, ); path = Cloud; sourceTree = ""; @@ -706,6 +709,7 @@ 0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */, 0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */, 0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */, + 0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */, 0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */, 0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */, 0C794B67289DACB600DD1CC8 /* SourceUpdateButtonView.swift in Sources */, diff --git a/Ferrite/API/AllDebridWrapper.swift b/Ferrite/API/AllDebridWrapper.swift index 3f76226..fbf0163 100644 --- a/Ferrite/API/AllDebridWrapper.swift +++ b/Ferrite/API/AllDebridWrapper.swift @@ -21,7 +21,6 @@ public class AllDebrid { // Fetches information for PIN auth public func getPinInfo() async throws -> PinResponse { let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get") - print("Auth URL: \(url)") let request = URLRequest(url: url) do { @@ -161,19 +160,40 @@ public class AllDebrid { let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data // Better to fetch no link at all than the wrong link - if let linkWrapper = rawResponse.magnets.links[safe: selectedIndex ?? -1] { + if let linkWrapper = rawResponse.magnets[safe: 0]?.links[safe: selectedIndex ?? -1] { return linkWrapper.link } else { throw ADError.EmptyTorrents } } + public func userMagnets() async throws -> [MagnetStatusData] { + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status")) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data + + if rawResponse.magnets.isEmpty { + throw ADError.EmptyData + } else { + return rawResponse.magnets + } + } + + public func deleteMagnet(magnetId: Int) async throws { + let queryItems = [ + URLQueryItem(name: "id", value: String(magnetId)) + ] + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems)) + + try await performRequest(request: &request, requestName: #function) + } + public func unlockLink(lockedLink: String) async throws -> String { let queryItems = [ URLQueryItem(name: "link", value: lockedLink) ] var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems)) - print(request) let data = try await performRequest(request: &request, requestName: #function) let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data diff --git a/Ferrite/Models/AllDebridModels.swift b/Ferrite/Models/AllDebridModels.swift index b37d130..fec4bc4 100644 --- a/Ferrite/Models/AllDebridModels.swift +++ b/Ferrite/Models/AllDebridModels.swift @@ -83,12 +83,24 @@ public extension AllDebrid { // MARK: - MagnetStatusResponse struct MagnetStatusResponse: Codable { - let magnets: MagnetStatusData + let magnets: [MagnetStatusData] + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) { + self.magnets = [data] + } else if let data = try? container.decode([MagnetStatusData].self, forKey: .magnets) { + self.magnets = data + } else { + self.magnets = [] + } + } } // MARK: - MagnetStatusData - internal struct MagnetStatusData: Codable { + struct MagnetStatusData: Codable { let id: Int let filename: String let size: Int diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 77267d5..b4a0255 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -64,6 +64,10 @@ public class DebridManager: ObservableObject { var selectedAllDebridItem: AllDebrid.IA? var selectedAllDebridFile: AllDebrid.IAFile? + // AllDebrid cloud variables + @Published var allDebridCloudMagnets: [AllDebrid.MagnetStatusData] = [] + var allDebridCloudTTL: Double = 0.0 + // Premiumize auth variables @Published var premiumizeAuthProcessing: Bool = false @@ -471,7 +475,8 @@ public class DebridManager: ObservableObject { // MARK: - Debrid fetch UI linked functions // Common function to delegate what debrid service to fetch from - public func fetchDebridDownload(magnet: Magnet?) async { + // Cloudinfo is used for any extra information provided by debrid cloud + public func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async { defer { currentDebridTask = nil showLoadingProgress = false @@ -481,29 +486,35 @@ public class DebridManager: ObservableObject { switch selectedDebridType { case .realDebrid: - await fetchRdDownload(magnet: magnet) + await fetchRdDownload(magnet: magnet, existingLink: cloudInfo) case .allDebrid: - await fetchAdDownload(magnet: magnet) + await fetchAdDownload(magnet: magnet, existingLockedLink: cloudInfo) case .premiumize: - await fetchPmDownload() + await fetchPmDownload(cloudItemId: cloudInfo) case .none: break } } - func fetchRdDownload(magnet: Magnet?) async { - do { - // Bypass the TTL since a download needs to be queried + func fetchRdDownload(magnet: Magnet?, existingLink: String?) async { + // If an existing link is passed in args, set it to that. Otherwise, find one from RD cloud. + let torrentLink: String? + if let existingLink { + torrentLink = existingLink + } else { + // Bypass the TTL for up to date information await fetchRdCloud(bypassTTL: true) - // If there's an existing torrent, check for a download link. Otherwise check for an unrestrict link - let existingTorrents = realDebridCloudTorrents.filter { $0.hash == selectedRealDebridItem?.magnet.hash && $0.status == "downloaded" } + let existingTorrent = realDebridCloudTorrents.first { $0.hash == selectedRealDebridItem?.magnet.hash && $0.status == "downloaded" } + torrentLink = existingTorrent?.links[safe: selectedRealDebridFile?.batchFileIndex ?? 0] + } + do { // If the links match from a user's downloads, no need to re-run a download - if let existingTorrent = existingTorrents[safe: 0], - let torrentLink = existingTorrent.links[safe: selectedRealDebridFile?.batchFileIndex ?? 0] + if let torrentLink, + let downloadLink = await checkRdUserDownloads(userTorrentLink: torrentLink) { - try await checkRdUserDownloads(userTorrentLink: torrentLink) + downloadUrl = downloadLink } else if let magnet { // Add a magnet after all the cache checks fail selectedRealDebridID = try await realDebrid.addMagnet(magnet: magnet) @@ -531,8 +542,7 @@ public class DebridManager: ObservableObject { toastModel?.updateToastDescription("Could not cache this torrent. Aborting.") } } else { - toastModel?.updateToastDescription("Could not fetch your file from RealDebrid's cache or API") - print("RealDebrid error: No magnet link or cached file found") + throw RealDebrid.RDError.FailedRequest(description: "Could not fetch your file from RealDebrid's cache or API") } } catch { switch error { @@ -588,42 +598,81 @@ public class DebridManager: ObservableObject { } } - func checkRdUserDownloads(userTorrentLink: String) async throws { - let existingLinks = realDebridCloudDownloads.filter { $0.link == userTorrentLink } - if let existingLink = existingLinks[safe: 0]?.download { - downloadUrl = existingLink - } else { - let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: userTorrentLink) + func checkRdUserDownloads(userTorrentLink: String) async -> String? { + do { + let existingLinks = realDebridCloudDownloads.first { $0.link == userTorrentLink } + if let existingLink = existingLinks?.download { + return existingLink + } else { + return try await realDebrid.unrestrictLink(debridDownloadLink: userTorrentLink) + } + } catch { + await sendDebridError(error, prefix: "RealDebrid download check error") - downloadUrl = downloadLink + return nil } } - func fetchAdDownload(magnet: Magnet?) async { - guard let magnet else { - toastModel?.updateToastDescription("Could not run your action because the magnet is invalid.") - print("AllDebrid error: Invalid magnet") + func fetchAdDownload(magnet: Magnet?, existingLockedLink: String?) async { + // If an existing link is passed in args, set it to that. Otherwise, find one from AD cloud. + let lockedLink: String? + if let existingLockedLink { + lockedLink = existingLockedLink + } else { + // Bypass the TTL for up to date information + await fetchAdCloud(bypassTTL: true) - return + let existingMagnet = allDebridCloudMagnets.first { $0.hash == selectedAllDebridItem?.magnet.hash && $0.status == "Ready" } + lockedLink = existingMagnet?.links[safe: selectedAllDebridFile?.id ?? 0]?.link } do { - let magnetID = try await allDebrid.addMagnet(magnet: magnet) - let lockedLink = try await allDebrid.fetchMagnetStatus( - magnetId: magnetID, - selectedIndex: selectedAllDebridFile?.id ?? 0 - ) - let unlockedLink = try await allDebrid.unlockLink(lockedLink: lockedLink) + if let lockedLink { + downloadUrl = try await allDebrid.unlockLink(lockedLink: lockedLink) + } else if let magnet { + let magnetID = try await allDebrid.addMagnet(magnet: magnet) + let lockedLink = try await allDebrid.fetchMagnetStatus( + magnetId: magnetID, + selectedIndex: selectedAllDebridFile?.id ?? 0 + ) - downloadUrl = unlockedLink + downloadUrl = try await allDebrid.unlockLink(lockedLink: lockedLink) + } else { + throw AllDebrid.ADError.FailedRequest(description: "Could not fetch your file from AllDebrid's cache or API") + } } catch { await sendDebridError(error, prefix: "AllDebrid download error", cancelString: "Download cancelled") } } + // Refreshes torrents and downloads from a RD user's account + public func fetchAdCloud(bypassTTL: Bool = false) async { + if bypassTTL || Date().timeIntervalSince1970 > allDebridCloudTTL { + do { + allDebridCloudMagnets = try await allDebrid.userMagnets() + realDebridCloudDownloads = try await realDebrid.userDownloads() + + // 5 minutes + allDebridCloudTTL = Date().timeIntervalSince1970 + 300 + } catch { + await sendDebridError(error, prefix: "AlLDebrid cloud fetch error") + } + } + } + + func deleteAdMagnet(magnetId: Int) async { + do { + try await allDebrid.deleteMagnet(magnetId: magnetId) + + await fetchAdCloud(bypassTTL: true) + } catch { + await sendDebridError(error, prefix: "AllDebrid delete error") + } + } + func fetchPmDownload(cloudItemId: String? = nil) async { do { - if let cloudItemId = cloudItemId { + if let cloudItemId { downloadUrl = try await premiumize.itemDetails(itemID: cloudItemId).link } else if let premiumizeFile = selectedPremiumizeFile { downloadUrl = premiumizeFile.streamUrlString diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift new file mode 100644 index 0000000..2476f27 --- /dev/null +++ b/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift @@ -0,0 +1,93 @@ +// +// AllDebridCloudView.swift +// Ferrite +// +// Created by Brian Dashore on 1/5/23. +// + +import SwiftUI + +struct AllDebridCloudView: View { + @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var navModel: NavigationViewModel + + @State private var viewTask: Task? + + var body: some View { + DisclosureGroup("Magnets") { + ForEach(debridManager.allDebridCloudMagnets, id: \.id) { magnet in + Button { + if magnet.status == "Ready" && !magnet.links.isEmpty { + navModel.resultFromCloud = true + navModel.selectedTitle = magnet.filename + + var historyInfo = HistoryEntryJson( + name: magnet.filename, + source: DebridType.allDebrid.toString() + ) + + Task { + if magnet.links.count == 1 { + if let lockedLink = magnet.links[safe: 0]?.link { + await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: lockedLink) + + if !debridManager.downloadUrl.isEmpty { + historyInfo.url = debridManager.downloadUrl + PersistenceController.shared.createHistory(historyInfo) + navModel.runDebridAction(urlString: debridManager.downloadUrl) + } + } + } else { + debridManager.clearIAValues() + let magnet = Magnet(hash: magnet.hash, link: nil) + await debridManager.populateDebridIA([magnet]) + + if debridManager.selectDebridResult(magnet: magnet) { + navModel.selectedHistoryInfo = historyInfo + navModel.currentChoiceSheet = .batch + } + } + } + } + + } label: { + VStack(alignment: .leading, spacing: 10) { + Text(magnet.filename) + + HStack { + Text(magnet.status) + Spacer() + DebridLabelView(cloudLinks: magnet.links.map(\.link)) + } + .font(.caption) + } + } + .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) + .backport.tint(.black) + } + .onDelete { offsets in + for index in offsets { + if let magnet = debridManager.allDebridCloudMagnets[safe: index] { + Task { + await debridManager.deleteAdMagnet(magnetId: magnet.id) + } + } + } + } + } + .onAppear { + viewTask = Task { + await debridManager.fetchAdCloud() + } + } + .onDisappear { + viewTask?.cancel() + } + } +} + +struct AllDebridCloudView_Previews: PreviewProvider { + static var previews: some View { + AllDebridCloudView() + } +} diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift index f8d7969..14166cc 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift @@ -14,8 +14,6 @@ struct PremiumizeCloudView: View { @State private var viewTask: Task? - @State private var searchText: String = "" - var body: some View { DisclosureGroup("Items") { ForEach(debridManager.premiumizeCloudItems, id: \.id) { item in @@ -24,14 +22,14 @@ struct PremiumizeCloudView: View { navModel.resultFromCloud = true navModel.selectedTitle = item.name - await debridManager.fetchPmDownload(cloudItemId: item.id) + await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: item.id) if !debridManager.downloadUrl.isEmpty { PersistenceController.shared.createHistory( HistoryEntryJson( name: item.name, url: debridManager.downloadUrl, - source: "Premiumize" + source: DebridType.premiumize.toString() ) ) diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift index fb365e6..79f5d12 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift @@ -48,27 +48,24 @@ struct RealDebridCloudView: View { DisclosureGroup("Torrents") { ForEach(debridManager.realDebridCloudTorrents, id: \.self) { torrentResponse in Button { - Task { - if torrentResponse.status == "downloaded" && !torrentResponse.links.isEmpty { - navModel.resultFromCloud = true - navModel.selectedTitle = torrentResponse.filename + if torrentResponse.status == "downloaded" && !torrentResponse.links.isEmpty { + navModel.resultFromCloud = true + navModel.selectedTitle = torrentResponse.filename - var historyInfo = HistoryEntryJson( - name: torrentResponse.filename, - source: DebridType.realDebrid.toString() - ) + var historyInfo = HistoryEntryJson( + name: torrentResponse.filename, + source: DebridType.realDebrid.toString() + ) + Task { if torrentResponse.links.count == 1 { - if let downloadLink = torrentResponse.links[safe: 0] { - do { - try await debridManager.checkRdUserDownloads(userTorrentLink: downloadLink) - navModel.selectedTitle = torrentResponse.filename - historyInfo.url = downloadLink - + if let torrentLink = torrentResponse.links[safe: 0] { + await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: torrentLink) + if !debridManager.downloadUrl.isEmpty { + historyInfo.url = debridManager.downloadUrl PersistenceController.shared.createHistory(historyInfo) - navModel.currentChoiceSheet = .magnet - } catch { - debridManager.toastModel?.updateToastDescription("RealDebrid cloud fetch error: \(error)") + + navModel.runDebridAction(urlString: debridManager.downloadUrl) } } } else { diff --git a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift index 0ee250c..8ae90a8 100644 --- a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import SwiftUIX struct DebridCloudView: View { @EnvironmentObject var debridManager: DebridManager @@ -20,7 +19,9 @@ struct DebridCloudView: View { RealDebridCloudView() case .premiumize: PremiumizeCloudView() - case .allDebrid, .none: + case .allDebrid: + AllDebridCloudView() + case .none: EmptyView() } } diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift index cec3138..66ddb99 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift @@ -51,6 +51,10 @@ struct SearchResultButtonView: View { } case .partial: if debridManager.selectDebridResult(magnet: result.magnet) { + navModel.selectedHistoryInfo = HistoryEntryJson( + name: result.title, + source: result.source + ) navModel.currentChoiceSheet = .batch } case .none: diff --git a/Ferrite/Views/LibraryView.swift b/Ferrite/Views/LibraryView.swift index 3d2e3fe..07604a7 100644 --- a/Ferrite/Views/LibraryView.swift +++ b/Ferrite/Views/LibraryView.swift @@ -72,7 +72,7 @@ struct LibraryView: View { EmptyInstructionView(title: "No History", message: "Start watching to build history") } case .debridCloud: - if debridManager.selectedDebridType == nil || debridManager.selectedDebridType == .allDebrid { + if debridManager.selectedDebridType == nil { EmptyInstructionView(title: "Cloud Unavailable", message: "Listing is not available for this service") } } diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index a1cb620..ed777c3 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -161,7 +161,7 @@ struct SettingsView: View { await debridManager.handleCallback(url: callbackURL, error: error) } } - .prefersEphemeralWebBrowserSession(false) + .prefersEphemeralWebBrowserSession(true) } .navigationTitle("Settings") } diff --git a/Ferrite/Views/SheetViews/BatchChoiceView.swift b/Ferrite/Views/SheetViews/BatchChoiceView.swift index fa1f094..00edb19 100644 --- a/Ferrite/Views/SheetViews/BatchChoiceView.swift +++ b/Ferrite/Views/SheetViews/BatchChoiceView.swift @@ -78,6 +78,7 @@ struct BatchChoiceView: View { if var selectedHistoryInfo = navModel.selectedHistoryInfo { selectedHistoryInfo.url = debridManager.downloadUrl + selectedHistoryInfo.subName = fileName PersistenceController.shared.createHistory(selectedHistoryInfo) } -- 2.45.2 From e8f62e3cdc6ec6de62ecda50fa9d03b6b3f0f2a3 Mon Sep 17 00:00:00 2001 From: kingbri Date: Sat, 7 Jan 2023 17:38:58 -0500 Subject: [PATCH 16/19] Library: Add searching and cleanup Add a searchbar to filter through various library entries so it's easier to find items. Also add fixes for < iOS 16 devices and fix up searchbar constraints. Signed-off-by: kingbri --- Ferrite/Classes/Application.swift | 2 + Ferrite/ViewModels/DebridManager.swift | 10 +- .../Library/BookmarksView.swift | 59 ++++++----- .../Library/Cloud/AllDebridCloudView.swift | 15 ++- .../Library/Cloud/PremiumizeCloudView.swift | 12 +-- .../Library/Cloud/RealDebridCloudView.swift | 19 ++-- .../Library/DebridCloudView.swift | 35 +++---- .../Library/HistoryButtonView.swift | 2 +- .../ComponentViews/Library/HistoryView.swift | 99 ++++++++++++++----- Ferrite/Views/ContentView.swift | 10 +- Ferrite/Views/LibraryView.swift | 38 +++++-- Ferrite/Views/SettingsView.swift | 8 ++ Ferrite/Views/SourcesView.swift | 27 ++--- 13 files changed, 214 insertions(+), 122 deletions(-) diff --git a/Ferrite/Classes/Application.swift b/Ferrite/Classes/Application.swift index cf9832f..2f70175 100644 --- a/Ferrite/Classes/Application.swift +++ b/Ferrite/Classes/Application.swift @@ -32,4 +32,6 @@ public class Application { } #endif } + + let osVersion: OperatingSystemVersion = ProcessInfo().operatingSystemVersion } diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index b4a0255..9dfad81 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -544,6 +544,9 @@ public class DebridManager: ObservableObject { } else { throw RealDebrid.RDError.FailedRequest(description: "Could not fetch your file from RealDebrid's cache or API") } + + // Fetch one more time to add updated data into the RD cloud cache + await fetchRdCloud(bypassTTL: true) } catch { switch error { case RealDebrid.RDError.EmptyTorrents: @@ -640,6 +643,9 @@ public class DebridManager: ObservableObject { } else { throw AllDebrid.ADError.FailedRequest(description: "Could not fetch your file from AllDebrid's cache or API") } + + // Fetch one more time to add updated data into the AD cloud cache + await fetchAdCloud(bypassTTL: true) } catch { await sendDebridError(error, prefix: "AllDebrid download error", cancelString: "Download cancelled") } @@ -650,7 +656,6 @@ public class DebridManager: ObservableObject { if bypassTTL || Date().timeIntervalSince1970 > allDebridCloudTTL { do { allDebridCloudMagnets = try await allDebrid.userMagnets() - realDebridCloudDownloads = try await realDebrid.userDownloads() // 5 minutes allDebridCloudTTL = Date().timeIntervalSince1970 + 300 @@ -685,6 +690,9 @@ public class DebridManager: ObservableObject { throw Premiumize.PMError.FailedRequest(description: "There were no items or files found!") } + // Fetch one more time to add updated data into the PM cloud cache + await fetchPmCloud(bypassTTL: true) + // Add a PM transfer if the item exists if let premiumizeItem = selectedPremiumizeItem { try await premiumize.createTransfer(magnet: premiumizeItem.magnet) diff --git a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift index ef8743e..7cd8189 100644 --- a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift +++ b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift @@ -15,14 +15,15 @@ struct BookmarksView: View { let backgroundContext = PersistenceController.shared.backgroundContext - var bookmarks: FetchedResults + @Binding var searchText: String @State private var viewTask: Task? + @State private var bookmarkPredicate: NSPredicate? var body: some View { - ZStack { - if !bookmarks.isEmpty { - List { + DynamicFetchRequest(predicate: bookmarkPredicate) { (bookmarks: FetchedResults) in + List { + if !bookmarks.isEmpty { ForEach(bookmarks, id: \.self) { bookmark in SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark) } @@ -30,43 +31,53 @@ struct BookmarksView: View { for index in offsets { if let bookmark = bookmarks[safe: index] { PersistenceController.shared.delete(bookmark, context: backgroundContext) - + NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark) } } } .onMove { source, destination in var changedBookmarks = bookmarks.map { $0 } - + changedBookmarks.move(fromOffsets: source, toOffset: destination) - + for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) { changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex) } - + PersistenceController.shared.save() } } - .inlinedList() - .listStyle(.insetGrouped) + } + .inlinedList() + .listStyle(.insetGrouped) + .onAppear { + if debridManager.enabledDebrids.count > 0 { + viewTask = Task { + let magnets = bookmarks.compactMap { + if let magnetHash = $0.magnetHash { + return Magnet(hash: magnetHash, link: $0.magnetLink) + } else { + return nil + } + } + await debridManager.populateDebridIA(magnets) + } + } + } + .onDisappear { + viewTask?.cancel() } } .onAppear { - if debridManager.enabledDebrids.count > 0 { - viewTask = Task { - let magnets = bookmarks.compactMap { - if let magnetHash = $0.magnetHash { - return Magnet(hash: magnetHash, link: $0.magnetLink) - } else { - return nil - } - } - await debridManager.populateDebridIA(magnets) - } - } + applyPredicate() } - .onDisappear { - viewTask?.cancel() + .onChange(of: searchText) { _ in + applyPredicate() } } + + func applyPredicate() { + bookmarkPredicate = searchText.isEmpty ? nil : NSPredicate(format: "title CONTAINS[cd] %@", searchText) + } } diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift index 2476f27..4aed0f7 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift @@ -11,11 +11,15 @@ struct AllDebridCloudView: View { @EnvironmentObject var debridManager: DebridManager @EnvironmentObject var navModel: NavigationViewModel + @Binding var searchText: String + @State private var viewTask: Task? var body: some View { DisclosureGroup("Magnets") { - ForEach(debridManager.allDebridCloudMagnets, id: \.id) { magnet in + ForEach(debridManager.allDebridCloudMagnets.filter { + searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) + }, id: \.id) { magnet in Button { if magnet.status == "Ready" && !magnet.links.isEmpty { navModel.resultFromCloud = true @@ -38,8 +42,9 @@ struct AllDebridCloudView: View { } } } else { - debridManager.clearIAValues() let magnet = Magnet(hash: magnet.hash, link: nil) + + // Do not clear old IA values await debridManager.populateDebridIA([magnet]) if debridManager.selectDebridResult(magnet: magnet) { @@ -85,9 +90,3 @@ struct AllDebridCloudView: View { } } } - -struct AllDebridCloudView_Previews: PreviewProvider { - static var previews: some View { - AllDebridCloudView() - } -} diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift index 14166cc..96e902c 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift @@ -12,11 +12,15 @@ struct PremiumizeCloudView: View { @EnvironmentObject var debridManager: DebridManager @EnvironmentObject var navModel: NavigationViewModel + @Binding var searchText: String + @State private var viewTask: Task? var body: some View { DisclosureGroup("Items") { - ForEach(debridManager.premiumizeCloudItems, id: \.id) { item in + ForEach(debridManager.premiumizeCloudItems.filter { + searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased()) + }, id: \.id) { item in Button(item.name) { Task { navModel.resultFromCloud = true @@ -60,9 +64,3 @@ struct PremiumizeCloudView: View { } } } - -struct PremiumizeCloudView_Previews: PreviewProvider { - static var previews: some View { - PremiumizeCloudView() - } -} diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift index 79f5d12..45e1239 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift @@ -11,12 +11,16 @@ struct RealDebridCloudView: View { @EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var debridManager: DebridManager + @Binding var searchText: String + @State private var viewTask: Task? var body: some View { Group { DisclosureGroup("Downloads") { - ForEach(debridManager.realDebridCloudDownloads, id: \.self) { downloadResponse in + ForEach(debridManager.realDebridCloudDownloads.filter { + searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) + }, id: \.self) { downloadResponse in Button(downloadResponse.filename) { navModel.resultFromCloud = true navModel.selectedTitle = downloadResponse.filename @@ -46,7 +50,9 @@ struct RealDebridCloudView: View { } DisclosureGroup("Torrents") { - ForEach(debridManager.realDebridCloudTorrents, id: \.self) { torrentResponse in + ForEach(debridManager.realDebridCloudTorrents.filter { + searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) + }, id: \.self) { torrentResponse in Button { if torrentResponse.status == "downloaded" && !torrentResponse.links.isEmpty { navModel.resultFromCloud = true @@ -69,8 +75,9 @@ struct RealDebridCloudView: View { } } } else { - debridManager.clearIAValues() let magnet = Magnet(hash: torrentResponse.hash, link: nil) + + // Do not clear old IA values await debridManager.populateDebridIA([magnet]) if debridManager.selectDebridResult(magnet: magnet) { @@ -119,9 +126,3 @@ struct RealDebridCloudView: View { } } } - -struct RealDebridCloudView_Previews: PreviewProvider { - static var previews: some View { - RealDebridCloudView() - } -} diff --git a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift index 8ae90a8..fb435bb 100644 --- a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift @@ -10,30 +10,21 @@ import SwiftUI struct DebridCloudView: View { @EnvironmentObject var debridManager: DebridManager + @Binding var searchText: String + var body: some View { - NavView { - VStack { - List { - switch debridManager.selectedDebridType { - case .realDebrid: - RealDebridCloudView() - case .premiumize: - PremiumizeCloudView() - case .allDebrid: - AllDebridCloudView() - case .none: - EmptyView() - } - } - .inlinedList() - .listStyle(.grouped) + List { + switch debridManager.selectedDebridType { + case .realDebrid: + RealDebridCloudView(searchText: $searchText) + case .premiumize: + PremiumizeCloudView(searchText: $searchText) + case .allDebrid: + AllDebridCloudView(searchText: $searchText) + case .none: + EmptyView() } } - } -} - -struct DebridCloudView_Previews: PreviewProvider { - static var previews: some View { - DebridCloudView() + .listStyle(.plain) } } diff --git a/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift index 3a52d2d..8e85859 100644 --- a/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift +++ b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift @@ -36,7 +36,7 @@ struct HistoryButtonView: View { toastModel.updateToastDescription("URL invalid. Cannot load this history entry. Please delete it.") } } label: { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading) { Text(entry.name ?? "Unknown title") .font(entry.subName == nil ? .body : .subheadline) diff --git a/Ferrite/Views/ComponentViews/Library/HistoryView.swift b/Ferrite/Views/ComponentViews/Library/HistoryView.swift index e53ffb5..ca273db 100644 --- a/Ferrite/Views/ComponentViews/Library/HistoryView.swift +++ b/Ferrite/Views/ComponentViews/Library/HistoryView.swift @@ -10,44 +10,89 @@ import SwiftUI struct HistoryView: View { @EnvironmentObject var navModel: NavigationViewModel - let backgroundContext = PersistenceController.shared.backgroundContext - var history: FetchedResults - var formatter: DateFormatter = .init() - @State private var historyIndex = 0 + @Binding var searchText: String - init(history: FetchedResults) { - self.history = history - - formatter.dateStyle = .medium - formatter.timeStyle = .none - } - - func groupedEntries(_ result: FetchedResults) -> [[History]] { - Dictionary(grouping: result) { (element: History) in - element.dateString ?? "" - }.values.sorted { $0[0].date ?? Date() > $1[0].date ?? Date() } - } + @State private var historyPredicate: NSPredicate? var body: some View { - if !history.isEmpty { + DynamicFetchRequest(predicate: historyPredicate) { (allEntries: FetchedResults) in List { - ForEach(groupedEntries(history), id: \.self) { (section: [History]) in - Section(header: Text(formatter.string(from: section[0].date ?? Date()))) { - ForEach(section, id: \.self) { history in - ForEach(history.entryArray) { entry in - HistoryButtonView(entry: entry) - } - .onDelete { offsets in - removeEntry(at: offsets, from: history) - } - } + if !history.isEmpty { + ForEach(groupedHistory(history), id: \.self) { historyGroup in + HistorySectionView(allEntries: allEntries, historyGroup: historyGroup) } } } .listStyle(.insetGrouped) } + .onAppear { + applyPredicate() + } + .onChange(of: searchText) { _ in + applyPredicate() + } + } + + func applyPredicate() { + if searchText.isEmpty { + historyPredicate = nil + } else { + let namePredicate = NSPredicate(format: "name CONTAINS[cd] %@", searchText.lowercased()) + let subNamePredicate = NSPredicate(format: "subName CONTAINS[cd] %@", searchText.lowercased()) + historyPredicate = NSCompoundPredicate(type: .or, subpredicates: [namePredicate, subNamePredicate]) + } + } + + func groupedHistory(_ result: FetchedResults) -> [[History]] { + return Dictionary(grouping: result) { (element: History) in + element.dateString ?? "" + } + .values + .sorted { $0[0].date ?? Date() > $1[0].date ?? Date() } + } +} + +struct HistorySectionView: View { + let backgroundContext = PersistenceController.shared.backgroundContext + + var formatter: DateFormatter = .init() + var allEntries: FetchedResults + var historyGroup: [History] + + init(allEntries: FetchedResults, historyGroup: [History]) { + self.allEntries = allEntries + self.historyGroup = historyGroup + + formatter.dateStyle = .medium + formatter.timeStyle = .none + } + + var body: some View { + if compareGroup(historyGroup) > 0 { + Section(header: Text(formatter.string(from: historyGroup[0].date ?? Date()))) { + ForEach(historyGroup, id: \.self) { history in + ForEach(history.entryArray.filter { allEntries.contains($0) }, id: \.self) { entry in + HistoryButtonView(entry: entry) + } + .onDelete { offsets in + removeEntry(at: offsets, from: history) + } + } + } + } + } + + func compareGroup(_ group: [History]) -> Int { + var totalCount = 0 + for history in group { + totalCount += history.entryArray.reduce(0, { result, item in + result + (allEntries.contains { $0.name == item.name || (item.subName.map { return !$0.isEmpty } ?? false && $0.subName == item.subName) } ? 1 : 0) + }) + } + + return totalCount } func removeEntry(at offsets: IndexSet, from history: History) { diff --git a/Ferrite/Views/ContentView.swift b/Ferrite/Views/ContentView.swift index b691ba5..28471f6 100644 --- a/Ferrite/Views/ContentView.swift +++ b/Ferrite/Views/ContentView.swift @@ -19,6 +19,8 @@ struct ContentView: View { sortDescriptors: [] ) var sources: FetchedResults + @AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true + @State private var selectedSource: Source? { didSet { scrapingModel.filteredSource = selectedSource @@ -72,7 +74,9 @@ struct ContentView: View { SearchResultsView() } .navigationTitle("Search") - .navigationBarTitleDisplayMode(navModel.isSearching ? .inline : .large) + .navigationBarTitleDisplayMode( + navModel.isSearching && Application.shared.osVersion.majorVersion > 14 ? .inline : .large + ) .navigationSearchBar { SearchBar("Search", text: $scrapingModel.searchText, @@ -114,8 +118,8 @@ struct ContentView: View { } .introspectSearchController { searchController in searchController.hidesNavigationBarDuringPresentation = false - searchController.searchBar.autocorrectionType = .no - searchController.searchBar.autocapitalizationType = .none + searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no + searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none } .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { diff --git a/Ferrite/Views/LibraryView.swift b/Ferrite/Views/LibraryView.swift index 07604a7..3c494c1 100644 --- a/Ferrite/Views/LibraryView.swift +++ b/Ferrite/Views/LibraryView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SwiftUIX struct LibraryView: View { enum LibraryPickerSegment { @@ -19,9 +20,7 @@ struct LibraryView: View { @FetchRequest( entity: Bookmark.entity(), - sortDescriptors: [ - NSSortDescriptor(keyPath: \Bookmark.orderNum, ascending: true) - ] + sortDescriptors: [] ) var bookmarks: FetchedResults @FetchRequest( @@ -31,11 +30,15 @@ struct LibraryView: View { ] ) var history: FetchedResults - @State private var historyEmpty = true + @AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true @State private var selectedSegment: LibraryPickerSegment = .bookmarks @State private var editMode: EditMode = .inactive + @State private var searchText: String = "" + @State private var isEditingSearch = false + @State private var isSearching = false + var body: some View { NavView { VStack { @@ -48,19 +51,34 @@ struct LibraryView: View { } } .pickerStyle(.segmented) - .padding() + .padding(.horizontal) + .padding(.vertical, 5) switch selectedSegment { case .bookmarks: - BookmarksView(bookmarks: bookmarks) + BookmarksView(searchText: $searchText) case .history: - HistoryView(history: history) + HistoryView(history: history, searchText: $searchText) case .debridCloud: - DebridCloudView() + DebridCloudView(searchText: $searchText) } Spacer() } + .navigationSearchBar { + SearchBar("Search", text: $searchText, isEditing: $isEditingSearch, onCommit: { + isSearching = true + }) + .showsCancelButton(isEditingSearch || isSearching) + .onCancel { + searchText = "" + isSearching = false + } + } + .introspectSearchController { searchController in + searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no + searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none + } .overlay { switch selectedSegment { case .bookmarks: @@ -80,7 +98,8 @@ struct LibraryView: View { .navigationTitle("Library") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - HStack { + HStack(spacing: Application.shared.osVersion.majorVersion > 14 ? 10 : 18) { + Spacer() EditButton() switch selectedSegment { @@ -90,6 +109,7 @@ struct LibraryView: View { HistoryActionsView() } } + .animation(.none) } } .environment(\.editMode, $editMode) diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index ed777c3..f4656c0 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -15,6 +15,8 @@ struct SettingsView: View { let backgroundContext = PersistenceController.shared.backgroundContext + @AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true + @AppStorage("Updates.AutomaticNotifs") var autoUpdateNotifs = true @AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none @@ -76,6 +78,12 @@ struct SettingsView: View { } } + Section(header: Text("Behavior")) { + Toggle(isOn: $autocorrectSearch) { + Text("Autocorrect search") + } + } + Section(header: Text("Source management")) { NavigationLink("Source lists", destination: SettingsSourceListView()) } diff --git a/Ferrite/Views/SourcesView.swift b/Ferrite/Views/SourcesView.swift index aa60a27..eb9c708 100644 --- a/Ferrite/Views/SourcesView.swift +++ b/Ferrite/Views/SourcesView.swift @@ -15,13 +15,11 @@ struct SourcesView: View { let backgroundContext = PersistenceController.shared.backgroundContext - @FetchRequest( - entity: Source.entity(), - sortDescriptors: [] - ) var sources: FetchedResults + @AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true @State private var checkedForSources = false - @State private var isEditing = false + @State private var isEditingSearch = false + @State private var isSearching = false @State private var viewTask: Task? = nil @State private var searchText: String = "" @@ -35,7 +33,7 @@ struct SourcesView: View { ZStack { if !checkedForSources { ProgressView() - } else if sources.isEmpty, sourceManager.availableSources.isEmpty { + } else if installedSources.isEmpty, sourceManager.availableSources.isEmpty { EmptyInstructionView(title: "No Sources", message: "Add a source list in Settings") } else { List { @@ -119,11 +117,18 @@ struct SourcesView: View { } .navigationTitle("Sources") .navigationSearchBar { - SearchBar("Search", text: $searchText, isEditing: $isEditing) - .showsCancelButton(isEditing) - .onCancel { - searchText = "" - } + SearchBar("Search", text: $searchText, isEditing: $isEditingSearch, onCommit: { + isSearching = true + }) + .showsCancelButton(isEditingSearch || isSearching) + .onCancel { + searchText = "" + isSearching = false + } + } + .introspectSearchController { searchController in + searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no + searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none } } } -- 2.45.2 From 39c4a10a72073aa529882d709690a32151bf7030 Mon Sep 17 00:00:00 2001 From: kingbri Date: Sat, 7 Jan 2023 17:40:44 -0500 Subject: [PATCH 17/19] Ferrite: format Signed-off-by: kingbri --- Ferrite/Extensions/String.swift | 4 ++-- Ferrite/Models/AllDebridModels.swift | 6 +++--- Ferrite/Models/DebridManagerModels.swift | 6 +++--- Ferrite/Models/PremiumizeModels.swift | 2 ++ Ferrite/ViewModels/DebridManager.swift | 4 ++-- Ferrite/Views/ComponentViews/Library/BookmarksView.swift | 8 ++++---- .../ComponentViews/Library/Cloud/AllDebridCloudView.swift | 2 +- .../Library/Cloud/RealDebridCloudView.swift | 2 +- Ferrite/Views/ComponentViews/Library/HistoryView.swift | 8 ++++---- .../SearchResult/SearchResultButtonView.swift | 7 ++++--- .../Views/ComponentViews/Source/SourceSettingsView.swift | 2 +- 11 files changed, 27 insertions(+), 24 deletions(-) diff --git a/Ferrite/Extensions/String.swift b/Ferrite/Extensions/String.swift index bbdb0ce..7be6c48 100644 --- a/Ferrite/Extensions/String.swift +++ b/Ferrite/Extensions/String.swift @@ -11,11 +11,11 @@ import Foundation extension String { // From https://www.hackingwithswift.com/example-code/strings/how-to-capitalize-the-first-letter-of-a-string func capitalizingFirstLetter() -> String { - return prefix(1).capitalized + dropFirst() + prefix(1).capitalized + dropFirst() } mutating func capitalizeFirstLetter() { - self = self.capitalizingFirstLetter() + self = capitalizingFirstLetter() } // From https://stackoverflow.com/a/59307884 diff --git a/Ferrite/Models/AllDebridModels.swift b/Ferrite/Models/AllDebridModels.swift index fec4bc4..755ae4a 100644 --- a/Ferrite/Models/AllDebridModels.swift +++ b/Ferrite/Models/AllDebridModels.swift @@ -89,11 +89,11 @@ public extension AllDebrid { let container = try decoder.container(keyedBy: CodingKeys.self) if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) { - self.magnets = [data] + magnets = [data] } else if let data = try? container.decode([MagnetStatusData].self, forKey: .magnets) { - self.magnets = data + magnets = data } else { - self.magnets = [] + magnets = [] } } } diff --git a/Ferrite/Models/DebridManagerModels.swift b/Ferrite/Models/DebridManagerModels.swift index 00cd554..a32f3b5 100644 --- a/Ferrite/Models/DebridManagerModels.swift +++ b/Ferrite/Models/DebridManagerModels.swift @@ -5,8 +5,8 @@ // Created by Brian Dashore on 11/27/22. // -import Foundation import Base32 +import Foundation // MARK: - Universal IA enum (IA = InstantAvailability) @@ -41,10 +41,10 @@ public struct Magnet: Codable, Hashable, Sendable { var link: String? init(hash: String?, link: String?, title: String? = nil, trackers: [String]? = nil) { - if let hash = hash, link == nil { + if let hash, link == nil { self.hash = parseHash(hash) self.link = generateLink(hash: hash, title: title, trackers: trackers) - } else if let link = link, hash == nil { + } else if let link, hash == nil { self.link = link self.hash = parseHash(extractHash(link: link)) } else { diff --git a/Ferrite/Models/PremiumizeModels.swift b/Ferrite/Models/PremiumizeModels.swift index 08547e6..21c928e 100644 --- a/Ferrite/Models/PremiumizeModels.swift +++ b/Ferrite/Models/PremiumizeModels.swift @@ -67,12 +67,14 @@ public extension Premiumize { } // MARK: - AllItemsResponse (listall endpoint) + struct AllItemsResponse: Codable { let status: String let files: [UserItem] } // MARK: User Items + // Abridged for required parameters struct UserItem: Codable { let id: String diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 9dfad81..8498868 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -122,7 +122,7 @@ public class DebridManager: ObservableObject { // Wrapper function to match error descriptions // Error can be suppressed to end user but must be printed in logs - func sendDebridError(_ error: Error, prefix: String, presentError: Bool = true, cancelString: String? = nil) async { + func sendDebridError(_ error: Error, prefix: String, presentError: Bool = true, cancelString: String? = nil) async { let error = error as NSError if presentError { if let cancelString, error.code == -999 { @@ -589,7 +589,7 @@ public class DebridManager: ObservableObject { func deleteRdTorrent(torrentID: String? = nil, presentError: Bool = true) async { do { - if let torrentID = torrentID { + if let torrentID { try await realDebrid.deleteTorrent(debridID: torrentID) } else if let selectedTorrentID = selectedRealDebridID { try await realDebrid.deleteTorrent(debridID: selectedTorrentID) diff --git a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift index 7cd8189..f2be3c7 100644 --- a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift +++ b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift @@ -31,20 +31,20 @@ struct BookmarksView: View { for index in offsets { if let bookmark = bookmarks[safe: index] { PersistenceController.shared.delete(bookmark, context: backgroundContext) - + NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark) } } } .onMove { source, destination in var changedBookmarks = bookmarks.map { $0 } - + changedBookmarks.move(fromOffsets: source, toOffset: destination) - + for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) { changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex) } - + PersistenceController.shared.save() } } diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift index 4aed0f7..e76418b 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift @@ -21,7 +21,7 @@ struct AllDebridCloudView: View { searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) }, id: \.id) { magnet in Button { - if magnet.status == "Ready" && !magnet.links.isEmpty { + if magnet.status == "Ready", !magnet.links.isEmpty { navModel.resultFromCloud = true navModel.selectedTitle = magnet.filename diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift index 45e1239..9e07bc6 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift @@ -54,7 +54,7 @@ struct RealDebridCloudView: View { searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) }, id: \.self) { torrentResponse in Button { - if torrentResponse.status == "downloaded" && !torrentResponse.links.isEmpty { + if torrentResponse.status == "downloaded", !torrentResponse.links.isEmpty { navModel.resultFromCloud = true navModel.selectedTitle = torrentResponse.filename diff --git a/Ferrite/Views/ComponentViews/Library/HistoryView.swift b/Ferrite/Views/ComponentViews/Library/HistoryView.swift index ca273db..1e7b4cc 100644 --- a/Ferrite/Views/ComponentViews/Library/HistoryView.swift +++ b/Ferrite/Views/ComponentViews/Library/HistoryView.swift @@ -46,7 +46,7 @@ struct HistoryView: View { } func groupedHistory(_ result: FetchedResults) -> [[History]] { - return Dictionary(grouping: result) { (element: History) in + Dictionary(grouping: result) { (element: History) in element.dateString ?? "" } .values @@ -87,9 +87,9 @@ struct HistorySectionView: View { func compareGroup(_ group: [History]) -> Int { var totalCount = 0 for history in group { - totalCount += history.entryArray.reduce(0, { result, item in - result + (allEntries.contains { $0.name == item.name || (item.subName.map { return !$0.isEmpty } ?? false && $0.subName == item.subName) } ? 1 : 0) - }) + totalCount += history.entryArray.reduce(0) { result, item in + result + (allEntries.contains { $0.name == item.name || (item.subName.map { !$0.isEmpty } ?? false && $0.subName == item.subName) } ? 1 : 0) + } } return totalCount diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift index 66ddb99..40a1ffa 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift @@ -40,7 +40,7 @@ struct SearchResultButtonView: View { source: result.source ) ) - + navModel.runDebridAction(urlString: debridManager.downloadUrl) if navModel.currentChoiceSheet != .magnet { @@ -130,8 +130,9 @@ struct SearchResultButtonView: View { .onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in // If the instance contains the deleted bookmark, remove it. if let deletedBookmark = notification.object as? Bookmark, - let bookmark = existingBookmark, - deletedBookmark.objectID == bookmark.objectID { + let bookmark = existingBookmark, + deletedBookmark.objectID == bookmark.objectID + { existingBookmark = nil } } diff --git a/Ferrite/Views/ComponentViews/Source/SourceSettingsView.swift b/Ferrite/Views/ComponentViews/Source/SourceSettingsView.swift index 0936658..2d70f74 100644 --- a/Ferrite/Views/ComponentViews/Source/SourceSettingsView.swift +++ b/Ferrite/Views/ComponentViews/Source/SourceSettingsView.swift @@ -60,7 +60,7 @@ struct SourceSettingsView: View { .onDisappear { PersistenceController.shared.save() } - .navigationTitle("Source settings") + .navigationTitle("Source Settings") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { -- 2.45.2 From f960efc1ed18dbd823033a96810e62c270f0e0aa Mon Sep 17 00:00:00 2001 From: kingbri Date: Sat, 7 Jan 2023 18:11:20 -0500 Subject: [PATCH 18/19] Update README Signed-off-by: kingbri --- README.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 49cd3f1..9931aa6 100644 --- a/README.md +++ b/README.md @@ -14,27 +14,39 @@ Finding shows and movies is usually pretty easy because there are many websites However, the main problem is that these websites tend to suck in terms of UI or finding media to watch. Ferrite aims to provide a better UI to search and find the media you want. -I also wanted to support the use of RealDebrid since there aren't any (free) options on iOS that have support for this service. +I also wanted to support the use of debrid services since there aren't any (free) options on iOS that have support for this service. ## What iOS versions are supported? -iOS 14 and up. I was able to successfully backport the app! +- v0.7 and up: iOS 15 and up + +- v0.6.x and lower: iOS 14 and up ## Planned features -- Website API support in sources: This allows for website APIs to be used in Ferrite sources which is quicker than scraping or RSS parsing +More of these can be found in [issues](https://github.com/bdashore3/Ferrite/issues), but here is a small snippet: + +- A decentralized actions API: Allows for playback on other devices + +- More involved search filtering + +- Companion apps for playback on other devices ## Downloads Ferrite will only exist as an ipa. There are and will never be any plans to release on TestFlight or the App Store. Ipa builds are automatically built and are provided in Github actions artifacts. +## Plugins/Sources + +Sources are not provided by the application. They must be found from external means or you can make them yourself using the [wiki](https://github.com/bdashore3/Ferrite/wiki). Various communities have created sources for Ferrite and they can be imported in the app with ease. + ## Building from source -Xcode 14 must be used since Ferrite requires some iOS 16 APIs that are not present in Xcode 13. Please make sure you have the right Xcode or download the beta xip from Apple's developer website. +Xcode 14 must be used. -There is currently one branch in the repository: +There are currently two branches in the repository: -- default: The current working branch. This will change in the future once a stable version is released. +- default: A snapshot of the latest stable build. Tags can also be used for older versions. - next: The development branch. Nightlies are automatically built here. ## Nightly builds -- 2.45.2 From 6456b342101bcc188107bbb9efcdbac86d13b823 Mon Sep 17 00:00:00 2001 From: kingbri Date: Sat, 7 Jan 2023 18:19:58 -0500 Subject: [PATCH 19/19] Ferrite: Bump version Signed-off-by: kingbri --- Ferrite.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index a4a2d25..b47a44e 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -875,7 +875,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\""; DEVELOPMENT_TEAM = 8A74DBQ6S3; ENABLE_PREVIEWS = YES; @@ -894,7 +894,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.5.1; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -910,7 +910,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\""; DEVELOPMENT_TEAM = 8A74DBQ6S3; ENABLE_PREVIEWS = YES; @@ -929,7 +929,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.5.1; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; -- 2.45.2