diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index f0846d8..7d5c314 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -13,6 +13,8 @@ 0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; }; 0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; }; 0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6032C1A859B00808A46 /* FormDataBody.swift */; }; + 0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */; }; + 0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */; }; 0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */; }; 0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; }; 0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */; }; @@ -171,6 +173,8 @@ 0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = ""; }; 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = ""; }; 0C07C6032C1A859B00808A46 /* FormDataBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormDataBody.swift; sourceTree = ""; }; + 0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudModels.swift; sourceTree = ""; }; + 0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudWrapper.swift; sourceTree = ""; }; 0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatingSystemVersion.swift; sourceTree = ""; }; 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = ""; }; 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginAggregateView.swift; sourceTree = ""; }; @@ -417,6 +421,7 @@ 0C6771F929B3D1AE005D38D2 /* KodiModels.swift */, 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */, 0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */, + 0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */, ); path = Models; sourceTree = ""; @@ -670,6 +675,7 @@ 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */, 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */, 0C890E482C188808003B17B5 /* TorBoxWrapper.swift */, + 0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */, ); path = API; sourceTree = ""; @@ -937,6 +943,7 @@ 0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */, 0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */, 0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */, + 0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */, 0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */, 0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */, 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */, @@ -946,6 +953,7 @@ 0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */, 0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */, 0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */, + 0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */, 0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */, 0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */, 0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */, diff --git a/Ferrite/API/OffCloudWrapper.swift b/Ferrite/API/OffCloudWrapper.swift new file mode 100644 index 0000000..2bee0d2 --- /dev/null +++ b/Ferrite/API/OffCloudWrapper.swift @@ -0,0 +1,268 @@ +// +// OffCloudWrapper.swift +// Ferrite +// +// Created by Brian Dashore on 6/12/24. +// + +import Foundation + +// Torrents: /cloud/history +// IA: /cache (JSON array of hashes) +// Add Magnet: /cloud (URL param in JSON body) +// Get files/unrestrict: /cloud/explore/\(requestId) +// Delete torrent (website URL, not API URL): /cloud/remove/\(torrentId) + +class OffCloud: DebridSource, ObservableObject { + var id: String = "OffCloud" + var abbreviation: String = "OC" + var website: String = "https://offcloud.com" + + @Published var authProcessing: Bool = false + var isLoggedIn: Bool { + getToken() != nil + } + + var manualToken: String? { + if UserDefaults.standard.bool(forKey: "OffCloud.UseManualKey") { + return getToken() + } else { + return nil + } + } + + @Published var IAValues: [DebridIA] = [] + @Published var cloudDownloads: [DebridCloudDownload] = [] + @Published var cloudTorrents: [DebridCloudTorrent] = [] + var cloudTTL: Double = 0.0 + + private let baseApiUrl = "https://offcloud.com/api" + private let jsonDecoder = JSONDecoder() + private let jsonEncoder = JSONEncoder() + + func setApiKey(_ key: String) { + FerriteKeychain.shared.set(key, forKey: "OffCloud.ApiKey") + UserDefaults.standard.set(true, forKey: "OffCloud.UseManualKey") + } + + func logout() async { + FerriteKeychain.shared.delete("OffCloud.ApiKey") + UserDefaults.standard.removeObject(forKey: "OffCloud.UseManualKey") + } + + private func getToken() -> String? { + FerriteKeychain.shared.get("OffCloud.ApiKey") + } + + // Wrapper request function which matches the responses and returns data + @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let response = response as? HTTPURLResponse else { + throw DebridError.FailedRequest(description: "No HTTP response given") + } + + if response.statusCode >= 200, response.statusCode <= 299 { + return data + } else if response.statusCode == 401 { + throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to TorBox in Settings.") + } else { + print(response) + throw DebridError.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 DebridError.InvalidUrl + } + + guard let token = getToken() else { + throw DebridError.InvalidToken + } + + components.queryItems = [ + URLQueryItem(name: "key", value: token) + ] + queryItems + + if let url = components.url { + return url + } else { + throw DebridError.InvalidUrl + } + } + + func instantAvailability(magnets: [Magnet]) async throws { + let now = Date().timeIntervalSince1970 + + let sendMagnets = magnets.filter { magnet in + if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) { + if now > IAValues[IAIndex].expiryTimeStamp { + IAValues.remove(at: IAIndex) + return true + } else { + return false + } + } else { + return true + } + } + + if sendMagnets.isEmpty { + return + } + + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/cache")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = InstantAvailabilityRequest(hashes: sendMagnets.compactMap(\.hash)) + request.httpBody = try jsonEncoder.encode(body) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(InstantAvailabilityResponse.self, from: data) + + let availableHashes = rawResponse.cachedItems.map { + DebridIA( + magnet: Magnet(hash: $0, link: nil), + source: self.id, + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: [] + ) + } + + IAValues += availableHashes + } + + // Cloud in OffCloud's API + func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) { + let selectedTorrent: DebridCloudTorrent + + // Don't queue a new job if the torrent already exists + if let existingTorrent = cloudTorrents.first(where: { $0.hash == magnet.hash && $0.status == "downloaded" }) { + selectedTorrent = existingTorrent + } else { + let cloudDownloadResponse = try await offcloudDownload(magnet: magnet) + + guard cloudDownloadResponse.status == "downloaded" else { + throw DebridError.IsCaching + } + + selectedTorrent = DebridCloudTorrent( + torrentId: cloudDownloadResponse.requestId, + source: id, + fileName: cloudDownloadResponse.fileName, + status: cloudDownloadResponse.status, + hash: "", + links: [] + ) + } + + let cloudExploreLinks = try await cloudExplore(requestId: selectedTorrent.torrentId) + + if cloudExploreLinks.count > 1 { + var copiedIA = ia + + copiedIA?.files = cloudExploreLinks.enumerated().compactMap { index, exploreLink in + guard let exploreURL = URL(string: exploreLink) else { + return nil + } + + return DebridIAFile( + fileId: index, + name: exploreURL.lastPathComponent, + streamUrlString: exploreLink.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + ) + } + + return (nil, copiedIA) + } else if let exploreLink = cloudExploreLinks.first { + let restrictedFile = DebridIAFile( + fileId: 0, + name: selectedTorrent.fileName, + streamUrlString: exploreLink + ) + + return (restrictedFile, nil) + } else { + return (nil, nil) + } + } + + // Called as "cloud" in offcloud's API + private func offcloudDownload(magnet: Magnet) async throws -> CloudDownloadResponse { + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/cloud")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + guard let magnetLink = magnet.link else { + throw DebridError.EmptyData + } + + let body = CloudDownloadRequest(url: magnetLink) + request.httpBody = try jsonEncoder.encode(body) + + let data = try await performRequest(request: &request, requestName: "cloud") + let rawResponse = try jsonDecoder.decode(CloudDownloadResponse.self, from: data) + + return rawResponse + } + + private func cloudExplore(requestId: String) async throws -> CloudExploreResponse { + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/cloud/explore/\(requestId)")) + + let data = try await performRequest(request: &request, requestName: "cloudExplore") + let rawResponse = try jsonDecoder.decode(CloudExploreResponse.self, from: data) + + return rawResponse + } + + func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String { + guard let streamUrlString = restrictedFile.streamUrlString else { + throw DebridError.FailedRequest(description: "Could not get a streaming URL from the OffCloud API") + } + + return streamUrlString + } + + func getUserDownloads() async throws {} + + func checkUserDownloads(link: String) async throws -> String? { + nil + } + + func deleteDownload(downloadId: String) async throws {} + + func getUserTorrents() async throws { + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/cloud/history")) + + let data = try await performRequest(request: &request, requestName: "cloudHistory") + let rawResponse = try jsonDecoder.decode([CloudHistoryResponse].self, from: data) + + cloudTorrents = rawResponse.compactMap { cloudHistory in + guard let magnetHash = Magnet(hash: nil, link: cloudHistory.originalLink).hash else { + return nil + } + + return DebridCloudTorrent( + torrentId: cloudHistory.requestId, + source: self.id, + fileName: cloudHistory.fileName, + status: cloudHistory.status, + hash: magnetHash, + links: [cloudHistory.originalLink] + ) + } + } + + // Uses the base website because this isn't present in the API path but still works like the API? + func deleteTorrent(torrentId: String?) async throws { + guard let torrentId else { + throw DebridError.InvalidPostBody + } + + var request = URLRequest(url: try buildRequestURL(urlString: "\(website)/cloud/remove/\(torrentId)")) + try await performRequest(request: &request, requestName: "cloudRemove") + } +} diff --git a/Ferrite/Models/DebridModels.swift b/Ferrite/Models/DebridModels.swift index 50f9d27..60ca4b8 100644 --- a/Ferrite/Models/DebridModels.swift +++ b/Ferrite/Models/DebridModels.swift @@ -54,4 +54,5 @@ enum DebridError: Error { case IsCaching case FailedRequest(description: String) case AuthQuery(description: String) + case NotImplemented } diff --git a/Ferrite/Models/OffCloudModels.swift b/Ferrite/Models/OffCloudModels.swift new file mode 100644 index 0000000..0485563 --- /dev/null +++ b/Ferrite/Models/OffCloudModels.swift @@ -0,0 +1,41 @@ +// +// OffCloudModels.swift +// Ferrite +// +// Created by Brian Dashore on 6/12/24. +// + +import Foundation + +extension OffCloud { + struct InstantAvailabilityRequest: Codable, Sendable { + let hashes: [String] + } + + struct InstantAvailabilityResponse: Codable, Sendable { + let cachedItems: [String] + } + + struct CloudDownloadRequest: Codable, Sendable { + let url: String + } + + struct CloudDownloadResponse: Codable, Sendable { + let requestId: String + let fileName: String + let status: String + let originalLink: String + let url: String + } + + typealias CloudExploreResponse = [String] + + struct CloudHistoryResponse: Codable, Sendable { + let requestId: String + let fileName: String + let status: String + let originalLink: String + let isDirectory: Bool + let server: String + } +} diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 823b228..70771a4 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -16,8 +16,9 @@ class DebridManager: ObservableObject { @Published var allDebrid: AllDebrid = .init() @Published var premiumize: Premiumize = .init() @Published var torbox: TorBox = .init() + @Published var offcloud: OffCloud = .init() - lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox] + lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox, offcloud] // UI Variables @Published var showWebView: Bool = false @@ -52,6 +53,8 @@ class DebridManager: ObservableObject { @Published var showDeleteAlert: Bool = false @Published var showWebLoginAlert: Bool = false + @Published var showNotImplementedAlert: Bool = false + @Published var notImplementedMessage: String = "" init() { // Set the preferred service. Contains migration logic for earlier versions @@ -343,6 +346,10 @@ class DebridManager: ObservableObject { // Indicate that a link needs to be selected (batch) if let newIA { + if newIA.files.isEmpty { + throw DebridError.EmptyData + } + selectedDebridItem = newIA requiresUnrestrict = true @@ -431,7 +438,19 @@ class DebridManager: ObservableObject { await fetchDebridCloud(bypassTTL: true) } catch { - await sendDebridError(error, prefix: "\(selectedSource.id) download delete error") + switch error { + case DebridError.NotImplemented: + let message = "Download deletion for \(selectedSource.id) is not implemented. Please delete from the service's website." + + notImplementedMessage = message + showNotImplementedAlert.toggle() + logManager?.error( + "DebridManager: \(message)", + showToast: false + ) + default: + await sendDebridError(error, prefix: "\(selectedSource.id) download delete error") + } } } @@ -445,7 +464,19 @@ class DebridManager: ObservableObject { await fetchDebridCloud(bypassTTL: true) } catch { - await sendDebridError(error, prefix: "\(selectedSource.id) torrent delete error") + switch error { + case DebridError.NotImplemented: + let message = "Torrent deletion for \(selectedSource.id) is not implemented. Please use the service's website." + + notImplementedMessage = message + showNotImplementedAlert.toggle() + logManager?.error( + "DebridManager: \(message)", + showToast: false + ) + default: + await sendDebridError(error, prefix: "\(selectedSource.id) torrent delete error") + } } } } diff --git a/Ferrite/Views/LibraryView.swift b/Ferrite/Views/LibraryView.swift index 347bc04..03fab5b 100644 --- a/Ferrite/Views/LibraryView.swift +++ b/Ferrite/Views/LibraryView.swift @@ -96,6 +96,11 @@ struct LibraryView: View { .esAutocapitalization(autocorrectSearch ? .sentences : .none) .environment(\.editMode, $editMode) } + .alert("Not implemented", isPresented: $debridManager.showNotImplementedAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(debridManager.notImplementedMessage) + } .onChange(of: navModel.libraryPickerSelection) { _ in editMode = .inactive }