diff --git a/Ferrite/API/TorBoxWrapper.swift b/Ferrite/API/TorBoxWrapper.swift new file mode 100644 index 0000000..29e72ca --- /dev/null +++ b/Ferrite/API/TorBoxWrapper.swift @@ -0,0 +1,275 @@ +// +// TorBoxWrapper.swift +// Ferrite +// +// Created by Brian Dashore on 6/11/24. +// + +import Foundation + +// Torrents: /torrents/mylist +// IA: /torrents/checkcached +// Add Magnet: /torrents/createtorrent +// Delete torrent: /torrents/controltorrent +// Unrestrict: /torrents/requestdl + +class TorBox: DebridSource, ObservableObject { + var id: String = "TorBox" + var abbreviation: String = "TB" + var website: String = "https://torbox.app" + + @Published var authProcessing: Bool = false + var isLoggedIn: Bool { + return getToken() != nil + } + + var manualToken: String? { + if UserDefaults.standard.bool(forKey: "TorBox.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://api.torbox.app/v1/api" + private let jsonDecoder = JSONDecoder() + private let jsonEncoder = JSONEncoder() + + // MARK: - Auth + + func setApiKey(_ key: String) { + FerriteKeychain.shared.set(key, forKey: "TorBox.ApiKey") + UserDefaults.standard.set(true, forKey: "TorBox.UseManualKey") + } + + func logout() async { + FerriteKeychain.shared.delete("TorBox.ApiKey") + UserDefaults.standard.removeObject(forKey: "TorBox.UseManualKey") + } + + private func getToken() -> String? { + FerriteKeychain.shared.get("TorBox.ApiKey") + } + + // MARK: - Common request + + // 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 = getToken() else { + throw DebridError.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 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 { + throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") + } + } + + // MARK: - Instant availability + + 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 components = URLComponents(string: "\(baseApiUrl)/torrents/checkcached")! + components.queryItems = sendMagnets.map { URLQueryItem(name: "hash", value: $0.hash) } + components.queryItems?.append(URLQueryItem(name: "format", value: "list")) + + guard let url = components.url else { + throw DebridError.InvalidUrl + } + + var request = URLRequest(url: url) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(TBResponse.self, from: data) + + // If the data is a failure, return + guard case .links(let iaObjects) = rawResponse.data else { + return + } + + let availableHashes = iaObjects.map { + DebridIA( + magnet: Magnet(hash: $0.hash, link: nil), + source: self.id, + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: [] + ) + } + + IAValues += availableHashes + } + + // MARK: - Downloading + + func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) { + let torrentId = try await createTorrent(magnet: magnet) + let torrentList = try await myTorrentList() + guard let filteredTorrent = torrentList.first(where: { $0.id == torrentId }) else { + throw DebridError.FailedRequest(description: "A torrent wasn't found. Are you sure it's cached?") + } + + // If the torrent isn't saved, it's considered as caching + guard filteredTorrent.downloadState == "cached" || filteredTorrent.downloadState == "completed" else { + throw DebridError.IsCaching + } + + if filteredTorrent.files.count > 1 { + var copiedIA = ia + + copiedIA?.files = filteredTorrent.files.map { torrentFile in + DebridIAFile( + fileId: torrentFile.id, + name: torrentFile.shortName, + streamUrlString: String(torrentId) + ) + } + + return (nil, copiedIA) + } else if let torrentFile = filteredTorrent.files.first { + let restrictedFile = DebridIAFile(fileId: torrentFile.id, name: torrentFile.name, streamUrlString: String(torrentId)) + + return (restrictedFile, nil) + } else { + return (nil, nil) + } + } + + private func createTorrent(magnet: Magnet) async throws -> Int { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/createtorrent")!) + request.httpMethod = "POST" + + guard let magnetLink = magnet.link else { + throw DebridError.EmptyData + } + + let formData = FormDataBody(params: ["magnet": magnetLink]) + request.setValue("multipart/form-data; boundary=\(formData.boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = formData.body + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(TBResponse.self, from: data) + + guard let torrentId = rawResponse.data?.torrentId else { + throw DebridError.EmptyData + } + + return torrentId + } + + private func myTorrentList() async throws -> [MyTorrentListResponse] { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/mylist")!) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(TBResponse<[MyTorrentListResponse]>.self, from: data) + + guard let torrentList = rawResponse.data else { + throw DebridError.EmptyData + } + + return torrentList + } + + func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String { + var components = URLComponents(string: "\(baseApiUrl)/torrents/requestdl")! + components.queryItems = [ + URLQueryItem(name: "token", value: getToken()), + URLQueryItem(name: "torrent_id", value: restrictedFile.streamUrlString), + URLQueryItem(name: "file_id", value: String(restrictedFile.fileId)) + ] + + guard let url = components.url else { + throw DebridError.InvalidUrl + } + + var request = URLRequest(url: url) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(TBResponse.self, from: data) + + guard let unrestrictedLink = rawResponse.data else { + throw DebridError.FailedRequest(description: "Could not get an unrestricted URL from TorBox.") + } + + return unrestrictedLink + } + + // MARK: - Cloud methods + + // Unused + func getUserDownloads() async throws { + return + } + + func checkUserDownloads(link: String) async throws -> String? { + return nil + } + + func deleteDownload(downloadId: String) async throws { + return + } + + func getUserTorrents() async throws { + let torrentList = try await myTorrentList() + cloudTorrents = torrentList.map { torrent in + + // Only need one link to force a green badge + DebridCloudTorrent( + torrentId: String(torrent.id), + source: self.id, + fileName: torrent.name, + status: torrent.downloadState == "cached" || torrent.downloadState == "completed" ? "downloaded" : torrent.downloadState, + hash: torrent.hash, + links: [String(torrent.id)] + ) + } + } + + func deleteTorrent(torrentId: String?) async throws { + guard let torrentId else { + throw DebridError.InvalidPostBody + } + + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/controltorrent")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = ControlTorrentRequest(torrentId: torrentId, operation: "Delete") + request.httpBody = try jsonEncoder.encode(body) + + try await performRequest(request: &request, requestName: "controltorrent") + } +} diff --git a/Ferrite/Models/TorBoxModels.swift b/Ferrite/Models/TorBoxModels.swift new file mode 100644 index 0000000..b3c5196 --- /dev/null +++ b/Ferrite/Models/TorBoxModels.swift @@ -0,0 +1,103 @@ +// +// TorBoxModels.swift +// Ferrite +// +// Created by Brian Dashore on 6/11/24. +// + +import Foundation + +extension TorBox { + struct TBResponse: Codable { + let success: Bool + let detail: String + let data: TBData? + } + + // MARK: - InstantAvailability + enum InstantAvailabilityData: Codable { + case links([InstantAvailabilityDataObject]) + case failure(InstantAvailabilityDataFailure) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + // Only continue if the data is a List which indicates a success + if let linkArray = try? container.decode([InstantAvailabilityDataObject].self) { + self = .links(linkArray) + } else { + let value = try container.decode(InstantAvailabilityDataFailure.self) + self = .failure(value) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .links(let array): + try container.encode(array) + case .failure(let value): + try container.encode(value) + } + } + } + + struct InstantAvailabilityDataObject: Codable, Sendable { + let name: String + let size: Int + let hash: String + } + + struct InstantAvailabilityDataFailure: Codable, Sendable { + let data: Bool + } + + struct CreateTorrentResponse: Codable, Sendable { + let hash: String + let torrentId: Int + let authId: String + + enum CodingKeys: String, CodingKey { + case hash + case torrentId = "torrent_id" + case authId = "auth_id" + } + } + + struct MyTorrentListResponse: Codable, Sendable { + let id: Int + let hash: String + let name: String + let downloadState: String + let files: [MyTorrentListFile] + + enum CodingKeys: String, CodingKey { + case id, hash, name, files + case downloadState = "download_state" + } + } + + struct MyTorrentListFile: Codable, Sendable { + let id: Int + let hash: String + let name: String + let shortName: String + + enum CodingKeys: String, CodingKey { + case id, hash, name + case shortName = "short_name" + } + } + + typealias RequestDLResponse = String + + struct ControlTorrentRequest: Codable, Sendable { + let torrentId: String + let operation: String + + enum CodingKeys: String, CodingKey { + case operation + case torrentId = "torrent_id" + } + } +} diff --git a/Ferrite/Utils/FormDataBody.swift b/Ferrite/Utils/FormDataBody.swift new file mode 100644 index 0000000..d76e84b --- /dev/null +++ b/Ferrite/Utils/FormDataBody.swift @@ -0,0 +1,27 @@ +// +// MultipartFormDataRequest.swift +// Ferrite +// +// Created by Brian Dashore on 6/12/24. +// + +import Foundation + +struct FormDataBody { + let boundary: String = UUID().uuidString + let body: Data + + init(params: [String: String]) { + var body = Data() + + for (key, value) in params { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + body.append("\(value)\r\n".data(using: .utf8)!) + } + + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + + self.body = body + } +}