Skip to content

Commit

Permalink
Debrid: Add OffCloud support
Browse files Browse the repository at this point in the history
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
kingbri1 committed Jun 13, 2024
1 parent 39f01e4 commit 05d10c0
Show file tree
Hide file tree
Showing 6 changed files with 357 additions and 3 deletions.
8 changes: 8 additions & 0 deletions Ferrite.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; };
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; };
0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6032C1A859B00808A46 /* FormDataBody.swift */; };
0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */; };
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */; };
0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */; };
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */; };
Expand Down Expand Up @@ -171,6 +173,8 @@
0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = "<group>"; };
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = "<group>"; };
0C07C6032C1A859B00808A46 /* FormDataBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormDataBody.swift; sourceTree = "<group>"; };
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudModels.swift; sourceTree = "<group>"; };
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudWrapper.swift; sourceTree = "<group>"; };
0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatingSystemVersion.swift; sourceTree = "<group>"; };
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginAggregateView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -417,6 +421,7 @@
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */,
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */,
0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */,
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */,
);
path = Models;
sourceTree = "<group>";
Expand Down Expand Up @@ -670,6 +675,7 @@
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */,
0C890E482C188808003B17B5 /* TorBoxWrapper.swift */,
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */,
);
path = API;
sourceTree = "<group>";
Expand Down Expand Up @@ -937,6 +943,7 @@
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */,
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */,
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */,
0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */,
0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */,
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */,
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
Expand All @@ -946,6 +953,7 @@
0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */,
0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */,
0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */,
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */,
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */,
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */,
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */,
Expand Down
268 changes: 268 additions & 0 deletions Ferrite/API/OffCloudWrapper.swift
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")
}
}
1 change: 1 addition & 0 deletions Ferrite/Models/DebridModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,5 @@ enum DebridError: Error {
case IsCaching
case FailedRequest(description: String)
case AuthQuery(description: String)
case NotImplemented
}
41 changes: 41 additions & 0 deletions Ferrite/Models/OffCloudModels.swift
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
}
}
Loading

0 comments on commit 05d10c0

Please # to comment.