Ferrite-backup/Ferrite/API/RealDebridWrapper.swift
kingbri e9670ea118 RealDebrid: Add batch torrent support
Batch torrents are torrents that have multiple files bundled within
one torrent file.

RealDebrid does support these, but it is difficult to get them to work.

The main flow requires setting a specific combination in RealDebrid
to allow for link generation. However, this is not intuitive to users
and is bad API design on RealDebrid's part.

Ferrite's implementation presents users with all the possible files
from batches (duplicates deleted) and selects the user-chosen file
to download. That way, only the user chosen file is presented to
play on an external video player.

This still needs work for optimization purposes, but this commit
does produce a working build.

Signed-off-by: kingbri <bdashore3@gmail.com>
2022-08-04 21:33:59 -04:00

350 lines
14 KiB
Swift

//
// RealDebridWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 7/7/22.
//
import Foundation
public enum RealDebridError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case FailedRequest(description: String)
case AuthQuery(description: String)
}
public class RealDebrid: ObservableObject {
var parentManager: DebridManager? = nil
let jsonDecoder = JSONDecoder()
let keychain = Keychain()
let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
let openSourceClientId = "X245A4XAIBGVM"
var authTask: Task<Void, Error>?
// Fetches the device code from RD
public func getVerificationInfo() async throws -> String {
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: openSourceClientId),
URLQueryItem(name: "new_credentials", value: "yes")
]
guard let url = urlComponents.url else {
throw RealDebridError.InvalidUrl
}
let request = URLRequest(url: url)
do {
let (data, _) = try await URLSession.shared.data(for: request)
let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data)
// Spawn a separate process to get the device code
Task {
do {
try await getDeviceCredentials(deviceCode: rawResponse.deviceCode)
} catch {
print("Authentication error in \(#function): \(error)")
authTask?.cancel()
Task { @MainActor in
parentManager?.toastModel?.toastDescription = "Authentication error in \(#function): \(error)"
}
}
}
return rawResponse.directVerificationURL
} catch {
print("Couldn't get the new client creds!")
throw RealDebridError.AuthQuery(description: error.localizedDescription)
}
}
// Fetches the user's client ID and secret
public func getDeviceCredentials(deviceCode: String) async throws {
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: openSourceClientId),
URLQueryItem(name: "code", value: deviceCode)
]
guard let url = urlComponents.url else {
throw RealDebridError.InvalidUrl
}
let request = URLRequest(url: url)
try await getDeviceCredentialsInternal(urlRequest: request, deviceCode: deviceCode)
}
// Timer to poll RD api for credentials
func getDeviceCredentialsInternal(urlRequest: URLRequest, deviceCode: String) async throws {
authTask = Task {
var count = 0
while count < 20 {
let (data, _) = try await URLSession.shared.data(for: urlRequest)
// We don't care if this fails
let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
UserDefaults.standard.set(clientId, forKey: "RealDebrid.ClientId")
Keychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret")
try await getTokens(deviceCode: deviceCode)
break
} else {
try await Task.sleep(seconds: 5)
count += 1
}
}
}
if case let .failure(error) = await authTask?.result {
throw error
}
}
// Fetch all tokens for the user and store in keychain
public func getTokens(deviceCode: String) async throws {
guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else {
throw RealDebridError.EmptyData
}
guard let clientSecret = Keychain.shared.get("RealDebrid.ClientSecret") else {
throw RealDebridError.EmptyData
}
var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "client_secret", value: clientSecret),
URLQueryItem(name: "code", value: deviceCode),
URLQueryItem(name: "grant_type", value: "http://oauth.net/grant_type/device/1.0")
]
request.httpBody = bodyComponents.query?.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data)
Keychain.shared.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
Keychain.shared.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
UserDefaults.standard.set(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
// Set AppStorage variable
Task { @MainActor in
parentManager?.realDebridEnabled = true
}
}
public func fetchToken() async -> String? {
let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp")
if Date().timeIntervalSince1970 > accessTokenStamp {
do {
if let refreshToken = Keychain.shared.get("RealDebrid.RefreshToken") {
print("Refresh token found")
try await getTokens(deviceCode: refreshToken)
}
} catch {
print(error)
return nil
}
}
return Keychain.shared.get("RealDebrid.AccessToken")
}
public func deleteTokens() async throws {
Keychain.shared.delete("RealDebrid.RefreshToken")
Keychain.shared.delete("RealDebrid.ClientSecret")
UserDefaults.standard.removeObject(forKey: "RealDebrid.ClientId")
UserDefaults.standard.removeObject(forKey: "RealDebrid.AccessTokenStamp")
// Run the request, doesn't matter if it fails
if let token = Keychain.shared.get("RealDebrid.AccessToken") {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let _ = try? await URLSession.shared.data(for: request)
Keychain.shared.delete("RealDebrid.AccessToken")
}
Task { @MainActor in
parentManager?.realDebridEnabled = false
}
}
// Wrapper request function which matches the responses and returns data
@discardableResult public func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = await fetchToken() else {
throw RealDebridError.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 RealDebridError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
try await deleteTokens()
throw RealDebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
} else {
throw RealDebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// Checks if the magnet is streamable on RD
// Currently does not work for batch links
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)
// Does not account for torrent packs at the moment
let rawResponseDict = try jsonDecoder.decode([String: InstantAvailabilityResponse].self, from: data)
for (hash, response) in rawResponseDict {
guard let data = response.data else {
continue
}
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))
}
}
return availableHashes
}
// Adds a magnet link to the user's RD account
public func addMagnet(magnetLink: String) async throws -> String {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "magnet", value: magnetLink)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(AddMagnetResponse.self, from: data)
return rawResponse.id
}
// Queues the magnet link for downloading
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()
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)
try await performRequest(request: &request, requestName: #function)
}
// Fetches the info of a torrent
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] {
return torrentLink
} else {
throw RealDebridError.EmptyData
}
}
// Downloads link from selectFiles for playback
public func unrestrictLink(debridDownloadLink: String) async throws -> String {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "link", value: debridDownloadLink)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(UnrestrictLinkResponse.self, from: data)
return rawResponse.download
}
}