Debrid: Add TorBox support
TorBox is a service that handles magnet links under both a free and paid plan. Integrate support into Ferrite. Will add rich services once the instantAvailability endpoint returns a file list. Signed-off-by: kingbri <bdashore3@proton.me>
This commit is contained in:
parent
4beb953596
commit
d6d731102c
3 changed files with 405 additions and 0 deletions
275
Ferrite/API/TorBoxWrapper.swift
Normal file
275
Ferrite/API/TorBoxWrapper.swift
Normal file
|
|
@ -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<InstantAvailabilityData>.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<CreateTorrentResponse>.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<RequestDLResponse>.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")
|
||||
}
|
||||
}
|
||||
103
Ferrite/Models/TorBoxModels.swift
Normal file
103
Ferrite/Models/TorBoxModels.swift
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
//
|
||||
// TorBoxModels.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 6/11/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension TorBox {
|
||||
struct TBResponse<TBData: Codable>: 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Ferrite/Utils/FormDataBody.swift
Normal file
27
Ferrite/Utils/FormDataBody.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue