Ferrite-backup/Ferrite/API/RealDebridWrapper.swift
kingbri 9b7bc55a25 Library: Add support for RealDebrid cloud
RealDebrid saves a user's unrestricted links and "torrents" (magnet
links in this case). Add the ability to see and queue a user's RD
library in Ferrite itself.

This required a further abstraction of the debrid manager to allow
for more types other than search results to be passed to various
functions.

Deleting an item from RD's cloud list deletes the item from RD as well.

NOTE: This does not track download progress, but it does show if a
magnet is currently being downloaded or not.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-02 15:13:32 -05:00

368 lines
14 KiB
Swift

//
// RealDebridWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 7/7/22.
//
import Foundation
import KeychainSwift
public class RealDebrid {
let jsonDecoder = JSONDecoder()
let keychain = KeychainSwift()
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 -> DeviceCodeResponse {
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 RDError.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)
return rawResponse
} catch {
print("Couldn't get the new client creds!")
throw RDError.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 RDError.InvalidUrl
}
let request = URLRequest(url: url)
// Timer to poll RD API for credentials
authTask = Task {
var count = 0
while count < 12 {
if Task.isCancelled {
throw RDError.AuthQuery(description: "Token request cancelled.")
}
let (data, _) = try await URLSession.shared.data(for: request)
// We don't care if this fails
let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
// If there's a client ID from the response, end the task successfully
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
UserDefaults.standard.set(clientId, forKey: "RealDebrid.ClientId")
keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret")
try await getTokens(deviceCode: deviceCode)
return
} else {
try await Task.sleep(seconds: 5)
count += 1
}
}
throw RDError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
}
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 RDError.EmptyData
}
guard let clientSecret = keychain.get("RealDebrid.ClientSecret") else {
throw RDError.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.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
UserDefaults.standard.set(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
}
public func fetchToken() async -> String? {
let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp")
if Date().timeIntervalSince1970 > accessTokenStamp {
do {
if let refreshToken = keychain.get("RealDebrid.RefreshToken") {
try await getTokens(deviceCode: refreshToken)
}
} catch {
print(error)
return nil
}
}
return keychain.get("RealDebrid.AccessToken")
}
public func deleteTokens() async throws {
keychain.delete("RealDebrid.RefreshToken")
keychain.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.get("RealDebrid.AccessToken") {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
_ = try? await URLSession.shared.data(for: request)
keychain.delete("RealDebrid.AccessToken")
}
}
// 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 = await fetchToken() else {
throw RDError.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 RDError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
try await deleteTokens()
throw RDError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
} else {
throw RDError.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(magnets: [Magnet]) async throws -> [IA] {
var availableHashes: [RealDebrid.IA] = []
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnets.map(\.hash).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: [RealDebrid.IABatchFile] = fileDict.map { key, value in
// Force unwrapped ID. Is safe because ID is guaranteed on a successful response
RealDebrid.IABatchFile(id: Int(key)!, fileName: value.filename)
}.sorted(by: { $0.id < $1.id })
return RealDebrid.IABatch(files: batchFiles)
}
// RD files array
// Possibly sort this in the future, but not sure how at the moment
var files: [RealDebrid.IAFile] = []
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(
RealDebrid.IAFile(
name: batchFile.fileName,
batchIndex: index,
batchFileIndex: batchFileIndex
)
)
}
}
}
// TTL: 5 minutes
availableHashes.append(
RealDebrid.IA(
hash: hash,
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files,
batches: batches
)
)
} else {
availableHashes.append(
RealDebrid.IA(
hash: hash,
expiryTimeStamp: Date().timeIntervalSince1970 + 300
)
)
}
}
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)
}
// 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)
// 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 RDError.EmptyTorrents
} else {
throw RDError.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)")!)
request.httpMethod = "DELETE"
try await performRequest(request: &request, requestName: #function)
}
// 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
}
// 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
}
public func deleteDownload(debridID: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(debridID)")!)
request.httpMethod = "DELETE"
try await performRequest(request: &request, requestName: #function)
}
}