diff --git a/aria2/aria2c b/aria2/aria2c new file mode 100644 index 00000000..ffa71ecf Binary files /dev/null and b/aria2/aria2c differ diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py new file mode 100644 index 00000000..855d5ef4 --- /dev/null +++ b/python_rpc/http_downloader.py @@ -0,0 +1,47 @@ +import aria2p + +class HttpDownloader: + def __init__(self): + self.download = None + self.aria2 = aria2p.API( + aria2p.Client( + host="http://localhost", + port=6800, + secret="" + ) + ) + + def start_download(self, url: str, save_path: str, header: str): + if self.download: + self.aria2.resume([self.download]) + else: + downloads = self.aria2.add(url, options={"header": header, "dir": save_path}) + self.download = downloads[0] + + def pause_download(self): + if self.download: + self.aria2.pause([self.download]) + + def cancel_download(self): + if self.download: + self.aria2.remove([self.download]) + self.download = None + + def get_download_status(self): + if self.download == None: + return None + + download = self.aria2.get_download(self.download.gid) + + response = { + 'folderName': str(download.dir) + "/" + download.name, + 'fileSize': download.total_length, + 'progress': download.completed_length / download.total_length if download.total_length else 0, + 'downloadSpeed': download.download_speed, + 'numPeers': 0, + 'numSeeds': 0, + 'status': download.status, + 'bytesDownloaded': download.completed_length, + } + + return response diff --git a/python_rpc/main.py b/python_rpc/main.py new file mode 100644 index 00000000..561a022b --- /dev/null +++ b/python_rpc/main.py @@ -0,0 +1,136 @@ +from flask import Flask, request, jsonify +import sys, json, urllib.parse, psutil +from torrent_downloader import TorrentDownloader +from http_downloader import HttpDownloader +from profile_image_processor import ProfileImageProcessor +import libtorrent as lt + +app = Flask(__name__) + +# Retrieve command line arguments +torrent_port = sys.argv[1] +http_port = sys.argv[2] +rpc_password = sys.argv[3] + +downloads = {} +# This can be streamed down from Node +downloading_game_id = -1 + +torrent_session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=torrent_port)}) + +def validate_rpc_password(): + """Middleware to validate RPC password.""" + header_password = request.headers.get('x-hydra-rpc-password') + if header_password != rpc_password: + return jsonify({"error": "Unauthorized"}), 401 + +@app.route("/status", methods=["GET"]) +def status(): + auth_error = validate_rpc_password() + if auth_error: + return auth_error + + downloader = downloads.get(downloading_game_id) + if downloader: + status = downloads.get(downloading_game_id).get_download_status() + return jsonify(status), 200 + else: + return jsonify(None) + +@app.route("/seed-status", methods=["GET"]) +def seed_status(): + auth_error = validate_rpc_password() + if auth_error: + return auth_error + + status = torrent_downloader.get_seed_status() + return jsonify(status), 200 + +@app.route("/healthcheck", methods=["GET"]) +def healthcheck(): + return "", 200 + +@app.route("/process-list", methods=["GET"]) +def process_list(): + auth_error = validate_rpc_password() + if auth_error: + return auth_error + + process_list = [proc.info for proc in psutil.process_iter(['exe', 'pid', 'username'])] + return jsonify(process_list), 200 + +@app.route("/profile-image", methods=["POST"]) +def profile_image(): + auth_error = validate_rpc_password() + if auth_error: + return auth_error + + data = request.get_json() + image_path = data.get('image_path') + + try: + processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path) + return jsonify({'imagePath': processed_image_path, 'mimeType': mime_type}), 200 + except Exception as e: + return jsonify({"error": str(e)}), 400 + +@app.route("/action", methods=["POST"]) +def action(): + global torrent_session + global downloading_game_id + + auth_error = validate_rpc_password() + if auth_error: + return auth_error + + data = request.get_json() + action = data.get('action') + game_id = data.get('game_id') + + print(data) + + if action == 'start': + url = data.get('url') + + existing_downloader = downloads.get(game_id) + + if existing_downloader: + # This will resume the download + existing_downloader.start_download(url, data['save_path'], data.get('header')) + else: + if url.startswith('magnet'): + torrent_downloader = TorrentDownloader(torrent_session) + downloads[game_id] = torrent_downloader + torrent_downloader.start_download(url, data['save_path'], "") + else: + http_downloader = HttpDownloader() + downloads[game_id] = http_downloader + http_downloader.start_download(url, data['save_path'], data.get('header')) + + downloading_game_id = game_id + + elif action == 'pause': + downloader = downloads.get(game_id) + if downloader: + downloader.pause_download() + downloading_game_id = -1 + elif action == 'cancel': + downloader = downloads.get(game_id) + if downloader: + downloader.cancel_download() + + # elif action == 'kill-torrent': + # torrent_downloader.abort_session() + # torrent_downloader = None + # elif action == 'pause-seeding': + # torrent_downloader.pause_seeding(game_id) + # elif action == 'resume-seeding': + # torrent_downloader.resume_seeding(game_id, data['url'], data['save_path']) + else: + return jsonify({"error": "Invalid action"}), 400 + + return "", 200 + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(http_port)) + \ No newline at end of file diff --git a/python_rpc/profile_image_processor.py b/python_rpc/profile_image_processor.py new file mode 100644 index 00000000..9fe8489e --- /dev/null +++ b/python_rpc/profile_image_processor.py @@ -0,0 +1,30 @@ +from PIL import Image +import os, uuid, tempfile + +class ProfileImageProcessor: + + @staticmethod + def get_parsed_image_data(image_path): + Image.MAX_IMAGE_PIXELS = 933120000 + + image = Image.open(image_path) + + try: + image.seek(1) + except EOFError: + mime_type = image.get_format_mimetype() + return image_path, mime_type + else: + newUUID = str(uuid.uuid4()) + new_image_path = os.path.join(tempfile.gettempdir(), newUUID) + ".webp" + image.save(new_image_path) + + new_image = Image.open(new_image_path) + mime_type = new_image.get_format_mimetype() + + return new_image_path, mime_type + + + @staticmethod + def process_image(image_path): + return ProfileImageProcessor.get_parsed_image_data(image_path) diff --git a/python_rpc/setup.py b/python_rpc/setup.py new file mode 100644 index 00000000..1dc5a06e --- /dev/null +++ b/python_rpc/setup.py @@ -0,0 +1,20 @@ +from cx_Freeze import setup, Executable + +# Dependencies are automatically detected, but it might need fine tuning. +build_exe_options = { + "packages": ["libtorrent"], + "build_exe": "hydra-python-rpc", + "include_msvcr": True +} + +setup( + name="hydra-python-rpc", + version="0.1", + description="Hydra", + options={"build_exe": build_exe_options}, + executables=[Executable( + "python_rpc/main.py", + target_name="hydra-python-rpc", + icon="build/icon.ico" + )] +) diff --git a/python_rpc/torrent_downloader.py b/python_rpc/torrent_downloader.py new file mode 100644 index 00000000..a3a8376c --- /dev/null +++ b/python_rpc/torrent_downloader.py @@ -0,0 +1,191 @@ +import libtorrent as lt + +class TorrentDownloader: + def __init__(self, torrent_session): + self.torrent_handle = None + self.session = torrent_session + self.trackers = [ + "udp://tracker.opentrackr.org:1337/announce", + "http://tracker.opentrackr.org:1337/announce", + "udp://open.tracker.cl:1337/announce", + "udp://open.demonii.com:1337/announce", + "udp://open.stealth.si:80/announce", + "udp://tracker.torrent.eu.org:451/announce", + "udp://exodus.desync.com:6969/announce", + "udp://tracker.theoks.net:6969/announce", + "udp://tracker-udp.gbitt.info:80/announce", + "udp://explodie.org:6969/announce", + "https://tracker.tamersunion.org:443/announce", + "udp://tracker2.dler.org:80/announce", + "udp://tracker1.myporn.club:9337/announce", + "udp://tracker.tiny-vps.com:6969/announce", + "udp://tracker.dler.org:6969/announce", + "udp://tracker.bittor.pw:1337/announce", + "udp://tracker.0x7c0.com:6969/announce", + "udp://retracker01-msk-virt.corbina.net:80/announce", + "udp://opentracker.io:6969/announce", + "udp://open.free-tracker.ga:6969/announce", + "udp://new-line.net:6969/announce", + "udp://moonburrow.club:6969/announce", + "udp://leet-tracker.moe:1337/announce", + "udp://bt2.archive.org:6969/announce", + "udp://bt1.archive.org:6969/announce", + "http://tracker2.dler.org:80/announce", + "http://tracker1.bt.moack.co.kr:80/announce", + "http://tracker.dler.org:6969/announce", + "http://tr.kxmp.cf:80/announce", + "udp://u.peer-exchange.download:6969/announce", + "udp://ttk2.nbaonlineservice.com:6969/announce", + "udp://tracker.tryhackx.org:6969/announce", + "udp://tracker.srv00.com:6969/announce", + "udp://tracker.skynetcloud.site:6969/announce", + "udp://tracker.jamesthebard.net:6969/announce", + "udp://tracker.fnix.net:6969/announce", + "udp://tracker.filemail.com:6969/announce", + "udp://tracker.farted.net:6969/announce", + "udp://tracker.edkj.club:6969/announce", + "udp://tracker.dump.cl:6969/announce", + "udp://tracker.deadorbit.nl:6969/announce", + "udp://tracker.darkness.services:6969/announce", + "udp://tracker.ccp.ovh:6969/announce", + "udp://tamas3.ynh.fr:6969/announce", + "udp://ryjer.com:6969/announce", + "udp://run.publictracker.xyz:6969/announce", + "udp://public.tracker.vraphim.com:6969/announce", + "udp://p4p.arenabg.com:1337/announce", + "udp://p2p.publictracker.xyz:6969/announce", + "udp://open.u-p.pw:6969/announce", + "udp://open.publictracker.xyz:6969/announce", + "udp://open.dstud.io:6969/announce", + "udp://open.demonoid.ch:6969/announce", + "udp://odd-hd.fr:6969/announce", + "udp://martin-gebhardt.eu:25/announce", + "udp://jutone.com:6969/announce", + "udp://isk.richardsw.club:6969/announce", + "udp://evan.im:6969/announce", + "udp://epider.me:6969/announce", + "udp://d40969.acod.regrucolo.ru:6969/announce", + "udp://bt.rer.lol:6969/announce", + "udp://amigacity.xyz:6969/announce", + "udp://1c.premierzal.ru:6969/announce", + "https://trackers.run:443/announce", + "https://tracker.yemekyedim.com:443/announce", + "https://tracker.renfei.net:443/announce", + "https://tracker.pmman.tech:443/announce", + "https://tracker.lilithraws.org:443/announce", + "https://tracker.imgoingto.icu:443/announce", + "https://tracker.cloudit.top:443/announce", + "https://tracker-zhuqiy.dgj055.icu:443/announce", + "http://tracker.renfei.net:8080/announce", + "http://tracker.mywaifu.best:6969/announce", + "http://tracker.ipv6tracker.org:80/announce", + "http://tracker.files.fm:6969/announce", + "http://tracker.edkj.club:6969/announce", + "http://tracker.bt4g.com:2095/announce", + "http://tracker-zhuqiy.dgj055.icu:80/announce", + "http://t1.aag.moe:17715/announce", + "http://t.overflow.biz:6969/announce", + "http://bittorrent-tracker.e-n-c-r-y-p-t.net:1337/announce", + "udp://torrents.artixlinux.org:6969/announce", + "udp://mail.artixlinux.org:6969/announce", + "udp://ipv4.rer.lol:2710/announce", + "udp://concen.org:6969/announce", + "udp://bt.rer.lol:2710/announce", + "udp://aegir.sexy:6969/announce", + "https://www.peckservers.com:9443/announce", + "https://tracker.ipfsscan.io:443/announce", + "https://tracker.gcrenwp.top:443/announce", + "http://www.peckservers.com:9000/announce", + "http://tracker1.itzmx.com:8080/announce", + "http://ch3oh.ru:6969/announce", + "http://bvarf.tracker.sh:2086/announce", + ] + + def start_download(self, magnet: str, save_path: str, header: str): + params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers} + self.torrent_handle = self.session.add_torrent(params) + self.torrent_handle.set_flags(lt.torrent_flags.auto_managed) + self.torrent_handle.resume() + + def pause_download(self): + if self.torrent_handle: + self.torrent_handle.pause() + self.torrent_handle.unset_flags(lt.torrent_flags.auto_managed) + + def cancel_download(self): + if self.torrent_handle: + self.torrent_handle.pause() + self.session.remove_torrent(self.torrent_handle) + self.torrent_handle = None + + def abort_session(self): + for game_id in self.torrent_handles: + self.torrent_handle = self.torrent_handles[game_id] + self.torrent_handle.pause() + self.session.remove_torrent(self.torrent_handle) + + self.session.abort() + self.torrent_handle = None + + def get_download_status(self): + if self.torrent_handle == None: + return None + + status = self.torrent_handle.status() + info = self.torrent_handle.get_torrent_info() + + response = { + 'folderName': info.name() if info else "", + 'fileSize': info.total_size() if info else 0, + 'progress': status.progress, + 'downloadSpeed': status.download_rate, + 'numPeers': status.num_peers, + 'numSeeds': status.num_seeds, + 'status': status.state, + 'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download, + } + + return response + + # def get_seed_status(self): + # response = [] + + # for game_id, torrent_handle in self.torrent_handles.items(): + # if game_id == self.downloading_game_id: + # continue + + # status = torrent_handle.status() + # info = torrent_handle.torrent_file() + + # torrent_info = { + # 'folderName': info.name() if info else "", + # 'fileSize': info.total_size() if info else 0, + # 'gameId': game_id, + # 'progress': status.progress, + # 'downloadSpeed': status.download_rate, + # 'uploadSpeed': status.upload_rate, + # 'numPeers': status.num_peers, + # 'numSeeds': status.num_seeds, + # 'status': status.state, + # 'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download, + # } + + # if status.state == 5: + # response.append(torrent_info) + + # return response + + # def pause_seeding(self, game_id: int): + # torrent_handle = self.torrent_handles.get(game_id) + # if torrent_handle: + # torrent_handle.pause() + # torrent_handle.unset_flags(lt.torrent_flags.auto_managed) + # self.session.remove_torrent(torrent_handle) + # self.torrent_handles.pop(game_id, None) + + # def resume_seeding(self, game_id: int, magnet: str, save_path: str): + # params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers} + # torrent_handle = self.session.add_torrent(params) + # self.torrent_handles[game_id] = torrent_handle + # torrent_handle.set_flags(lt.torrent_flags.auto_managed) + # torrent_handle.resume() diff --git a/src/main/services/aria2.ts b/src/main/services/aria2.ts new file mode 100644 index 00000000..728df633 --- /dev/null +++ b/src/main/services/aria2.ts @@ -0,0 +1,32 @@ +import path from "node:path"; +import cp from "node:child_process"; +import { app } from "electron"; + +export const startAria2 = () => {}; + +export class Aria2 { + private static process: cp.ChildProcess | null = null; + + public static spawn() { + const binaryPath = app.isPackaged + ? path.join(process.resourcesPath, "aria2", "aria2c") + : path.join(__dirname, "..", "..", "aria2", "aria2c"); + + this.process = cp.spawn( + binaryPath, + [ + "--enable-rpc", + "--rpc-listen-all", + "--file-allocation=none", + "--allow-overwrite=true", + ], + { stdio: "inherit" } + ); + + console.log(this.process); + } + + public static kill() { + this.process?.kill(); + } +} diff --git a/src/main/services/download/real-debrid.ts b/src/main/services/download/real-debrid.ts new file mode 100644 index 00000000..a6b08e45 --- /dev/null +++ b/src/main/services/download/real-debrid.ts @@ -0,0 +1,119 @@ +import axios, { AxiosInstance } from "axios"; +import parseTorrent from "parse-torrent"; +import type { + RealDebridAddMagnet, + RealDebridTorrentInfo, + RealDebridUnrestrictLink, + RealDebridUser, +} from "@types"; + +export class RealDebridClient { + private static instance: AxiosInstance; + private static baseURL = "https://api.real-debrid.com/rest/1.0"; + + static authorize(apiToken: string) { + this.instance = axios.create({ + baseURL: this.baseURL, + headers: { + Authorization: `Bearer ${apiToken}`, + }, + }); + } + + static async addMagnet(magnet: string) { + const searchParams = new URLSearchParams({ magnet }); + + const response = await this.instance.post( + "/torrents/addMagnet", + searchParams.toString() + ); + + return response.data; + } + + static async getTorrentInfo(id: string) { + const response = await this.instance.get( + `/torrents/info/${id}` + ); + return response.data; + } + + static async getUser() { + const response = await this.instance.get(`/user`); + return response.data; + } + + static async selectAllFiles(id: string) { + const searchParams = new URLSearchParams({ files: "all" }); + + return this.instance.post( + `/torrents/selectFiles/${id}`, + searchParams.toString() + ); + } + + static async unrestrictLink(link: string) { + const searchParams = new URLSearchParams({ link }); + + const response = await this.instance.post( + "/unrestrict/link", + searchParams.toString() + ); + + return response.data; + } + + private static async getAllTorrentsFromUser() { + const response = + await this.instance.get("/torrents"); + + return response.data; + } + + static async getTorrentId(magnetUri: string) { + const userTorrents = await RealDebridClient.getAllTorrentsFromUser(); + + const { infoHash } = await parseTorrent(magnetUri); + const userTorrent = userTorrents.find( + (userTorrent) => userTorrent.hash === infoHash + ); + + if (userTorrent) return userTorrent.id; + + const torrent = await RealDebridClient.addMagnet(magnetUri); + return torrent.id; + } + + public static async getDownloadUrl(uri: string) { + let realDebridTorrentId: string | null = null; + + if (uri.startsWith("magnet:")) { + realDebridTorrentId = await this.getTorrentId(uri); + } + + if (realDebridTorrentId) { + let torrentInfo = await this.getTorrentInfo(realDebridTorrentId); + + if (torrentInfo.status === "waiting_files_selection") { + await this.selectAllFiles(realDebridTorrentId); + + torrentInfo = await this.getTorrentInfo(realDebridTorrentId); + } + + const { links, status } = torrentInfo; + + if (status === "downloaded") { + const [link] = links; + + const { download } = await this.unrestrictLink(link); + return decodeURIComponent(download); + } + + return null; + } + + const { download } = await this.unrestrictLink(uri); + + return decodeURIComponent(download); + } +} diff --git a/src/main/services/download/torbox.ts b/src/main/services/download/torbox.ts new file mode 100644 index 00000000..cf556a48 --- /dev/null +++ b/src/main/services/download/torbox.ts @@ -0,0 +1,96 @@ +import axios, { AxiosInstance } from "axios"; +import parseTorrent from "parse-torrent"; +import type { + TorBoxUserRequest, + TorBoxTorrentInfoRequest, + TorBoxAddTorrentRequest, + TorBoxRequestLinkRequest, +} from "@types"; + +export class TorBoxClient { + private static instance: AxiosInstance; + private static baseURL = "https://api.torbox.app/v1/api"; + public static apiToken: string; + + static authorize(apiToken: string) { + this.instance = axios.create({ + baseURL: this.baseURL, + headers: { + Authorization: `Bearer ${apiToken}`, + }, + }); + this.apiToken = apiToken; + } + + static async addMagnet(magnet: string) { + const form = new FormData(); + form.append("magnet", magnet); + + const response = await this.instance.post( + "/torrents/createtorrent", + form + ); + + return response.data.data; + } + + static async getTorrentInfo(id: number) { + const response = + await this.instance.get("/torrents/mylist"); + const data = response.data.data; + + const info = data.find((item) => item.id === id); + + if (!info) { + return null; + } + + return info; + } + + static async getUser() { + const response = await this.instance.get(`/user/me`); + return response.data.data; + } + + static async requestLink(id: number) { + const searchParams = new URLSearchParams({}); + + searchParams.set("token", this.apiToken); + searchParams.set("torrent_id", id.toString()); + searchParams.set("zip_link", "true"); + + const response = await this.instance.get( + "/torrents/requestdl?" + searchParams.toString() + ); + + if (response.status !== 200) { + console.error(response.data.error); + console.error(response.data.detail); + return null; + } + + return response.data.data; + } + + private static async getAllTorrentsFromUser() { + const response = + await this.instance.get("/torrents/mylist"); + + return response.data.data; + } + + static async getTorrentId(magnetUri: string) { + const userTorrents = await this.getAllTorrentsFromUser(); + + const { infoHash } = await parseTorrent(magnetUri); + const userTorrent = userTorrents.find( + (userTorrent) => userTorrent.hash === infoHash + ); + + if (userTorrent) return userTorrent.id; + + const torrent = await this.addMagnet(magnetUri); + return torrent.torrent_id; + } +} diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts new file mode 100644 index 00000000..55e3845b --- /dev/null +++ b/src/main/services/python-rpc.ts @@ -0,0 +1,96 @@ +import axios from "axios"; + +import cp from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; + +import { logger } from "./logger"; +import { Readable } from "node:stream"; +import { app, dialog } from "electron"; + +const binaryNameByPlatform: Partial> = { + darwin: "hydra-python-rpc", + linux: "hydra-python-rpc", + win32: "hydra-python-rpc.exe", +}; + +export class PythonRPC { + public static readonly BITTORRENT_PORT = "5881"; + public static readonly RPC_PORT = "8084"; + private static readonly RPC_PASSWORD = crypto.randomBytes(32).toString("hex"); + + private static pythonProcess: cp.ChildProcess | null = null; + + public static rpc = axios.create({ + baseURL: `http://localhost:${this.RPC_PORT}`, + headers: { + "x-hydra-rpc-password": this.RPC_PASSWORD, + }, + }); + + private static logStderr(readable: Readable | null) { + if (!readable) return; + + readable.setEncoding("utf-8"); + readable.on("data", logger.log); + } + + public static spawn() { + console.log([this.BITTORRENT_PORT, this.RPC_PORT, this.RPC_PASSWORD]); + const commonArgs = [this.BITTORRENT_PORT, this.RPC_PORT, this.RPC_PASSWORD]; + + if (app.isPackaged) { + const binaryName = binaryNameByPlatform[process.platform]!; + const binaryPath = path.join( + process.resourcesPath, + "hydra-python-rpc", + binaryName + ); + + if (!fs.existsSync(binaryPath)) { + dialog.showErrorBox( + "Fatal", + "Hydra Python Instance binary not found. Please check if it has been removed by Windows Defender." + ); + + app.quit(); + } + + const childProcess = cp.spawn(binaryPath, commonArgs, { + windowsHide: true, + stdio: ["inherit", "inherit"], + }); + + this.logStderr(childProcess.stderr); + + this.pythonProcess = childProcess; + } else { + const scriptPath = path.join( + __dirname, + "..", + "..", + "python_rpc", + "main.py" + ); + + console.log(scriptPath); + + const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], { + stdio: ["inherit", "inherit"], + }); + + this.logStderr(childProcess.stderr); + + this.pythonProcess = childProcess; + } + } + + public static kill() { + if (this.pythonProcess) { + logger.log("Killing python process"); + this.pythonProcess.kill(); + this.pythonProcess = null; + } + } +} diff --git a/src/types/torbox.types.ts b/src/types/torbox.types.ts new file mode 100644 index 00000000..a53ccc4c --- /dev/null +++ b/src/types/torbox.types.ts @@ -0,0 +1,77 @@ +export interface TorBoxUser { + id: number; + email: string; + plan: string; + expiration: string; +} + +export interface TorBoxUserRequest { + success: boolean; + detail: string; + error: string; + data: TorBoxUser; +} + +export interface TorBoxFile { + id: number; + md5: string; + s3_path: string; + name: string; + size: number; + mimetype: string; + short_name: string; +} + +export interface TorBoxTorrentInfo { + id: number; + hash: string; + created_at: string; + updated_at: string; + magnet: string; + size: number; + active: boolean; + cached: boolean; + auth_id: string; + download_state: + | "downloading" + | "uploading" + | "stalled (no seeds)" + | "paused" + | "completed" + | "cached" + | "metaDL" + | "checkingResumeData"; + seeds: number; + ratio: number; + progress: number; + download_speed: number; + upload_speed: number; + name: string; + eta: number; + files: TorBoxFile[]; +} + +export interface TorBoxTorrentInfoRequest { + success: boolean; + detail: string; + error: string; + data: TorBoxTorrentInfo[]; +} + +export interface TorBoxAddTorrentRequest { + success: boolean; + detail: string; + error: string; + data: { + torrent_id: number; + name: string; + hash: string; + }; +} + +export interface TorBoxRequestLinkRequest { + success: boolean; + detail: string; + error: string; + data: string; +}