RealDebrid: Add batch torrent support

Batch torrents are torrents that have multiple files bundled within
one torrent file.

RealDebrid does support these, but it is difficult to get them to work.

The main flow requires setting a specific combination in RealDebrid
to allow for link generation. However, this is not intuitive to users
and is bad API design on RealDebrid's part.

Ferrite's implementation presents users with all the possible files
from batches (duplicates deleted) and selects the user-chosen file
to download. That way, only the user chosen file is presented to
play on an external video player.

This still needs work for optimization purposes, but this commit
does produce a working build.

Signed-off-by: kingbri <bdashore3@gmail.com>
This commit is contained in:
kingbri 2022-07-24 14:50:29 -04:00
parent 47f713744b
commit e9670ea118
11 changed files with 265 additions and 50 deletions

View file

@ -32,6 +32,8 @@
0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D3288903F000DE2211 /* SearchResultsView.swift */; };
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D4288903F000DE2211 /* ContentView.swift */; };
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; };
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; };
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; };
0CFEFCFD288A006200B3F490 /* GroupBoxStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */; };
/* End PBXBuildFile section */
@ -43,7 +45,7 @@
0CA148C1288903F000DE2211 /* NavView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavView.swift; sourceTree = "<group>"; };
0CA148C2288903F000DE2211 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrapingViewModel.swift; sourceTree = "<group>"; };
0CA148C4288903F000DE2211 /* RealDebridModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = "<group>"; };
0CA148C4288903F000DE2211 /* RealDebridModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 3; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = "<group>"; };
0CA148C6288903F000DE2211 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
0CA148C7288903F000DE2211 /* FerriteApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FerriteApp.swift; sourceTree = "<group>"; };
0CA148C9288903F000DE2211 /* Collection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = "<group>"; };
@ -58,6 +60,8 @@
0CA148D3288903F000DE2211 /* SearchResultsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = "<group>"; };
0CA148D4288903F000DE2211 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; };
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = "<group>"; };
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = "<group>"; };
0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupBoxStyle.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -128,6 +132,7 @@
0CA148BB288903F000DE2211 /* SettingsView.swift */,
0CA148BE288903F000DE2211 /* CardView.swift */,
0CA148BC288903F000DE2211 /* LoginWebView.swift */,
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */,
0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */,
);
path = Views;
@ -139,6 +144,7 @@
0CA148CD288903F000DE2211 /* DebridManager.swift */,
0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */,
0CA148CF288903F000DE2211 /* ToastViewModel.swift */,
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */,
);
path = Models;
sourceTree = "<group>";
@ -261,6 +267,7 @@
files = (
0CA148DB288903F000DE2211 /* NavView.swift in Sources */,
0CA148E9288903F000DE2211 /* MainView.swift in Sources */,
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */,
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
0CA148E1288903F000DE2211 /* Collection.swift in Sources */,
0CA148E4288903F000DE2211 /* Keychain.swift in Sources */,
@ -272,6 +279,7 @@
0CA148E6288903F000DE2211 /* WebView.swift in Sources */,
0CA148E2288903F000DE2211 /* Data.swift in Sources */,
0CA148DE288903F000DE2211 /* RealDebridModels.swift in Sources */,
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */,
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */,
0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */,

View file

@ -51,10 +51,10 @@ public struct TokenResponse: Codable {
// MARK: - instantAvailability endpoint
// Thanks Skitty!
struct InstantAvailabilityResponse: Codable {
public struct InstantAvailabilityResponse: Codable {
var data: InstantAvailabilityData?
init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let data = try? container.decode(InstantAvailabilityData.self) {
@ -72,6 +72,34 @@ struct InstantAvailabilityInfo: Codable {
var filesize: Int
}
// MARK: - Instant Availability client side structures
public struct RealDebridIA: Codable, Hashable {
let hash: String
var files: [RealDebridIAFile] = []
var batches: [RealDebridIABatch] = []
}
public struct RealDebridIABatch: Codable, Hashable {
let files: [RealDebridIABatchFile]
}
public struct RealDebridIABatchFile: Codable, Hashable {
let id: Int
let fileName: String
}
public struct RealDebridIAFile: Codable, Hashable {
let name: String
let batchIndex: Int
let batchFileIndex: Int
}
public enum RealDebridIAStatus: Codable, Hashable {
case full
case partial
case none
}
// MARK: - addMagnet endpoint
public struct AddMagnetResponse: Codable {
let id: String

View file

@ -218,8 +218,8 @@ public class RealDebrid: ObservableObject {
// Checks if the magnet is streamable on RD
// Currently does not work for batch links
public func instantAvailability(magnetHashes: [String]) async throws -> [String] {
var availableHashes: [String] = []
public func instantAvailability(magnetHashes: [String]) async throws -> [RealDebridIA] {
var availableHashes: [RealDebridIA] = []
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnetHashes.joined(separator: "/"))")!)
let data = try await performRequest(request: &request, requestName: #function)
@ -232,9 +232,47 @@ public class RealDebrid: ObservableObject {
continue
}
// Do not include if a hash is a batch
if !(data.rd.count > 1), !(data.rd[safe: 0]?.keys.count ?? 0 > 1) {
availableHashes.append(hash)
if data.rd.isEmpty {
continue
}
// Is this a batch
if data.rd.count > 1 || data.rd[0].count > 1 {
// Batch array
let batches = data.rd.map { fileDict in
let batchFiles: [RealDebridIABatchFile] = fileDict.map { (key, value) in
// Force unwrapped ID. Is safe because ID is guaranteed on a successful response
return RealDebridIABatchFile(id: Int(key)!, fileName: value.filename)
}.sorted(by: { $0.id < $1.id })
return RealDebridIABatch(files: batchFiles)
}
// RD files array
// Possibly sort this in the future, but not sure how at the moment
var files: [RealDebridIAFile] = []
for index in batches.indices {
let batchFiles = batches[index].files
for batchFileIndex in batchFiles.indices {
let batchFile = batchFiles[batchFileIndex]
if !files.contains(where: { $0.name == batchFile.fileName }) {
files.append(
RealDebridIAFile(
name: batchFile.fileName,
batchIndex: index,
batchFileIndex: batchFileIndex
)
)
}
}
}
availableHashes.append(RealDebridIA(hash: hash, files: files, batches: batches))
} else {
availableHashes.append(RealDebridIA(hash: hash))
}
}
@ -259,13 +297,19 @@ public class RealDebrid: ObservableObject {
}
// Queues the magnet link for downloading
public func selectFiles(debridID: String) async throws {
public 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()
bodyComponents.queryItems = [URLQueryItem(name: "files", value: "all")]
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)
@ -273,13 +317,14 @@ public class RealDebrid: ObservableObject {
}
// Fetches the info of a torrent
public func torrentInfo(debridID: String) async throws -> String {
public func torrentInfo(debridID: String, selectedIndex: Int?) async throws -> String {
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)
if let torrentLink = rawResponse.links[safe: 0] {
// Error out if no index is provided
if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1] {
return torrentLink
} else {
throw RealDebridError.EmptyData

View file

@ -12,6 +12,7 @@ struct FerriteApp: App {
@StateObject var scrapingModel: ScrapingViewModel = .init()
@StateObject var toastModel: ToastViewModel = .init()
@StateObject var debridManager: DebridManager = .init()
@StateObject var navigationModel: NavigationViewModel = .init()
var body: some Scene {
WindowGroup {
@ -23,6 +24,7 @@ struct FerriteApp: App {
.environmentObject(debridManager)
.environmentObject(scrapingModel)
.environmentObject(toastModel)
.environmentObject(navigationModel)
}
}
}

View file

@ -18,9 +18,11 @@ public class DebridManager: ObservableObject {
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@Published var realDebridHashes: [String] = []
@Published var realDebridHashes: [RealDebridIA] = []
@Published var realDebridAuthUrl: String = ""
@Published var realDebridDownloadUrl: String = ""
@Published var selectedRealDebridItem: RealDebridIA?
@Published var selectedRealDebridFile: RealDebridIAFile?
init() {
realDebrid.parentManager = self
@ -50,6 +52,38 @@ public class DebridManager: ObservableObject {
}
}
public func matchSearchResult(result: SearchResult?) -> RealDebridIAStatus {
guard let result = result else {
return .none
}
guard let debridMatch = realDebridHashes.first(where: { result.magnetHash == $0.hash }) else {
return .none
}
if debridMatch.batches.isEmpty {
return .full
} else {
return .partial
}
}
@MainActor
public func setSelectedRdResult(result: SearchResult) -> Bool {
guard let magnetHash = result.magnetHash else {
toastModel?.toastDescription = "Could not find the torrent magnet hash"
return false
}
if let realDebridItem = realDebridHashes.first(where: { magnetHash == $0.hash }) {
selectedRealDebridItem = realDebridItem
return true
} else {
toastModel?.toastDescription = "Could not find the associated RealDebrid entry for magnet hash \(magnetHash)"
return false
}
}
public func authenticateRd() async {
do {
let url = try await realDebrid.getVerificationInfo()
@ -67,12 +101,23 @@ public class DebridManager: ObservableObject {
}
}
public func fetchRdDownload(searchResult: SearchResult) async {
public func fetchRdDownload(searchResult: SearchResult, iaFile: RealDebridIAFile? = nil) async {
do {
let realDebridId = try await realDebrid.addMagnet(magnetLink: searchResult.magnetLink)
try await realDebrid.selectFiles(debridID: realDebridId)
let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId)
var fileIds: [Int] = []
if let iaFile = iaFile {
guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else {
return
}
fileIds = iaBatchFromFile.files.map({ $0.id })
}
try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds)
let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: iaFile == nil ? 0 : iaFile?.batchFileIndex)
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
Task { @MainActor in

View file

@ -0,0 +1,21 @@
//
// NavigationViewModel.swift
// Ferrite
//
// Created by Brian Dashore on 7/24/22.
//
import SwiftUI
class NavigationViewModel: ObservableObject {
enum ChoiceSheetType: Identifiable {
var id: Int {
hashValue
}
case magnet
case batch
}
@Published var currentChoiceSheet: ChoiceSheetType?
}

View file

@ -54,9 +54,7 @@ class ScrapingViewModel: ObservableObject {
@Published var searchResults: [SearchResult] = []
@Published var debridHashes: [String] = []
@Published var searchText: String = ""
@Published var realDebridAuthUrl: String = ""
@Published var showWebView: Bool = false
@Published var selectedSearchResult: SearchResult?
// Fetches the HTML body for the source website
@MainActor

View file

@ -0,0 +1,61 @@
//
// BatchChoiceView.swift
// Ferrite
//
// Created by Brian Dashore on 7/24/22.
//
import SwiftUI
struct BatchChoiceView: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var navigationModel: NavigationViewModel
var body: some View {
NavView {
List {
// To present this sheet, an RD item had to be set, this force unwrap is therefore safe
ForEach(debridManager.selectedRealDebridItem!.files, id: \.self) { file in
Button(file.name) {
debridManager.selectedRealDebridFile = file
if let searchResult = scrapingModel.selectedSearchResult {
Task {
await debridManager.fetchRdDownload(searchResult: searchResult, iaFile: file)
// The download may complete before this sheet dismisses
try? await Task.sleep(seconds: 1)
navigationModel.currentChoiceSheet = .magnet
debridManager.selectedRealDebridFile = nil
debridManager.selectedRealDebridItem = nil
}
}
dismiss()
}
}
}
.navigationTitle("Select a file")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
debridManager.selectedRealDebridItem = nil
dismiss()
}
}
}
}
}
}
struct BatchChoiceView_Previews: PreviewProvider {
static var previews: some View {
BatchChoiceView()
}
}

View file

@ -11,6 +11,8 @@ struct ContentView: View {
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var debridManager: DebridManager
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
var body: some View {
NavView {
VStack {
@ -25,7 +27,10 @@ struct ContentView: View {
}
await scrapingModel.scrapeWebsite(source: source, html: html)
await debridManager.populateDebridHashes(scrapingModel.searchResults)
if realDebridEnabled {
await debridManager.populateDebridHashes(scrapingModel.searchResults)
}
}
}
}

View file

@ -11,19 +11,18 @@ import ActivityView
struct MagnetChoiceView: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var debridManager: DebridManager
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@Binding var selectedResult: SearchResult?
@State private var showActivityView = false
@State private var activityItem: ActivityItem?
var body: some View {
NavView {
Form {
if realDebridEnabled, debridManager.realDebridHashes.contains(selectedResult?.magnetHash ?? "") {
if realDebridEnabled, debridManager.matchSearchResult(result: scrapingModel.selectedSearchResult) != .none {
Section("Real Debrid options") {
Button("Play on Outplayer") {
guard let downloadUrl = URL(string: "outplayer://\(debridManager.realDebridDownloadUrl)") else {
@ -66,11 +65,11 @@ struct MagnetChoiceView: View {
Section("Magnet options") {
Button("Copy magnet") {
UIPasteboard.general.string = selectedResult?.magnetLink
UIPasteboard.general.string = scrapingModel.selectedSearchResult?.magnetLink
}
Button("Share magnet") {
if let result = selectedResult, let url = URL(string: result.magnetLink) {
if let result = scrapingModel.selectedSearchResult, let url = URL(string: result.magnetLink) {
activityItem = ActivityItem(items: url)
showActivityView.toggle()
}
@ -83,6 +82,8 @@ struct MagnetChoiceView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
debridManager.realDebridDownloadUrl = ""
dismiss()
}
}
@ -93,16 +94,6 @@ struct MagnetChoiceView: View {
struct MagnetChoiceView_Previews: PreviewProvider {
static var previews: some View {
MagnetChoiceView(
selectedResult:
.constant(
SearchResult(
title: "",
source: "",
size: "",
magnetLink: "",
magnetHash: nil)
)
)
MagnetChoiceView()
}
}

View file

@ -13,36 +13,42 @@ struct SearchResultsView: View {
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navigationModel: NavigationViewModel
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@State var selectedResult: SearchResult?
@State private var showExternalSheet = false
@State private var resultUsesRd = false
var body: some View {
List {
ForEach(scrapingModel.searchResults, id: \.self) { result in
VStack(alignment: .leading) {
Button {
selectedResult = result
scrapingModel.selectedSearchResult = result
if debridManager.realDebridHashes.contains(result.magnetHash ?? ""), realDebridEnabled {
switch debridManager.matchSearchResult(result: result) {
case .full:
Task {
await debridManager.fetchRdDownload(searchResult: result)
showExternalSheet.toggle()
navigationModel.currentChoiceSheet = .magnet
}
} else {
showExternalSheet.toggle()
case .partial:
if debridManager.setSelectedRdResult(result: result) {
navigationModel.currentChoiceSheet = .batch
}
case .none:
navigationModel.currentChoiceSheet = .magnet
}
} label: {
Text(result.title)
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
}
.sheet(isPresented: $showExternalSheet) {
MagnetChoiceView(selectedResult: $selectedResult)
.sheet(item: $navigationModel.currentChoiceSheet) { item in
switch item {
case .magnet:
MagnetChoiceView()
case .batch:
BatchChoiceView()
}
}
.tint(colorScheme == .light ? .black : .white)
.padding(.bottom, 5)
@ -59,11 +65,16 @@ struct SearchResultsView: View {
.fontWeight(.bold)
.padding(2)
.background {
if debridManager.realDebridHashes.contains(result.magnetHash ?? "") {
switch debridManager.matchSearchResult(result: result) {
case .full:
Color.green
.cornerRadius(4)
.opacity(0.5)
} else {
case .partial:
Color.orange
.cornerRadius(4)
.opacity(0.5)
case .none:
Color.red
.cornerRadius(4)
.opacity(0.5)