484 lines
18 KiB
Swift
484 lines
18 KiB
Swift
//
|
|
// RealDebridWrapper.swift
|
|
// Ferrite
|
|
//
|
|
// Created by Brian Dashore on 7/7/22.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
class RealDebrid: PollingDebridSource, ObservableObject {
|
|
let id = "RealDebrid"
|
|
let abbreviation = "RD"
|
|
let website = "https://real-debrid.com"
|
|
let description: String? = "RealDebrid is a debrid service that is used for downloads and media playback. " +
|
|
"You must pay to access this service. \n\n" +
|
|
"It is not recommended to use this service since media cache checks are not possible via the API. " +
|
|
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
|
|
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
|
|
"This service does not inform if a magnet link is a batch before downloading."
|
|
|
|
let cachedStatus: [String] = ["downloaded"]
|
|
var authTask: Task<Void, Error>?
|
|
|
|
@Published var authProcessing: Bool = false
|
|
|
|
// Check the manual token since getTokens() is async
|
|
var isLoggedIn: Bool {
|
|
FerriteKeychain.shared.get("RealDebrid.AccessToken") != nil
|
|
}
|
|
|
|
var manualToken: String? {
|
|
if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") {
|
|
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@Published var IAValues: [DebridIA] = []
|
|
@Published var cloudDownloads: [DebridCloudDownload] = []
|
|
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
|
var cloudTTL: Double = 0.0
|
|
|
|
private let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
|
|
private let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
|
|
private let openSourceClientId = "X245A4XAIBGVM"
|
|
|
|
private let jsonDecoder = JSONDecoder()
|
|
|
|
@MainActor
|
|
private func setUserDefaultsValue(_ value: Any, forKey: String) {
|
|
UserDefaults.standard.set(value, forKey: forKey)
|
|
}
|
|
|
|
@MainActor
|
|
private func removeUserDefaultsValue(forKey: String) {
|
|
UserDefaults.standard.removeObject(forKey: forKey)
|
|
}
|
|
|
|
init() {
|
|
// Populate user downloads and magnets
|
|
Task {
|
|
try? await getUserDownloads()
|
|
try? await getUserMagnets()
|
|
}
|
|
}
|
|
|
|
// MARK: - Auth
|
|
|
|
// Fetches the device code from RD
|
|
func getAuthUrl() async throws -> URL {
|
|
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 DebridError.InvalidUrl
|
|
}
|
|
|
|
let request = URLRequest(url: url)
|
|
do {
|
|
let (data, _) = try await URLSession.shared.data(for: request)
|
|
|
|
// Validate the URL before doing anything else
|
|
let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data)
|
|
guard let directVerificationUrl = URL(string: rawResponse.directVerificationURL) else {
|
|
throw DebridError.AuthQuery(description: "The verification URL is invalid")
|
|
}
|
|
|
|
// Spawn the polling task separately
|
|
authTask = Task {
|
|
try await getDeviceCredentials(deviceCode: rawResponse.deviceCode)
|
|
}
|
|
|
|
return directVerificationUrl
|
|
} catch {
|
|
print("Couldn't get the new client creds!")
|
|
throw DebridError.AuthQuery(description: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
// Fetches the user's client ID and secret
|
|
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 DebridError.InvalidUrl
|
|
}
|
|
|
|
let request = URLRequest(url: url)
|
|
|
|
// Timer to poll RD API for credentials
|
|
var count = 0
|
|
|
|
while count < 12 {
|
|
if Task.isCancelled {
|
|
throw DebridError.AuthQuery(description: "Token request cancelled.")
|
|
}
|
|
|
|
let (data, _) = try await URLSession.shared.data(for: request)
|
|
|
|
// We don't care if this fails
|
|
let rawResponse = try? 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 {
|
|
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
|
|
FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret")
|
|
|
|
try await getApiTokens(deviceCode: deviceCode)
|
|
|
|
return
|
|
} else {
|
|
try await Task.sleep(seconds: 5)
|
|
count += 1
|
|
}
|
|
}
|
|
|
|
throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
|
|
}
|
|
|
|
// Fetch all tokens for the user and store in FerriteKeychain.shared
|
|
func getApiTokens(deviceCode: String) async throws {
|
|
guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else {
|
|
throw DebridError.EmptyData
|
|
}
|
|
|
|
guard let clientSecret = FerriteKeychain.shared.get("RealDebrid.ClientSecret") else {
|
|
throw DebridError.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)
|
|
|
|
FerriteKeychain.shared.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
|
|
FerriteKeychain.shared.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
|
|
|
|
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
|
|
await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
|
|
}
|
|
|
|
func getToken() async -> String? {
|
|
let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp")
|
|
|
|
if Date().timeIntervalSince1970 > accessTokenStamp {
|
|
do {
|
|
if let refreshToken = FerriteKeychain.shared.get("RealDebrid.RefreshToken") {
|
|
try await getApiTokens(deviceCode: refreshToken)
|
|
}
|
|
} catch {
|
|
print(error)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
|
|
}
|
|
|
|
// Adds a manual API key instead of web auth
|
|
// Clear out existing refresh tokens and timestamps
|
|
func setApiKey(_ key: String) {
|
|
FerriteKeychain.shared.set(key, forKey: "RealDebrid.AccessToken")
|
|
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
|
|
FerriteKeychain.shared.delete("RealDebrid.AccessTokenStamp")
|
|
|
|
UserDefaults.standard.set(true, forKey: "RealDebrid.UseManualKey")
|
|
}
|
|
|
|
// Deletes tokens from device and RD's servers
|
|
func logout() async {
|
|
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
|
|
FerriteKeychain.shared.delete("RealDebrid.ClientSecret")
|
|
await removeUserDefaultsValue(forKey: "RealDebrid.ClientId")
|
|
await removeUserDefaultsValue(forKey: "RealDebrid.AccessTokenStamp")
|
|
|
|
// Run the request, doesn't matter if it fails
|
|
if let token = FerriteKeychain.shared.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)
|
|
|
|
FerriteKeychain.shared.delete("RealDebrid.AccessToken")
|
|
await removeUserDefaultsValue(forKey: "RealDebrid.UseManualKey")
|
|
}
|
|
}
|
|
|
|
// 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 = await 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 RealDebrid in Settings.")
|
|
} else {
|
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
|
}
|
|
}
|
|
|
|
// MARK: - Instant availability
|
|
|
|
// Post-API changes
|
|
// Use user magnets to check for IA instead
|
|
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
|
|
}
|
|
}
|
|
|
|
// Fetch the user magnets to the latest version
|
|
try await getUserMagnets()
|
|
|
|
for cloudMagnet in cloudMagnets {
|
|
if sendMagnets.contains(where: { $0.hash == cloudMagnet.hash }) {
|
|
IAValues.append(
|
|
DebridIA(
|
|
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
|
|
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
|
files: []
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Downloading
|
|
|
|
// Wrapper function to fetch a download link from the API
|
|
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
|
var selectedMagnetId = ""
|
|
|
|
do {
|
|
// Don't queue a new job if the magnet already exists in the user's library
|
|
if let existingCloudMagnet = cloudMagnets.first(where: { $0.hash == magnet.hash && $0.status == "downloaded" }) {
|
|
selectedMagnetId = existingCloudMagnet.id
|
|
} else {
|
|
selectedMagnetId = try await addMagnet(magnet: magnet)
|
|
|
|
try await selectFiles(debridID: selectedMagnetId, fileIds: iaFile?.batchIds ?? [])
|
|
}
|
|
|
|
let response = try await torrentInfo(debridID: selectedMagnetId)
|
|
let filteredFiles = response.files.filter { $0.selected == 1 }
|
|
|
|
if filteredFiles.count > 1, iaFile == nil {
|
|
// Need to return this to the user
|
|
|
|
var copiedIA = ia
|
|
|
|
copiedIA?.files = response.files.enumerated().compactMap { index, file in
|
|
DebridIAFile(
|
|
id: index,
|
|
name: file.path,
|
|
streamUrlString: response.links[safe: index]
|
|
)
|
|
}
|
|
|
|
return (nil, copiedIA)
|
|
}
|
|
|
|
// RealDebrid has 1 as the first ID for a file
|
|
let selectedFileId = iaFile?.id ?? 1
|
|
let linkIndex = filteredFiles.firstIndex(where: { $0.id == selectedFileId })
|
|
|
|
guard let cloudMagnetLink = response.links[safe: linkIndex ?? -1] else {
|
|
throw DebridError.EmptyUserMagnets
|
|
}
|
|
|
|
let restrictedFile = DebridIAFile(id: 0, name: response.filename, streamUrlString: cloudMagnetLink)
|
|
return (restrictedFile, nil)
|
|
} catch {
|
|
if case DebridError.EmptyUserMagnets = error, !selectedMagnetId.isEmpty {
|
|
try? await deleteUserMagnet(cloudMagnetId: selectedMagnetId)
|
|
}
|
|
|
|
// Re-raise the error to the calling function
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Adds a magnet link to the user's RD account
|
|
func addMagnet(magnet: Magnet) async throws -> String {
|
|
guard let magnetLink = magnet.link else {
|
|
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
|
}
|
|
|
|
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
|
|
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
|
|
func torrentInfo(debridID: String) async throws -> TorrentInfoResponse {
|
|
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 magnet is downloading
|
|
switch rawResponse.status {
|
|
case "downloaded":
|
|
return rawResponse
|
|
case "downloading", "queued":
|
|
throw DebridError.IsCaching
|
|
default:
|
|
throw DebridError.EmptyUserMagnets
|
|
}
|
|
}
|
|
|
|
// Downloads link from selectFiles for playback
|
|
func unrestrictFile(_ restrictedFile: DebridIAFile) 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: restrictedFile.streamUrlString)]
|
|
|
|
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
|
|
}
|
|
|
|
// MARK: - Cloud methods
|
|
|
|
// Gets the user's cloud magnet library
|
|
func getUserMagnets() async throws {
|
|
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)
|
|
cloudMagnets = rawResponse.map { response in
|
|
DebridCloudMagnet(
|
|
id: response.id,
|
|
fileName: response.filename,
|
|
status: response.status,
|
|
hash: response.hash,
|
|
links: [response.id]
|
|
)
|
|
}
|
|
}
|
|
|
|
// Deletes a magnet download from RD
|
|
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
|
let deleteId: String
|
|
|
|
if let cloudMagnetId {
|
|
deleteId = cloudMagnetId
|
|
} else {
|
|
// Refresh the user magnet list
|
|
// The first file is the currently caching one
|
|
let _ = try await getUserMagnets()
|
|
guard let firstCloudMagnet = cloudMagnets[safe: -1] else {
|
|
throw DebridError.EmptyUserMagnets
|
|
}
|
|
|
|
deleteId = firstCloudMagnet.id
|
|
}
|
|
|
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(deleteId)")!)
|
|
request.httpMethod = "DELETE"
|
|
|
|
try await performRequest(request: &request, requestName: #function)
|
|
}
|
|
|
|
// Gets the user's downloads
|
|
func getUserDownloads() async throws {
|
|
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)
|
|
cloudDownloads = rawResponse.map { response in
|
|
DebridCloudDownload(id: response.id, fileName: response.filename, link: response.download)
|
|
}
|
|
}
|
|
|
|
// Not used
|
|
func checkUserDownloads(link: String) -> String? {
|
|
link
|
|
}
|
|
|
|
func deleteUserDownload(downloadId: String) async throws {
|
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(downloadId)")!)
|
|
request.httpMethod = "DELETE"
|
|
|
|
try await performRequest(request: &request, requestName: #function)
|
|
}
|
|
}
|