-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
OffCloud is a debrid provider that allows for caching and playing media. Does not have rich debrid support. Also add a handler if functionality isn't implemented in the service. Signed-off-by: kingbri <bdashore3@proton.me>
- Loading branch information
Showing
6 changed files
with
357 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,268 @@ | ||
// | ||
// OffCloudWrapper.swift | ||
// Ferrite | ||
// | ||
// Created by Brian Dashore on 6/12/24. | ||
// | ||
|
||
import Foundation | ||
|
||
// Torrents: /cloud/history | ||
// IA: /cache (JSON array of hashes) | ||
// Add Magnet: /cloud (URL param in JSON body) | ||
// Get files/unrestrict: /cloud/explore/\(requestId) | ||
// Delete torrent (website URL, not API URL): /cloud/remove/\(torrentId) | ||
|
||
class OffCloud: DebridSource, ObservableObject { | ||
var id: String = "OffCloud" | ||
var abbreviation: String = "OC" | ||
var website: String = "https://offcloud.com" | ||
|
||
@Published var authProcessing: Bool = false | ||
var isLoggedIn: Bool { | ||
getToken() != nil | ||
} | ||
|
||
var manualToken: String? { | ||
if UserDefaults.standard.bool(forKey: "OffCloud.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://offcloud.com/api" | ||
private let jsonDecoder = JSONDecoder() | ||
private let jsonEncoder = JSONEncoder() | ||
|
||
func setApiKey(_ key: String) { | ||
FerriteKeychain.shared.set(key, forKey: "OffCloud.ApiKey") | ||
UserDefaults.standard.set(true, forKey: "OffCloud.UseManualKey") | ||
} | ||
|
||
func logout() async { | ||
FerriteKeychain.shared.delete("OffCloud.ApiKey") | ||
UserDefaults.standard.removeObject(forKey: "OffCloud.UseManualKey") | ||
} | ||
|
||
private func getToken() -> String? { | ||
FerriteKeychain.shared.get("OffCloud.ApiKey") | ||
} | ||
|
||
// Wrapper request function which matches the responses and returns data | ||
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { | ||
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 { | ||
print(response) | ||
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") | ||
} | ||
} | ||
|
||
// Builds a URL for further requests | ||
private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL { | ||
guard var components = URLComponents(string: urlString) else { | ||
throw DebridError.InvalidUrl | ||
} | ||
|
||
guard let token = getToken() else { | ||
throw DebridError.InvalidToken | ||
} | ||
|
||
components.queryItems = [ | ||
URLQueryItem(name: "key", value: token) | ||
] + queryItems | ||
|
||
if let url = components.url { | ||
return url | ||
} else { | ||
throw DebridError.InvalidUrl | ||
} | ||
} | ||
|
||
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 request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/cache")) | ||
request.httpMethod = "POST" | ||
request.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||
|
||
let body = InstantAvailabilityRequest(hashes: sendMagnets.compactMap(\.hash)) | ||
request.httpBody = try jsonEncoder.encode(body) | ||
|
||
let data = try await performRequest(request: &request, requestName: #function) | ||
let rawResponse = try jsonDecoder.decode(InstantAvailabilityResponse.self, from: data) | ||
|
||
let availableHashes = rawResponse.cachedItems.map { | ||
DebridIA( | ||
magnet: Magnet(hash: $0, link: nil), | ||
source: self.id, | ||
expiryTimeStamp: Date().timeIntervalSince1970 + 300, | ||
files: [] | ||
) | ||
} | ||
|
||
IAValues += availableHashes | ||
} | ||
|
||
// Cloud in OffCloud's API | ||
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) { | ||
let selectedTorrent: DebridCloudTorrent | ||
|
||
// Don't queue a new job if the torrent already exists | ||
if let existingTorrent = cloudTorrents.first(where: { $0.hash == magnet.hash && $0.status == "downloaded" }) { | ||
selectedTorrent = existingTorrent | ||
} else { | ||
let cloudDownloadResponse = try await offcloudDownload(magnet: magnet) | ||
|
||
guard cloudDownloadResponse.status == "downloaded" else { | ||
throw DebridError.IsCaching | ||
} | ||
|
||
selectedTorrent = DebridCloudTorrent( | ||
torrentId: cloudDownloadResponse.requestId, | ||
source: id, | ||
fileName: cloudDownloadResponse.fileName, | ||
status: cloudDownloadResponse.status, | ||
hash: "", | ||
links: [] | ||
) | ||
} | ||
|
||
let cloudExploreLinks = try await cloudExplore(requestId: selectedTorrent.torrentId) | ||
|
||
if cloudExploreLinks.count > 1 { | ||
var copiedIA = ia | ||
|
||
copiedIA?.files = cloudExploreLinks.enumerated().compactMap { index, exploreLink in | ||
guard let exploreURL = URL(string: exploreLink) else { | ||
return nil | ||
} | ||
|
||
return DebridIAFile( | ||
fileId: index, | ||
name: exploreURL.lastPathComponent, | ||
streamUrlString: exploreLink.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) | ||
) | ||
} | ||
|
||
return (nil, copiedIA) | ||
} else if let exploreLink = cloudExploreLinks.first { | ||
let restrictedFile = DebridIAFile( | ||
fileId: 0, | ||
name: selectedTorrent.fileName, | ||
streamUrlString: exploreLink | ||
) | ||
|
||
return (restrictedFile, nil) | ||
} else { | ||
return (nil, nil) | ||
} | ||
} | ||
|
||
// Called as "cloud" in offcloud's API | ||
private func offcloudDownload(magnet: Magnet) async throws -> CloudDownloadResponse { | ||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/cloud")) | ||
request.httpMethod = "POST" | ||
request.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||
|
||
guard let magnetLink = magnet.link else { | ||
throw DebridError.EmptyData | ||
} | ||
|
||
let body = CloudDownloadRequest(url: magnetLink) | ||
request.httpBody = try jsonEncoder.encode(body) | ||
|
||
let data = try await performRequest(request: &request, requestName: "cloud") | ||
let rawResponse = try jsonDecoder.decode(CloudDownloadResponse.self, from: data) | ||
|
||
return rawResponse | ||
} | ||
|
||
private func cloudExplore(requestId: String) async throws -> CloudExploreResponse { | ||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/cloud/explore/\(requestId)")) | ||
|
||
let data = try await performRequest(request: &request, requestName: "cloudExplore") | ||
let rawResponse = try jsonDecoder.decode(CloudExploreResponse.self, from: data) | ||
|
||
return rawResponse | ||
} | ||
|
||
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String { | ||
guard let streamUrlString = restrictedFile.streamUrlString else { | ||
throw DebridError.FailedRequest(description: "Could not get a streaming URL from the OffCloud API") | ||
} | ||
|
||
return streamUrlString | ||
} | ||
|
||
func getUserDownloads() async throws {} | ||
|
||
func checkUserDownloads(link: String) async throws -> String? { | ||
nil | ||
} | ||
|
||
func deleteDownload(downloadId: String) async throws {} | ||
|
||
func getUserTorrents() async throws { | ||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/cloud/history")) | ||
|
||
let data = try await performRequest(request: &request, requestName: "cloudHistory") | ||
let rawResponse = try jsonDecoder.decode([CloudHistoryResponse].self, from: data) | ||
|
||
cloudTorrents = rawResponse.compactMap { cloudHistory in | ||
guard let magnetHash = Magnet(hash: nil, link: cloudHistory.originalLink).hash else { | ||
return nil | ||
} | ||
|
||
return DebridCloudTorrent( | ||
torrentId: cloudHistory.requestId, | ||
source: self.id, | ||
fileName: cloudHistory.fileName, | ||
status: cloudHistory.status, | ||
hash: magnetHash, | ||
links: [cloudHistory.originalLink] | ||
) | ||
} | ||
} | ||
|
||
// Uses the base website because this isn't present in the API path but still works like the API? | ||
func deleteTorrent(torrentId: String?) async throws { | ||
guard let torrentId else { | ||
throw DebridError.InvalidPostBody | ||
} | ||
|
||
var request = URLRequest(url: try buildRequestURL(urlString: "\(website)/cloud/remove/\(torrentId)")) | ||
try await performRequest(request: &request, requestName: "cloudRemove") | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
// | ||
// OffCloudModels.swift | ||
// Ferrite | ||
// | ||
// Created by Brian Dashore on 6/12/24. | ||
// | ||
|
||
import Foundation | ||
|
||
extension OffCloud { | ||
struct InstantAvailabilityRequest: Codable, Sendable { | ||
let hashes: [String] | ||
} | ||
|
||
struct InstantAvailabilityResponse: Codable, Sendable { | ||
let cachedItems: [String] | ||
} | ||
|
||
struct CloudDownloadRequest: Codable, Sendable { | ||
let url: String | ||
} | ||
|
||
struct CloudDownloadResponse: Codable, Sendable { | ||
let requestId: String | ||
let fileName: String | ||
let status: String | ||
let originalLink: String | ||
let url: String | ||
} | ||
|
||
typealias CloudExploreResponse = [String] | ||
|
||
struct CloudHistoryResponse: Codable, Sendable { | ||
let requestId: String | ||
let fileName: String | ||
let status: String | ||
let originalLink: String | ||
let isDirectory: Bool | ||
let server: String | ||
} | ||
} |
Oops, something went wrong.