396 lines
14 KiB
Swift
396 lines
14 KiB
Swift
//
|
|
// AllDebridWrapper.swift
|
|
// Ferrite
|
|
//
|
|
// Created by Brian Dashore on 11/25/22.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
class AllDebrid: PollingDebridSource, ObservableObject {
|
|
let id = "AllDebrid"
|
|
let abbreviation = "AD"
|
|
let website = "https://alldebrid.com"
|
|
let description: String? = "AllDebrid 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] = ["Ready"]
|
|
var authTask: Task<Void, Error>?
|
|
|
|
@Published var authProcessing: Bool = false
|
|
var isLoggedIn: Bool {
|
|
getToken() != nil
|
|
}
|
|
|
|
var manualToken: String? {
|
|
if UserDefaults.standard.bool(forKey: "AllDebrid.UseManualKey") {
|
|
return getToken()
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@Published var IAValues: [DebridIA] = []
|
|
@Published var cloudDownloads: [DebridCloudDownload] = []
|
|
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
|
var cloudTTL: Double = 0.0
|
|
|
|
private let baseApiUrl = "https://api.alldebrid.com/v4"
|
|
private let appName = "Ferrite"
|
|
|
|
private let jsonDecoder = JSONDecoder()
|
|
|
|
init() {
|
|
// Populate user downloads and magnets
|
|
Task {
|
|
try? await getUserDownloads()
|
|
try? await getUserMagnets()
|
|
}
|
|
}
|
|
|
|
// MARK: - Auth
|
|
|
|
// Fetches information for PIN auth
|
|
func getAuthUrl() async throws -> URL {
|
|
let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get")
|
|
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(ADResponse<PinResponse>.self, from: data).data
|
|
guard let userUrl = URL(string: rawResponse.userURL) else {
|
|
throw DebridError.AuthQuery(description: "The login URL is invalid")
|
|
}
|
|
|
|
// Spawn the polling task separately
|
|
authTask = Task {
|
|
try await getApiKey(checkID: rawResponse.check, pin: rawResponse.pin)
|
|
}
|
|
|
|
return userUrl
|
|
} catch {
|
|
print("Couldn't get pin information!")
|
|
throw DebridError.AuthQuery(description: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
// Fetches API keys
|
|
func getApiKey(checkID: String, pin: String) async throws {
|
|
let queryItems = [
|
|
URLQueryItem(name: "agent", value: appName),
|
|
URLQueryItem(name: "check", value: checkID),
|
|
URLQueryItem(name: "pin", value: pin)
|
|
]
|
|
|
|
let request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems))
|
|
|
|
// Timer to poll AD API for key
|
|
authTask = Task {
|
|
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? self.jsonDecoder.decode(ADResponse<ApiKeyResponse>.self, from: data).data
|
|
|
|
// If there's an API key from the response, end the task successfully
|
|
if let apiKeyResponse = rawResponse {
|
|
FerriteKeychain.shared.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey")
|
|
|
|
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.")
|
|
}
|
|
|
|
if case let .failure(error) = await authTask?.result {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Adds a manual API key instead of web auth
|
|
func setApiKey(_ key: String) {
|
|
FerriteKeychain.shared.set(key, forKey: "AllDebrid.ApiKey")
|
|
UserDefaults.standard.set(true, forKey: "AllDebrid.UseManualKey")
|
|
}
|
|
|
|
func getToken() -> String? {
|
|
FerriteKeychain.shared.get("AllDebrid.ApiKey")
|
|
}
|
|
|
|
// Clears tokens. No endpoint to deregister a device
|
|
func logout() {
|
|
FerriteKeychain.shared.delete("AllDebrid.ApiKey")
|
|
UserDefaults.standard.removeObject(forKey: "AllDebrid.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 = 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 AllDebrid in Settings.")
|
|
} else {
|
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
|
}
|
|
}
|
|
|
|
// Builds a URL for further requests
|
|
func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
|
guard var components = URLComponents(string: urlString) else {
|
|
throw DebridError.InvalidUrl
|
|
}
|
|
|
|
components.queryItems = [
|
|
URLQueryItem(name: "agent", value: appName)
|
|
] + queryItems
|
|
|
|
if let url = components.url {
|
|
return url
|
|
} else {
|
|
throw DebridError.InvalidUrl
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Fetch the user magnets to the latest version
|
|
try await getUserMagnets()
|
|
|
|
for cloudMagnet in cloudMagnets {
|
|
if cachedStatus.contains(cloudMagnet.status),
|
|
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?) {
|
|
let selectedMagnetId: String
|
|
|
|
if let existingMagnet = cloudMagnets.first(where: {
|
|
$0.hash == magnet.hash && cachedStatus.contains($0.status)
|
|
}) {
|
|
selectedMagnetId = existingMagnet.id
|
|
} else {
|
|
let magnetId = try await addMagnet(magnet: magnet)
|
|
selectedMagnetId = String(magnetId)
|
|
}
|
|
|
|
let rawResponse = try await fetchMagnetStatus(
|
|
magnetId: selectedMagnetId,
|
|
selectedIndex: iaFile?.id ?? 0
|
|
)
|
|
guard let magnets = rawResponse.magnets[safe: 0] else {
|
|
throw DebridError.EmptyUserMagnets
|
|
}
|
|
|
|
// Batches require an unrestrict from the user
|
|
if magnets.links.count > 1, iaFile == nil {
|
|
var copiedIA = ia
|
|
|
|
copiedIA?.files = magnets.links.enumerated().compactMap { index, file in
|
|
DebridIAFile(
|
|
id: index,
|
|
name: file.filename,
|
|
streamUrlString: file.link
|
|
)
|
|
}
|
|
|
|
return (nil, copiedIA)
|
|
}
|
|
|
|
if let cloudMagnetFile = magnets.links[safe: iaFile?.id ?? 0] {
|
|
let restrictedFile = DebridIAFile(
|
|
id: 0,
|
|
name: cloudMagnetFile.filename,
|
|
streamUrlString: cloudMagnetFile.link
|
|
)
|
|
|
|
return (restrictedFile, nil)
|
|
} else {
|
|
throw DebridError.EmptyUserMagnets
|
|
}
|
|
}
|
|
|
|
// Adds a magnet link to the user's AD account
|
|
func addMagnet(magnet: Magnet) async throws -> Int {
|
|
guard let magnetLink = magnet.link else {
|
|
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
|
}
|
|
|
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
|
|
var bodyComponents = URLComponents()
|
|
bodyComponents.queryItems = [
|
|
URLQueryItem(name: "magnets[]", value: magnetLink)
|
|
]
|
|
|
|
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
|
|
|
let data = try await performRequest(request: &request, requestName: #function)
|
|
let rawResponse = try jsonDecoder.decode(ADResponse<AddMagnetResponse>.self, from: data).data
|
|
|
|
if let magnet = rawResponse.magnets[safe: 0] {
|
|
if !magnet.ready {
|
|
throw DebridError.IsCaching
|
|
}
|
|
|
|
return magnet.id
|
|
} else {
|
|
throw DebridError.InvalidResponse
|
|
}
|
|
}
|
|
|
|
func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> MagnetStatusResponse {
|
|
let queryItems = [
|
|
URLQueryItem(name: "id", value: magnetId)
|
|
]
|
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems))
|
|
|
|
let data = try await performRequest(request: &request, requestName: #function)
|
|
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
|
|
|
|
return rawResponse
|
|
}
|
|
|
|
// Known as unlockLink in AD's API
|
|
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
|
let queryItems = [
|
|
URLQueryItem(name: "link", value: restrictedFile.streamUrlString)
|
|
]
|
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
|
|
|
|
let data = try await performRequest(request: &request, requestName: "unlockLink")
|
|
let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.self, from: data).data
|
|
|
|
return rawResponse.link
|
|
}
|
|
|
|
func saveLink(link: String) async throws {
|
|
let queryItems = [
|
|
URLQueryItem(name: "links[]", value: link)
|
|
]
|
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems))
|
|
|
|
try await performRequest(request: &request, requestName: #function)
|
|
}
|
|
|
|
// MARK: - Cloud methods
|
|
|
|
func getUserMagnets() async throws {
|
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status"))
|
|
|
|
let data = try await performRequest(request: &request, requestName: #function)
|
|
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
|
|
|
|
cloudMagnets = rawResponse.magnets.map { magnetResponse in
|
|
DebridCloudMagnet(
|
|
id: String(magnetResponse.id),
|
|
fileName: magnetResponse.filename,
|
|
status: magnetResponse.status,
|
|
hash: magnetResponse.hash,
|
|
links: magnetResponse.links.map(\.link)
|
|
)
|
|
}
|
|
}
|
|
|
|
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
|
guard let cloudMagnetId else {
|
|
throw DebridError.FailedRequest(description: "The cloud magnetID \(String(describing: cloudMagnetId)) is invalid")
|
|
}
|
|
|
|
let queryItems = [
|
|
URLQueryItem(name: "id", value: cloudMagnetId)
|
|
]
|
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems))
|
|
|
|
try await performRequest(request: &request, requestName: #function)
|
|
}
|
|
|
|
func getUserDownloads() async throws {
|
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links"))
|
|
|
|
let data = try await performRequest(request: &request, requestName: #function)
|
|
let rawResponse = try jsonDecoder.decode(ADResponse<SavedLinksResponse>.self, from: data).data
|
|
|
|
// The link is also the ID
|
|
cloudDownloads = rawResponse.links.map { link in
|
|
DebridCloudDownload(
|
|
id: link.link, fileName: link.filename, link: link.link
|
|
)
|
|
}
|
|
}
|
|
|
|
// Not used
|
|
func checkUserDownloads(link: String) -> String? {
|
|
link
|
|
}
|
|
|
|
// The downloadId is actually the download link
|
|
func deleteUserDownload(downloadId: String) async throws {
|
|
let queryItems = [
|
|
URLQueryItem(name: "link", value: downloadId)
|
|
]
|
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems))
|
|
|
|
try await performRequest(request: &request, requestName: #function)
|
|
}
|
|
}
|