mirror of
https://github.com/Ferrite-iOS/Ferrite.git
synced 2026-01-11 20:10:27 +00:00
Debrid: Add OffCloud support
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>
This commit is contained in:
parent
8f7fe94d21
commit
80e966512a
6 changed files with 357 additions and 3 deletions
|
|
@ -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 */; };
|
||||
|
|
@ -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>"; };
|
||||
|
|
@ -417,6 +421,7 @@
|
|||
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */,
|
||||
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */,
|
||||
0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */,
|
||||
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -670,6 +675,7 @@
|
|||
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
|
||||
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */,
|
||||
0C890E482C188808003B17B5 /* TorBoxWrapper.swift */,
|
||||
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */,
|
||||
);
|
||||
path = API;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -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 */,
|
||||
|
|
@ -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 */,
|
||||
|
|
|
|||
268
Ferrite/API/OffCloudWrapper.swift
Normal file
268
Ferrite/API/OffCloudWrapper.swift
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -54,4 +54,5 @@ enum DebridError: Error {
|
|||
case IsCaching
|
||||
case FailedRequest(description: String)
|
||||
case AuthQuery(description: String)
|
||||
case NotImplemented
|
||||
}
|
||||
|
|
|
|||
41
Ferrite/Models/OffCloudModels.swift
Normal file
41
Ferrite/Models/OffCloudModels.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -16,8 +16,9 @@ class DebridManager: ObservableObject {
|
|||
@Published var allDebrid: AllDebrid = .init()
|
||||
@Published var premiumize: Premiumize = .init()
|
||||
@Published var torbox: TorBox = .init()
|
||||
@Published var offcloud: OffCloud = .init()
|
||||
|
||||
lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox]
|
||||
lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox, offcloud]
|
||||
|
||||
// UI Variables
|
||||
@Published var showWebView: Bool = false
|
||||
|
|
@ -52,6 +53,8 @@ class DebridManager: ObservableObject {
|
|||
|
||||
@Published var showDeleteAlert: Bool = false
|
||||
@Published var showWebLoginAlert: Bool = false
|
||||
@Published var showNotImplementedAlert: Bool = false
|
||||
@Published var notImplementedMessage: String = ""
|
||||
|
||||
init() {
|
||||
// Set the preferred service. Contains migration logic for earlier versions
|
||||
|
|
@ -343,6 +346,10 @@ class DebridManager: ObservableObject {
|
|||
|
||||
// Indicate that a link needs to be selected (batch)
|
||||
if let newIA {
|
||||
if newIA.files.isEmpty {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
||||
selectedDebridItem = newIA
|
||||
requiresUnrestrict = true
|
||||
|
||||
|
|
@ -431,7 +438,19 @@ class DebridManager: ObservableObject {
|
|||
|
||||
await fetchDebridCloud(bypassTTL: true)
|
||||
} catch {
|
||||
await sendDebridError(error, prefix: "\(selectedSource.id) download delete error")
|
||||
switch error {
|
||||
case DebridError.NotImplemented:
|
||||
let message = "Download deletion for \(selectedSource.id) is not implemented. Please delete from the service's website."
|
||||
|
||||
notImplementedMessage = message
|
||||
showNotImplementedAlert.toggle()
|
||||
logManager?.error(
|
||||
"DebridManager: \(message)",
|
||||
showToast: false
|
||||
)
|
||||
default:
|
||||
await sendDebridError(error, prefix: "\(selectedSource.id) download delete error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -445,7 +464,19 @@ class DebridManager: ObservableObject {
|
|||
|
||||
await fetchDebridCloud(bypassTTL: true)
|
||||
} catch {
|
||||
await sendDebridError(error, prefix: "\(selectedSource.id) torrent delete error")
|
||||
switch error {
|
||||
case DebridError.NotImplemented:
|
||||
let message = "Torrent deletion for \(selectedSource.id) is not implemented. Please use the service's website."
|
||||
|
||||
notImplementedMessage = message
|
||||
showNotImplementedAlert.toggle()
|
||||
logManager?.error(
|
||||
"DebridManager: \(message)",
|
||||
showToast: false
|
||||
)
|
||||
default:
|
||||
await sendDebridError(error, prefix: "\(selectedSource.id) torrent delete error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,11 @@ struct LibraryView: View {
|
|||
.esAutocapitalization(autocorrectSearch ? .sentences : .none)
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
.alert("Not implemented", isPresented: $debridManager.showNotImplementedAlert) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(debridManager.notImplementedMessage)
|
||||
}
|
||||
.onChange(of: navModel.libraryPickerSelection) { _ in
|
||||
editMode = .inactive
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue