diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index 7985319..8b15878 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -32,6 +32,8 @@ 0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D3288903F000DE2211 /* SearchResultsView.swift */; }; 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D4288903F000DE2211 /* ContentView.swift */; }; 0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; }; + 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; }; + 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; }; 0CFEFCFD288A006200B3F490 /* GroupBoxStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */; }; /* End PBXBuildFile section */ @@ -43,7 +45,7 @@ 0CA148C1288903F000DE2211 /* NavView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavView.swift; sourceTree = ""; }; 0CA148C2288903F000DE2211 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrapingViewModel.swift; sourceTree = ""; }; - 0CA148C4288903F000DE2211 /* RealDebridModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = ""; }; + 0CA148C4288903F000DE2211 /* RealDebridModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 3; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = ""; }; 0CA148C6288903F000DE2211 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 0CA148C7288903F000DE2211 /* FerriteApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FerriteApp.swift; sourceTree = ""; }; 0CA148C9288903F000DE2211 /* Collection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; @@ -58,6 +60,8 @@ 0CA148D3288903F000DE2211 /* SearchResultsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = ""; }; 0CA148D4288903F000DE2211 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = ""; }; + 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = ""; }; 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupBoxStyle.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -128,6 +132,7 @@ 0CA148BB288903F000DE2211 /* SettingsView.swift */, 0CA148BE288903F000DE2211 /* CardView.swift */, 0CA148BC288903F000DE2211 /* LoginWebView.swift */, + 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */, 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */, ); path = Views; @@ -139,6 +144,7 @@ 0CA148CD288903F000DE2211 /* DebridManager.swift */, 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */, 0CA148CF288903F000DE2211 /* ToastViewModel.swift */, + 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */, ); path = Models; sourceTree = ""; @@ -261,6 +267,7 @@ files = ( 0CA148DB288903F000DE2211 /* NavView.swift in Sources */, 0CA148E9288903F000DE2211 /* MainView.swift in Sources */, + 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */, 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */, 0CA148E1288903F000DE2211 /* Collection.swift in Sources */, 0CA148E4288903F000DE2211 /* Keychain.swift in Sources */, @@ -272,6 +279,7 @@ 0CA148E6288903F000DE2211 /* WebView.swift in Sources */, 0CA148E2288903F000DE2211 /* Data.swift in Sources */, 0CA148DE288903F000DE2211 /* RealDebridModels.swift in Sources */, + 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */, 0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */, 0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */, 0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */, diff --git a/Ferrite/API/RealDebridModels.swift b/Ferrite/API/RealDebridModels.swift index 63cb7c7..49eb17f 100644 --- a/Ferrite/API/RealDebridModels.swift +++ b/Ferrite/API/RealDebridModels.swift @@ -51,10 +51,10 @@ public struct TokenResponse: Codable { // MARK: - instantAvailability endpoint // Thanks Skitty! -struct InstantAvailabilityResponse: Codable { +public struct InstantAvailabilityResponse: Codable { var data: InstantAvailabilityData? - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let data = try? container.decode(InstantAvailabilityData.self) { @@ -72,6 +72,34 @@ struct InstantAvailabilityInfo: Codable { var filesize: Int } +// MARK: - Instant Availability client side structures +public struct RealDebridIA: Codable, Hashable { + let hash: String + var files: [RealDebridIAFile] = [] + var batches: [RealDebridIABatch] = [] +} + +public struct RealDebridIABatch: Codable, Hashable { + let files: [RealDebridIABatchFile] +} + +public struct RealDebridIABatchFile: Codable, Hashable { + let id: Int + let fileName: String +} + +public struct RealDebridIAFile: Codable, Hashable { + let name: String + let batchIndex: Int + let batchFileIndex: Int +} + +public enum RealDebridIAStatus: Codable, Hashable { + case full + case partial + case none +} + // MARK: - addMagnet endpoint public struct AddMagnetResponse: Codable { let id: String diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index 0267d62..5d29442 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -218,8 +218,8 @@ public class RealDebrid: ObservableObject { // Checks if the magnet is streamable on RD // Currently does not work for batch links - public func instantAvailability(magnetHashes: [String]) async throws -> [String] { - var availableHashes: [String] = [] + public func instantAvailability(magnetHashes: [String]) async throws -> [RealDebridIA] { + var availableHashes: [RealDebridIA] = [] var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnetHashes.joined(separator: "/"))")!) let data = try await performRequest(request: &request, requestName: #function) @@ -232,9 +232,47 @@ public class RealDebrid: ObservableObject { continue } - // Do not include if a hash is a batch - if !(data.rd.count > 1), !(data.rd[safe: 0]?.keys.count ?? 0 > 1) { - availableHashes.append(hash) + if data.rd.isEmpty { + continue + } + + // Is this a batch + 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 + // Force unwrapped ID. Is safe because ID is guaranteed on a successful response + return RealDebridIABatchFile(id: Int(key)!, fileName: value.filename) + }.sorted(by: { $0.id < $1.id }) + + return RealDebridIABatch(files: batchFiles) + } + + // RD files array + // Possibly sort this in the future, but not sure how at the moment + var files: [RealDebridIAFile] = [] + + for index in batches.indices { + let batchFiles = batches[index].files + + for batchFileIndex in batchFiles.indices { + let batchFile = batchFiles[batchFileIndex] + + if !files.contains(where: { $0.name == batchFile.fileName }) { + files.append( + RealDebridIAFile( + name: batchFile.fileName, + batchIndex: index, + batchFileIndex: batchFileIndex + ) + ) + } + } + } + + availableHashes.append(RealDebridIA(hash: hash, files: files, batches: batches)) + } else { + availableHashes.append(RealDebridIA(hash: hash)) } } @@ -259,13 +297,19 @@ public class RealDebrid: ObservableObject { } // Queues the magnet link for downloading - public func selectFiles(debridID: String) async throws { + public func selectFiles(debridID: String, fileIds: [Int]) async throws { var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") var bodyComponents = URLComponents() - bodyComponents.queryItems = [URLQueryItem(name: "files", value: "all")] + + if fileIds.isEmpty { + bodyComponents.queryItems = [URLQueryItem(name: "files", value: "all")] + } else { + let joinedIds = fileIds.map(String.init).joined(separator: ",") + bodyComponents.queryItems = [URLQueryItem(name: "files", value: joinedIds)] + } request.httpBody = bodyComponents.query?.data(using: .utf8) @@ -273,13 +317,14 @@ public class RealDebrid: ObservableObject { } // Fetches the info of a torrent - public func torrentInfo(debridID: String) async throws -> String { + public func torrentInfo(debridID: String, selectedIndex: Int?) async throws -> String { var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!) let data = try await performRequest(request: &request, requestName: #function) let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data) - if let torrentLink = rawResponse.links[safe: 0] { + // Error out if no index is provided + if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1] { return torrentLink } else { throw RealDebridError.EmptyData diff --git a/Ferrite/FerriteApp.swift b/Ferrite/FerriteApp.swift index 3bdeaa9..5171831 100644 --- a/Ferrite/FerriteApp.swift +++ b/Ferrite/FerriteApp.swift @@ -12,6 +12,7 @@ struct FerriteApp: App { @StateObject var scrapingModel: ScrapingViewModel = .init() @StateObject var toastModel: ToastViewModel = .init() @StateObject var debridManager: DebridManager = .init() + @StateObject var navigationModel: NavigationViewModel = .init() var body: some Scene { WindowGroup { @@ -23,6 +24,7 @@ struct FerriteApp: App { .environmentObject(debridManager) .environmentObject(scrapingModel) .environmentObject(toastModel) + .environmentObject(navigationModel) } } } diff --git a/Ferrite/Models/DebridManager.swift b/Ferrite/Models/DebridManager.swift index 345e11f..b3177e1 100644 --- a/Ferrite/Models/DebridManager.swift +++ b/Ferrite/Models/DebridManager.swift @@ -18,9 +18,11 @@ public class DebridManager: ObservableObject { @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false - @Published var realDebridHashes: [String] = [] + @Published var realDebridHashes: [RealDebridIA] = [] @Published var realDebridAuthUrl: String = "" @Published var realDebridDownloadUrl: String = "" + @Published var selectedRealDebridItem: RealDebridIA? + @Published var selectedRealDebridFile: RealDebridIAFile? init() { realDebrid.parentManager = self @@ -50,6 +52,38 @@ public class DebridManager: ObservableObject { } } + public func matchSearchResult(result: SearchResult?) -> RealDebridIAStatus { + guard let result = result else { + return .none + } + + guard let debridMatch = realDebridHashes.first(where: { result.magnetHash == $0.hash }) else { + return .none + } + + if debridMatch.batches.isEmpty { + return .full + } else { + return .partial + } + } + + @MainActor + public func setSelectedRdResult(result: SearchResult) -> Bool { + guard let magnetHash = result.magnetHash else { + toastModel?.toastDescription = "Could not find the torrent magnet hash" + return false + } + + if let realDebridItem = realDebridHashes.first(where: { magnetHash == $0.hash }) { + selectedRealDebridItem = realDebridItem + return true + } else { + toastModel?.toastDescription = "Could not find the associated RealDebrid entry for magnet hash \(magnetHash)" + return false + } + } + public func authenticateRd() async { do { let url = try await realDebrid.getVerificationInfo() @@ -67,12 +101,23 @@ public class DebridManager: ObservableObject { } } - public func fetchRdDownload(searchResult: SearchResult) async { + public func fetchRdDownload(searchResult: SearchResult, iaFile: RealDebridIAFile? = nil) async { do { let realDebridId = try await realDebrid.addMagnet(magnetLink: searchResult.magnetLink) - try await realDebrid.selectFiles(debridID: realDebridId) - let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId) + var fileIds: [Int] = [] + + if let iaFile = iaFile { + guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else { + return + } + + fileIds = iaBatchFromFile.files.map({ $0.id }) + } + + try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds) + + let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: iaFile == nil ? 0 : iaFile?.batchFileIndex) let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink) Task { @MainActor in diff --git a/Ferrite/Models/NavigationViewModel.swift b/Ferrite/Models/NavigationViewModel.swift new file mode 100644 index 0000000..20b09f8 --- /dev/null +++ b/Ferrite/Models/NavigationViewModel.swift @@ -0,0 +1,21 @@ +// +// NavigationViewModel.swift +// Ferrite +// +// Created by Brian Dashore on 7/24/22. +// + +import SwiftUI + +class NavigationViewModel: ObservableObject { + enum ChoiceSheetType: Identifiable { + var id: Int { + hashValue + } + + case magnet + case batch + } + + @Published var currentChoiceSheet: ChoiceSheetType? +} diff --git a/Ferrite/Models/ScrapingViewModel.swift b/Ferrite/Models/ScrapingViewModel.swift index e7d5c9d..40aee36 100644 --- a/Ferrite/Models/ScrapingViewModel.swift +++ b/Ferrite/Models/ScrapingViewModel.swift @@ -54,9 +54,7 @@ class ScrapingViewModel: ObservableObject { @Published var searchResults: [SearchResult] = [] @Published var debridHashes: [String] = [] @Published var searchText: String = "" - - @Published var realDebridAuthUrl: String = "" - @Published var showWebView: Bool = false + @Published var selectedSearchResult: SearchResult? // Fetches the HTML body for the source website @MainActor diff --git a/Ferrite/Views/BatchChoiceView.swift b/Ferrite/Views/BatchChoiceView.swift new file mode 100644 index 0000000..dbaac88 --- /dev/null +++ b/Ferrite/Views/BatchChoiceView.swift @@ -0,0 +1,61 @@ +// +// BatchChoiceView.swift +// Ferrite +// +// Created by Brian Dashore on 7/24/22. +// + +import SwiftUI + +struct BatchChoiceView: View { + @Environment(\.dismiss) var dismiss + + @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var scrapingModel: ScrapingViewModel + @EnvironmentObject var navigationModel: NavigationViewModel + + var body: some View { + NavView { + List { + // To present this sheet, an RD item had to be set, this force unwrap is therefore safe + ForEach(debridManager.selectedRealDebridItem!.files, id: \.self) { file in + Button(file.name) { + debridManager.selectedRealDebridFile = file + + if let searchResult = scrapingModel.selectedSearchResult { + Task { + await debridManager.fetchRdDownload(searchResult: searchResult, iaFile: file) + + // The download may complete before this sheet dismisses + try? await Task.sleep(seconds: 1) + navigationModel.currentChoiceSheet = .magnet + + debridManager.selectedRealDebridFile = nil + debridManager.selectedRealDebridItem = nil + } + } + + dismiss() + } + } + } + .navigationTitle("Select a file") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + debridManager.selectedRealDebridItem = nil + + dismiss() + } + } + } + } + } +} + +struct BatchChoiceView_Previews: PreviewProvider { + static var previews: some View { + BatchChoiceView() + } +} diff --git a/Ferrite/Views/ContentView.swift b/Ferrite/Views/ContentView.swift index 32edbe7..801976b 100644 --- a/Ferrite/Views/ContentView.swift +++ b/Ferrite/Views/ContentView.swift @@ -11,6 +11,8 @@ struct ContentView: View { @EnvironmentObject var scrapingModel: ScrapingViewModel @EnvironmentObject var debridManager: DebridManager + @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false + var body: some View { NavView { VStack { @@ -25,7 +27,10 @@ struct ContentView: View { } await scrapingModel.scrapeWebsite(source: source, html: html) - await debridManager.populateDebridHashes(scrapingModel.searchResults) + + if realDebridEnabled { + await debridManager.populateDebridHashes(scrapingModel.searchResults) + } } } } diff --git a/Ferrite/Views/MagnetChoiceView.swift b/Ferrite/Views/MagnetChoiceView.swift index 257a2b9..60a68d2 100644 --- a/Ferrite/Views/MagnetChoiceView.swift +++ b/Ferrite/Views/MagnetChoiceView.swift @@ -11,19 +11,18 @@ import ActivityView struct MagnetChoiceView: View { @Environment(\.dismiss) var dismiss + @EnvironmentObject var scrapingModel: ScrapingViewModel @EnvironmentObject var debridManager: DebridManager @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false - @Binding var selectedResult: SearchResult? - @State private var showActivityView = false @State private var activityItem: ActivityItem? var body: some View { NavView { Form { - if realDebridEnabled, debridManager.realDebridHashes.contains(selectedResult?.magnetHash ?? "") { + if realDebridEnabled, debridManager.matchSearchResult(result: scrapingModel.selectedSearchResult) != .none { Section("Real Debrid options") { Button("Play on Outplayer") { guard let downloadUrl = URL(string: "outplayer://\(debridManager.realDebridDownloadUrl)") else { @@ -66,11 +65,11 @@ struct MagnetChoiceView: View { Section("Magnet options") { Button("Copy magnet") { - UIPasteboard.general.string = selectedResult?.magnetLink + UIPasteboard.general.string = scrapingModel.selectedSearchResult?.magnetLink } Button("Share magnet") { - if let result = selectedResult, let url = URL(string: result.magnetLink) { + if let result = scrapingModel.selectedSearchResult, let url = URL(string: result.magnetLink) { activityItem = ActivityItem(items: url) showActivityView.toggle() } @@ -83,6 +82,8 @@ struct MagnetChoiceView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { + debridManager.realDebridDownloadUrl = "" + dismiss() } } @@ -93,16 +94,6 @@ struct MagnetChoiceView: View { struct MagnetChoiceView_Previews: PreviewProvider { static var previews: some View { - MagnetChoiceView( - selectedResult: - .constant( - SearchResult( - title: "", - source: "", - size: "", - magnetLink: "", - magnetHash: nil) - ) - ) + MagnetChoiceView() } } diff --git a/Ferrite/Views/SearchResultsView.swift b/Ferrite/Views/SearchResultsView.swift index b80ed7e..c860091 100644 --- a/Ferrite/Views/SearchResultsView.swift +++ b/Ferrite/Views/SearchResultsView.swift @@ -13,36 +13,42 @@ struct SearchResultsView: View { @EnvironmentObject var scrapingModel: ScrapingViewModel @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var navigationModel: NavigationViewModel @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false - @State var selectedResult: SearchResult? - - @State private var showExternalSheet = false - @State private var resultUsesRd = false - var body: some View { List { ForEach(scrapingModel.searchResults, id: \.self) { result in VStack(alignment: .leading) { Button { - selectedResult = result + scrapingModel.selectedSearchResult = result - if debridManager.realDebridHashes.contains(result.magnetHash ?? ""), realDebridEnabled { + switch debridManager.matchSearchResult(result: result) { + case .full: Task { await debridManager.fetchRdDownload(searchResult: result) - showExternalSheet.toggle() + navigationModel.currentChoiceSheet = .magnet } - } else { - showExternalSheet.toggle() + case .partial: + if debridManager.setSelectedRdResult(result: result) { + navigationModel.currentChoiceSheet = .batch + } + case .none: + navigationModel.currentChoiceSheet = .magnet } } label: { Text(result.title) .font(.callout) .fixedSize(horizontal: false, vertical: true) } - .sheet(isPresented: $showExternalSheet) { - MagnetChoiceView(selectedResult: $selectedResult) + .sheet(item: $navigationModel.currentChoiceSheet) { item in + switch item { + case .magnet: + MagnetChoiceView() + case .batch: + BatchChoiceView() + } } .tint(colorScheme == .light ? .black : .white) .padding(.bottom, 5) @@ -59,11 +65,16 @@ struct SearchResultsView: View { .fontWeight(.bold) .padding(2) .background { - if debridManager.realDebridHashes.contains(result.magnetHash ?? "") { + switch debridManager.matchSearchResult(result: result) { + case .full: Color.green .cornerRadius(4) .opacity(0.5) - } else { + case .partial: + Color.orange + .cornerRadius(4) + .opacity(0.5) + case .none: Color.red .cornerRadius(4) .opacity(0.5)