From b85752c92c3eb1d06529e22146cfb17ed3c059d4 Mon Sep 17 00:00:00 2001 From: kingbri Date: Fri, 16 Sep 2022 12:29:08 -0400 Subject: [PATCH] RealDebrid: Improve fetch and recache times To declutter a RealDebrid user's library, check if the file and unrestricted link exist and serve those existing links. Otherwise perform a download like normal. Sometimes RealDebrid deletes cached items, but still keeps them on instant availability. Add a way to tell the user that the item is downloading along with an option to cancel it. Also remove unnecessary published variables from viewmodels Signed-off-by: kingbri --- Ferrite/API/RealDebridWrapper.swift | 29 +++++- Ferrite/Models/RealDebridModels.swift | 37 +++++++- Ferrite/ViewModels/DebridManager.swift | 88 +++++++++++++------ Ferrite/ViewModels/NavigationViewModel.swift | 6 +- Ferrite/ViewModels/ScrapingViewModel.swift | 2 +- Ferrite/ViewModels/SourceManager.swift | 2 +- Ferrite/Views/BatchChoiceView.swift | 2 +- .../Views/LibraryViews/BookmarksView.swift | 21 ++--- Ferrite/Views/SearchResultButtonView.swift | 31 +++++-- Ferrite/Views/SearchResultsView.swift | 2 - 10 files changed, 161 insertions(+), 59 deletions(-) diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index ffd06f3..fb60a5f 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -14,6 +14,7 @@ public enum RealDebridError: Error { case InvalidResponse case InvalidToken case EmptyData + case EmptyTorrents case FailedRequest(description: String) case AuthQuery(description: String) } @@ -306,21 +307,33 @@ public class RealDebrid { try await performRequest(request: &request, requestName: #function) } - // Fetches the info of a torrent + // Gets the info of a torrent from a given ID 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) - // Error out if no index is provided - if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1] { + // Let the user know if a torrent is downloading + if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" { return torrentLink + } else if rawResponse.status == "downloading" || rawResponse.status == "queued" { + throw RealDebridError.EmptyTorrents } else { throw RealDebridError.EmptyData } } + // Gets the user's torrent library + public func userTorrents() async throws -> [UserTorrentsResponse] { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data) + + return rawResponse + } + // Deletes a torrent download from RD public func deleteTorrent(debridID: String) async throws { var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(debridID)")!) @@ -345,4 +358,14 @@ public class RealDebrid { return rawResponse.download } + + // Gets the user's downloads + public func userDownloads() async throws -> [UserDownloadsResponse] { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data) + + return rawResponse + } } diff --git a/Ferrite/Models/RealDebridModels.swift b/Ferrite/Models/RealDebridModels.swift index b070fd9..39506f0 100644 --- a/Ferrite/Models/RealDebridModels.swift +++ b/Ferrite/Models/RealDebridModels.swift @@ -122,14 +122,16 @@ struct TorrentInfoResponse: Codable { let status, added: String let files: [TorrentInfoFile] let links: [String] - let ended: 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 + case host, split, progress, status, added, files, links, ended, speed, seeders } } @@ -139,6 +141,17 @@ struct TorrentInfoFile: Codable { let bytes, selected: Int } +public struct UserTorrentsResponse: Codable { + 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 { @@ -157,3 +170,23 @@ struct UnrestrictLinkResponse: Codable { case chunks, crc, download, streamable } } + +// MARK: - User downloads list + +public struct UserDownloadsResponse: Codable { + let id, filename, 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 a579d21..fc6457e 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -19,23 +19,28 @@ public class DebridManager: ObservableObject { @Published var showLoadingProgress: Bool = false // Service agnostic variables - @Published var currentDebridTask: Task? + var currentDebridTask: Task? // RealDebrid auth variables - @Published var realDebridEnabled: Bool = false { + var realDebridEnabled: Bool = false { didSet { UserDefaults.standard.set(realDebridEnabled, forKey: "RealDebrid.Enabled") } } @Published var realDebridAuthProcessing: Bool = false - @Published var realDebridAuthUrl: String = "" + var realDebridAuthUrl: String = "" // RealDebrid fetch variables @Published var realDebridIAValues: [RealDebridIA] = [] - @Published var realDebridDownloadUrl: String = "" - @Published var selectedRealDebridItem: RealDebridIA? - @Published var selectedRealDebridFile: RealDebridIAFile? + 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 selectedRealDebridID: String? init() { realDebridEnabled = UserDefaults.standard.bool(forKey: "RealDebrid.Enabled") @@ -138,7 +143,7 @@ public class DebridManager: ObservableObject { } } - public func fetchRdDownload(searchResult: SearchResult, iaFile: RealDebridIAFile? = nil) async { + public func fetchRdDownload(searchResult: SearchResult) async { defer { currentDebridTask = nil showLoadingProgress = false @@ -153,14 +158,10 @@ public class DebridManager: ObservableObject { return } - var realDebridId: String? - do { - realDebridId = try await realDebrid.addMagnet(magnetLink: magnetLink) - var fileIds: [Int] = [] - if let iaFile = iaFile { + if let iaFile = selectedRealDebridFile { guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else { return } @@ -168,29 +169,52 @@ public class DebridManager: ObservableObject { fileIds = iaBatchFromFile.files.map(\.id) } - if let realDebridId = realDebridId { - try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds) + // 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 torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: iaFile?.batchFileIndex ?? 0) - let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink) + // 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 { + realDebridDownloadUrl = existingLink + } else { + let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink) + + realDebridDownloadUrl = downloadLink + } - realDebridDownloadUrl = downloadLink } else { - toastModel?.updateToastDescription("Could not cache this torrent. Aborting.") + // Add a magnet after all the cache checks fail + selectedRealDebridID = try await realDebrid.addMagnet(magnetLink: magnetLink) + + 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 downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink) + + realDebridDownloadUrl = downloadLink + } else { + toastModel?.updateToastDescription("Could not cache this torrent. Aborting.") + } } } catch { - let error = error as NSError - - switch error.code { - case -999: - toastModel?.updateToastDescription("Download cancelled", newToastType: .info) + switch error { + case RealDebridError.EmptyTorrents: + showDeleteAlert.toggle() default: - toastModel?.updateToastDescription("RealDebrid download error: \(error)") - } + let error = error as NSError - // Delete the torrent download if it exists - if let realDebridId = realDebridId { - try? await realDebrid.deleteTorrent(debridID: realDebridId) + switch error.code { + case -999: + toastModel?.updateToastDescription("Download cancelled", newToastType: .info) + default: + toastModel?.updateToastDescription("RealDebrid download error: \(error)") + } + + await deleteRdTorrent() } showLoadingProgress = false @@ -198,4 +222,12 @@ public class DebridManager: ObservableObject { print("RealDebrid download error: \(error)") } } + + public func deleteRdTorrent() async { + if let realDebridId = selectedRealDebridID { + try? await realDebrid.deleteTorrent(debridID: realDebridId) + } + + selectedRealDebridID = nil + } } diff --git a/Ferrite/ViewModels/NavigationViewModel.swift b/Ferrite/ViewModels/NavigationViewModel.swift index 4fa24bf..1f5320d 100644 --- a/Ferrite/ViewModels/NavigationViewModel.swift +++ b/Ferrite/ViewModels/NavigationViewModel.swift @@ -37,7 +37,7 @@ class NavigationViewModel: ObservableObject { @Published var hideNavigationBar = false @Published var currentChoiceSheet: ChoiceSheetType? - @Published var activityItems: [Any] = [] + var activityItems: [Any] = [] // Used to show the activity sheet in the share menu @Published var showLocalActivitySheet = false @@ -47,10 +47,10 @@ class NavigationViewModel: ObservableObject { // Used between SourceListView and SourceSettingsView @Published var showSourceSettings: Bool = false - @Published var selectedSource: Source? + var selectedSource: Source? @Published var showSourceListEditor: Bool = false - @Published var selectedSourceList: SourceList? + var selectedSourceList: SourceList? @AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none @AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none diff --git a/Ferrite/ViewModels/ScrapingViewModel.swift b/Ferrite/ViewModels/ScrapingViewModel.swift index d7a4ef3..b64ac54 100644 --- a/Ferrite/ViewModels/ScrapingViewModel.swift +++ b/Ferrite/ViewModels/ScrapingViewModel.swift @@ -18,7 +18,7 @@ class ScrapingViewModel: ObservableObject { var toastModel: ToastViewModel? let byteCountFormatter: ByteCountFormatter = .init() - @Published var runningSearchTask: Task? + var runningSearchTask: Task? @Published var searchResults: [SearchResult] = [] @Published var searchText: String = "" @Published var filteredSource: Source? diff --git a/Ferrite/ViewModels/SourceManager.swift b/Ferrite/ViewModels/SourceManager.swift index d4f83e0..2484bfb 100644 --- a/Ferrite/ViewModels/SourceManager.swift +++ b/Ferrite/ViewModels/SourceManager.swift @@ -14,7 +14,7 @@ public class SourceManager: ObservableObject { @Published var availableSources: [SourceJson] = [] - @Published var urlErrorAlertText = "" + var urlErrorAlertText = "" @Published var showUrlErrorAlert = false @MainActor diff --git a/Ferrite/Views/BatchChoiceView.swift b/Ferrite/Views/BatchChoiceView.swift index eb7369c..afae3c8 100644 --- a/Ferrite/Views/BatchChoiceView.swift +++ b/Ferrite/Views/BatchChoiceView.swift @@ -23,7 +23,7 @@ struct BatchChoiceView: View { if let searchResult = navModel.selectedSearchResult { debridManager.currentDebridTask = Task { - await debridManager.fetchRdDownload(searchResult: searchResult, iaFile: file) + await debridManager.fetchRdDownload(searchResult: searchResult) if !debridManager.realDebridDownloadUrl.isEmpty { // The download may complete before this sheet dismisses diff --git a/Ferrite/Views/LibraryViews/BookmarksView.swift b/Ferrite/Views/LibraryViews/BookmarksView.swift index 2ad3fb2..3ed4b51 100644 --- a/Ferrite/Views/LibraryViews/BookmarksView.swift +++ b/Ferrite/Views/LibraryViews/BookmarksView.swift @@ -49,19 +49,20 @@ struct BookmarksView: View { PersistenceController.shared.save() } } + .id(UUID()) .listStyle(.insetGrouped) - .onAppear { - if realDebridEnabled { - viewTask = Task { - let hashes = bookmarks.compactMap { $0.magnetHash } - await debridManager.populateDebridHashes(hashes) - } - } - } - .onDisappear { - viewTask?.cancel() + } + } + .onAppear { + if realDebridEnabled { + viewTask = Task { + let hashes = bookmarks.compactMap { $0.magnetHash } + await debridManager.populateDebridHashes(hashes) } } } + .onDisappear { + viewTask?.cancel() + } } } diff --git a/Ferrite/Views/SearchResultButtonView.swift b/Ferrite/Views/SearchResultButtonView.swift index b952c2a..e4b6e82 100644 --- a/Ferrite/Views/SearchResultButtonView.swift +++ b/Ferrite/Views/SearchResultButtonView.swift @@ -28,15 +28,17 @@ struct SearchResultButtonView: View { switch debridManager.matchSearchResult(result: result) { case .full: - debridManager.currentDebridTask = Task { - await debridManager.fetchRdDownload(searchResult: result) - - if !debridManager.realDebridDownloadUrl.isEmpty { - navModel.addToHistory(name: result.title, source: result.source, url: debridManager.realDebridDownloadUrl) - navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl) + if debridManager.setSelectedRdResult(result: result) { + debridManager.currentDebridTask = Task { + await debridManager.fetchRdDownload(searchResult: result) + + if !debridManager.realDebridDownloadUrl.isEmpty { + navModel.addToHistory(name: result.title, source: result.source, url: debridManager.realDebridDownloadUrl) + navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl) - if navModel.currentChoiceSheet != .magnet { - debridManager.realDebridDownloadUrl = "" + if navModel.currentChoiceSheet != .magnet { + debridManager.realDebridDownloadUrl = "" + } } } } @@ -91,6 +93,19 @@ struct SearchResultButtonView: View { } } } + .dynamicAlert( + isPresented: $debridManager.showDeleteAlert, + title: "Caching file", + message: "RealDebrid is currently caching this file. Would you like to delete it? \n\nProgress can be checked on the RealDebrid website.", + buttons: [ + AlertButton("Yes", role: .destructive) { + Task { + await debridManager.deleteRdTorrent() + } + }, + AlertButton(role: .cancel) + ] + ) .onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { _ in existingBookmark = nil } diff --git a/Ferrite/Views/SearchResultsView.swift b/Ferrite/Views/SearchResultsView.swift index 2c04731..110813e 100644 --- a/Ferrite/Views/SearchResultsView.swift +++ b/Ferrite/Views/SearchResultsView.swift @@ -11,8 +11,6 @@ struct SearchResultsView: View { @EnvironmentObject var scrapingModel: ScrapingViewModel @EnvironmentObject var navModel: NavigationViewModel - @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false - var body: some View { List { ForEach(scrapingModel.searchResults, id: \.self) { result in