mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
Merge 24129481bc
into 110131f1d6
This commit is contained in:
commit
6ed43bc14f
27 changed files with 818 additions and 27 deletions
|
@ -44,5 +44,5 @@ class HttpDownloader:
|
|||
'status': download.status,
|
||||
'bytesDownloaded': download.completed_length,
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
return response
|
151
python_rpc/http_multi_link_downloader.py
Normal file
151
python_rpc/http_multi_link_downloader.py
Normal file
|
@ -0,0 +1,151 @@
|
|||
import aria2p
|
||||
from aria2p.client import ClientException as DownloadNotFound
|
||||
|
||||
class HttpMultiLinkDownloader:
|
||||
def __init__(self):
|
||||
self.downloads = []
|
||||
self.completed_downloads = []
|
||||
self.total_size = None
|
||||
self.aria2 = aria2p.API(
|
||||
aria2p.Client(
|
||||
host="http://localhost",
|
||||
port=6800,
|
||||
secret=""
|
||||
)
|
||||
)
|
||||
|
||||
def start_download(self, urls: list[str], save_path: str, header: str = None, out: str = None, total_size: int = None):
|
||||
"""Add multiple URLs to download queue with same options"""
|
||||
options = {"dir": save_path}
|
||||
if header:
|
||||
options["header"] = header
|
||||
if out:
|
||||
options["out"] = out
|
||||
|
||||
# Clear any existing downloads first
|
||||
self.cancel_download()
|
||||
self.completed_downloads = []
|
||||
self.total_size = total_size
|
||||
|
||||
for url in urls:
|
||||
try:
|
||||
added_downloads = self.aria2.add(url, options=options)
|
||||
self.downloads.extend(added_downloads)
|
||||
except Exception as e:
|
||||
print(f"Error adding download for URL {url}: {str(e)}")
|
||||
|
||||
def pause_download(self):
|
||||
"""Pause all active downloads"""
|
||||
if self.downloads:
|
||||
try:
|
||||
self.aria2.pause(self.downloads)
|
||||
except Exception as e:
|
||||
print(f"Error pausing downloads: {str(e)}")
|
||||
|
||||
def cancel_download(self):
|
||||
"""Cancel and remove all downloads"""
|
||||
if self.downloads:
|
||||
try:
|
||||
# First try to stop the downloads
|
||||
self.aria2.remove(self.downloads)
|
||||
except Exception as e:
|
||||
print(f"Error removing downloads: {str(e)}")
|
||||
finally:
|
||||
# Clear the downloads list regardless of success/failure
|
||||
self.downloads = []
|
||||
self.completed_downloads = []
|
||||
|
||||
def get_download_status(self):
|
||||
"""Get status for all tracked downloads, auto-remove completed/failed ones"""
|
||||
if not self.downloads and not self.completed_downloads:
|
||||
return []
|
||||
|
||||
total_completed = 0
|
||||
current_download_speed = 0
|
||||
active_downloads = []
|
||||
to_remove = []
|
||||
|
||||
# First calculate sizes from completed downloads
|
||||
for completed in self.completed_downloads:
|
||||
total_completed += completed['size']
|
||||
|
||||
# Then check active downloads
|
||||
for download in self.downloads:
|
||||
try:
|
||||
current_download = self.aria2.get_download(download.gid)
|
||||
|
||||
# Skip downloads that are not properly initialized
|
||||
if not current_download or not current_download.files:
|
||||
to_remove.append(download)
|
||||
continue
|
||||
|
||||
# Add to completed size and speed calculations
|
||||
total_completed += current_download.completed_length
|
||||
current_download_speed += current_download.download_speed
|
||||
|
||||
# If download is complete, move it to completed_downloads
|
||||
if current_download.status == 'complete':
|
||||
self.completed_downloads.append({
|
||||
'name': current_download.name,
|
||||
'size': current_download.total_length
|
||||
})
|
||||
to_remove.append(download)
|
||||
else:
|
||||
active_downloads.append({
|
||||
'name': current_download.name,
|
||||
'size': current_download.total_length,
|
||||
'completed': current_download.completed_length,
|
||||
'speed': current_download.download_speed
|
||||
})
|
||||
|
||||
except DownloadNotFound:
|
||||
to_remove.append(download)
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Error getting download status: {str(e)}")
|
||||
continue
|
||||
|
||||
# Clean up completed/removed downloads from active list
|
||||
for download in to_remove:
|
||||
try:
|
||||
if download in self.downloads:
|
||||
self.downloads.remove(download)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Return aggregate status
|
||||
if self.total_size or active_downloads or self.completed_downloads:
|
||||
# Use the first active download's name as the folder name, or completed if none active
|
||||
folder_name = None
|
||||
if active_downloads:
|
||||
folder_name = active_downloads[0]['name']
|
||||
elif self.completed_downloads:
|
||||
folder_name = self.completed_downloads[0]['name']
|
||||
|
||||
if folder_name and '/' in folder_name:
|
||||
folder_name = folder_name.split('/')[0]
|
||||
|
||||
# Use provided total size if available, otherwise sum from downloads
|
||||
total_size = self.total_size
|
||||
if not total_size:
|
||||
total_size = sum(d['size'] for d in active_downloads) + sum(d['size'] for d in self.completed_downloads)
|
||||
|
||||
# Calculate completion status based on total downloaded vs total size
|
||||
is_complete = len(active_downloads) == 0 and total_completed >= (total_size * 0.99) # Allow 1% margin for size differences
|
||||
|
||||
# If all downloads are complete, clear the completed_downloads list to prevent status updates
|
||||
if is_complete:
|
||||
self.completed_downloads = []
|
||||
|
||||
return [{
|
||||
'folderName': folder_name,
|
||||
'fileSize': total_size,
|
||||
'progress': total_completed / total_size if total_size > 0 else 0,
|
||||
'downloadSpeed': current_download_speed,
|
||||
'numPeers': 0,
|
||||
'numSeeds': 0,
|
||||
'status': 'complete' if is_complete else 'active',
|
||||
'bytesDownloaded': total_completed,
|
||||
}]
|
||||
|
||||
return []
|
|
@ -2,6 +2,7 @@ from flask import Flask, request, jsonify
|
|||
import sys, json, urllib.parse, psutil
|
||||
from torrent_downloader import TorrentDownloader
|
||||
from http_downloader import HttpDownloader
|
||||
from http_multi_link_downloader import HttpMultiLinkDownloader
|
||||
from profile_image_processor import ProfileImageProcessor
|
||||
import libtorrent as lt
|
||||
|
||||
|
@ -24,7 +25,15 @@ if start_download_payload:
|
|||
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
|
||||
downloading_game_id = initial_download['game_id']
|
||||
|
||||
if initial_download['url'].startswith('magnet'):
|
||||
if isinstance(initial_download['url'], list):
|
||||
# Handle multiple URLs using HttpMultiLinkDownloader
|
||||
http_multi_downloader = HttpMultiLinkDownloader()
|
||||
downloads[initial_download['game_id']] = http_multi_downloader
|
||||
try:
|
||||
http_multi_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get("out"))
|
||||
except Exception as e:
|
||||
print("Error starting multi-link download", e)
|
||||
elif initial_download['url'].startswith('magnet'):
|
||||
torrent_downloader = TorrentDownloader(torrent_session)
|
||||
downloads[initial_download['game_id']] = torrent_downloader
|
||||
try:
|
||||
|
@ -62,12 +71,23 @@ def status():
|
|||
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:
|
||||
if not downloader:
|
||||
return jsonify(None)
|
||||
|
||||
status = downloader.get_download_status()
|
||||
if not status:
|
||||
return jsonify(None)
|
||||
|
||||
if isinstance(status, list):
|
||||
if not status: # Empty list
|
||||
return jsonify(None)
|
||||
|
||||
# For multi-link downloader, use the aggregated status
|
||||
# The status will already be aggregated by the HttpMultiLinkDownloader
|
||||
return jsonify(status[0]), 200
|
||||
|
||||
return jsonify(status), 200
|
||||
|
||||
@app.route("/seed-status", methods=["GET"])
|
||||
def seed_status():
|
||||
auth_error = validate_rpc_password()
|
||||
|
@ -81,10 +101,24 @@ def seed_status():
|
|||
continue
|
||||
|
||||
response = downloader.get_download_status()
|
||||
if response is None:
|
||||
if not response:
|
||||
continue
|
||||
|
||||
if response.get('status') == 5:
|
||||
if isinstance(response, list):
|
||||
# For multi-link downloader, check if all files are complete
|
||||
if response and all(item['status'] == 'complete' for item in response):
|
||||
seed_status.append({
|
||||
'gameId': game_id,
|
||||
'status': 'complete',
|
||||
'folderName': response[0]['folderName'],
|
||||
'fileSize': sum(item['fileSize'] for item in response),
|
||||
'bytesDownloaded': sum(item['bytesDownloaded'] for item in response),
|
||||
'downloadSpeed': 0,
|
||||
'numPeers': 0,
|
||||
'numSeeds': 0,
|
||||
'progress': 1.0
|
||||
})
|
||||
elif response.get('status') == 5: # Original torrent seeding check
|
||||
seed_status.append({
|
||||
'gameId': game_id,
|
||||
**response,
|
||||
|
@ -138,7 +172,15 @@ def action():
|
|||
|
||||
existing_downloader = downloads.get(game_id)
|
||||
|
||||
if url.startswith('magnet'):
|
||||
if isinstance(url, list):
|
||||
# Handle multiple URLs using HttpMultiLinkDownloader
|
||||
if existing_downloader and isinstance(existing_downloader, HttpMultiLinkDownloader):
|
||||
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
||||
else:
|
||||
http_multi_downloader = HttpMultiLinkDownloader()
|
||||
downloads[game_id] = http_multi_downloader
|
||||
http_multi_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
||||
elif url.startswith('magnet'):
|
||||
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
|
||||
existing_downloader.start_download(url, data['save_path'])
|
||||
else:
|
||||
|
|
|
@ -227,7 +227,8 @@
|
|||
"seeding": "Seeding",
|
||||
"stop_seeding": "Stop seeding",
|
||||
"resume_seeding": "Resume seeding",
|
||||
"options": "Manage"
|
||||
"options": "Manage",
|
||||
"alldebrid_size_not_supported": "Download info for AllDebrid is not supported yet"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Downloads path",
|
||||
|
@ -306,7 +307,18 @@
|
|||
"enable_torbox": "Enable Torbox",
|
||||
"torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
|
||||
"torbox_account_linked": "TorBox account linked",
|
||||
"real_debrid_account_linked": "Real-Debrid account linked"
|
||||
"real_debrid_account_linked": "Real-Debrid account linked",
|
||||
"enable_all_debrid": "Enable All-Debrid",
|
||||
"all_debrid_description": "All-Debrid is an unrestricted downloader that allows you to quickly download files from various sources.",
|
||||
"all_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to All-Debrid",
|
||||
"all_debrid_account_linked": "All-Debrid account linked successfully",
|
||||
"alldebrid_missing_key": "Please provide an API key",
|
||||
"alldebrid_invalid_key": "Invalid API key",
|
||||
"alldebrid_blocked": "Your API key is geo-blocked or IP-blocked",
|
||||
"alldebrid_banned": "This account has been banned",
|
||||
"alldebrid_unknown_error": "An unknown error occurred",
|
||||
"alldebrid_invalid_response": "Invalid response from All-Debrid",
|
||||
"alldebrid_network_error": "Network error. Please check your connection"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Download complete",
|
||||
|
|
|
@ -134,7 +134,11 @@
|
|||
"real_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la Real-Debrid",
|
||||
"debrid_linked_message": "Contul \"{{username}}\" a fost legat",
|
||||
"save_changes": "Salvează modificările",
|
||||
"changes_saved": "Modificările au fost salvate cu succes"
|
||||
"changes_saved": "Modificările au fost salvate cu succes",
|
||||
"enable_all_debrid": "Activează All-Debrid",
|
||||
"all_debrid_description": "All-Debrid este un descărcător fără restricții care îți permite să descarci fișiere din diverse surse.",
|
||||
"all_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la All-Debrid",
|
||||
"all_debrid_account_linked": "Contul All-Debrid a fost conectat cu succes"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Descărcare completă",
|
||||
|
|
|
@ -48,6 +48,7 @@ import "./user-preferences/auto-launch";
|
|||
import "./autoupdater/check-for-updates";
|
||||
import "./autoupdater/restart-and-install-update";
|
||||
import "./user-preferences/authenticate-real-debrid";
|
||||
import "./user-preferences/authenticate-all-debrid";
|
||||
import "./user-preferences/authenticate-torbox";
|
||||
import "./download-sources/put-download-source";
|
||||
import "./auth/sign-out";
|
||||
|
|
18
src/main/events/user-preferences/authenticate-all-debrid.ts
Normal file
18
src/main/events/user-preferences/authenticate-all-debrid.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { AllDebridClient } from "@main/services/download/all-debrid";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const authenticateAllDebrid = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
apiKey: string
|
||||
) => {
|
||||
AllDebridClient.authorize(apiKey);
|
||||
const result = await AllDebridClient.getUser();
|
||||
|
||||
if ("error_code" in result) {
|
||||
return { error_code: result.error_code };
|
||||
}
|
||||
|
||||
return result.user;
|
||||
};
|
||||
|
||||
registerEvent("authenticateAllDebrid", authenticateAllDebrid);
|
|
@ -15,6 +15,12 @@ const getUserPreferences = async () =>
|
|||
);
|
||||
}
|
||||
|
||||
if (userPreferences?.allDebridApiKey) {
|
||||
userPreferences.allDebridApiKey = Crypto.decrypt(
|
||||
userPreferences.allDebridApiKey
|
||||
);
|
||||
}
|
||||
|
||||
if (userPreferences?.torBoxApiToken) {
|
||||
userPreferences.torBoxApiToken = Crypto.decrypt(
|
||||
userPreferences.torBoxApiToken
|
||||
|
|
|
@ -30,6 +30,10 @@ const updateUserPreferences = async (
|
|||
);
|
||||
}
|
||||
|
||||
if (preferences.allDebridApiKey) {
|
||||
preferences.allDebridApiKey = Crypto.encrypt(preferences.allDebridApiKey);
|
||||
}
|
||||
|
||||
if (preferences.torBoxApiToken) {
|
||||
preferences.torBoxApiToken = Crypto.encrypt(preferences.torBoxApiToken);
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
startMainLoop,
|
||||
} from "./services";
|
||||
import { RealDebridClient } from "./services/download/real-debrid";
|
||||
import { AllDebridClient } from "./services/download/all-debrid";
|
||||
import { HydraApi } from "./services/hydra-api";
|
||||
import { uploadGamesBatch } from "./services/library-sync";
|
||||
import { Aria2 } from "./services/aria2";
|
||||
|
@ -43,6 +44,10 @@ export const loadState = async () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (userPreferences?.allDebridApiKey) {
|
||||
AllDebridClient.authorize(Crypto.decrypt(userPreferences.allDebridApiKey));
|
||||
}
|
||||
|
||||
if (userPreferences?.torBoxApiToken) {
|
||||
TorBoxClient.authorize(Crypto.decrypt(userPreferences.torBoxApiToken));
|
||||
}
|
||||
|
@ -117,7 +122,8 @@ const migrateFromSqlite = async () => {
|
|||
.select("*")
|
||||
.then(async (userPreferences) => {
|
||||
if (userPreferences.length > 0) {
|
||||
const { realDebridApiToken, ...rest } = userPreferences[0];
|
||||
const { realDebridApiToken, allDebridApiKey, ...rest } =
|
||||
userPreferences[0];
|
||||
|
||||
await db.put<string, UserPreferences>(
|
||||
levelKeys.userPreferences,
|
||||
|
@ -126,6 +132,9 @@ const migrateFromSqlite = async () => {
|
|||
realDebridApiToken: realDebridApiToken
|
||||
? Crypto.encrypt(realDebridApiToken)
|
||||
: null,
|
||||
allDebridApiKey: allDebridApiKey
|
||||
? Crypto.encrypt(allDebridApiKey)
|
||||
: null,
|
||||
preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1,
|
||||
runAtStartup: rest.runAtStartup === 1,
|
||||
startMinimized: rest.startMinimized === 1,
|
||||
|
|
313
src/main/services/download/all-debrid.ts
Normal file
313
src/main/services/download/all-debrid.ts
Normal file
|
@ -0,0 +1,313 @@
|
|||
import axios, { AxiosInstance } from "axios";
|
||||
import type { AllDebridUser } from "@types";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
interface AllDebridMagnetStatus {
|
||||
id: number;
|
||||
filename: string;
|
||||
size: number;
|
||||
status: string;
|
||||
statusCode: number;
|
||||
downloaded: number;
|
||||
uploaded: number;
|
||||
seeders: number;
|
||||
downloadSpeed: number;
|
||||
uploadSpeed: number;
|
||||
uploadDate: number;
|
||||
completionDate: number;
|
||||
links: Array<{
|
||||
link: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface AllDebridError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AllDebridDownloadUrl {
|
||||
link: string;
|
||||
size?: number;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export class AllDebridClient {
|
||||
private static instance: AxiosInstance;
|
||||
private static readonly baseURL = "https://api.alldebrid.com/v4";
|
||||
|
||||
static authorize(apiKey: string) {
|
||||
logger.info("[AllDebrid] Authorizing with key:", apiKey ? "***" : "empty");
|
||||
this.instance = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
params: {
|
||||
agent: "hydra",
|
||||
apikey: apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async getUser() {
|
||||
try {
|
||||
const response = await this.instance.get<{
|
||||
status: string;
|
||||
data?: { user: AllDebridUser };
|
||||
error?: AllDebridError;
|
||||
}>("/user");
|
||||
|
||||
logger.info("[AllDebrid] API Response:", response.data);
|
||||
|
||||
if (response.data.status === "error") {
|
||||
const error = response.data.error;
|
||||
logger.error("[AllDebrid] API Error:", error);
|
||||
if (error?.code === "AUTH_MISSING_APIKEY") {
|
||||
return { error_code: "alldebrid_missing_key" };
|
||||
}
|
||||
if (error?.code === "AUTH_BAD_APIKEY") {
|
||||
return { error_code: "alldebrid_invalid_key" };
|
||||
}
|
||||
if (error?.code === "AUTH_BLOCKED") {
|
||||
return { error_code: "alldebrid_blocked" };
|
||||
}
|
||||
if (error?.code === "AUTH_USER_BANNED") {
|
||||
return { error_code: "alldebrid_banned" };
|
||||
}
|
||||
return { error_code: "alldebrid_unknown_error" };
|
||||
}
|
||||
|
||||
if (!response.data.data?.user) {
|
||||
logger.error("[AllDebrid] No user data in response");
|
||||
return { error_code: "alldebrid_invalid_response" };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Successfully got user:",
|
||||
response.data.data.user.username
|
||||
);
|
||||
return { user: response.data.data.user };
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Request Error:", error);
|
||||
if (error.response?.data?.error) {
|
||||
return { error_code: "alldebrid_invalid_key" };
|
||||
}
|
||||
return { error_code: "alldebrid_network_error" };
|
||||
}
|
||||
}
|
||||
|
||||
private static async uploadMagnet(magnet: string) {
|
||||
try {
|
||||
logger.info("[AllDebrid] Uploading magnet with params:", { magnet });
|
||||
|
||||
const response = await this.instance.get("/magnet/upload", {
|
||||
params: {
|
||||
magnets: [magnet],
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Upload Magnet Raw Response:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
const magnetInfo = response.data.data.magnets[0];
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet Info:",
|
||||
JSON.stringify(magnetInfo, null, 2)
|
||||
);
|
||||
|
||||
if (magnetInfo.error) {
|
||||
throw new Error(magnetInfo.error.message);
|
||||
}
|
||||
|
||||
return magnetInfo.id;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Upload Magnet Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async checkMagnetStatus(
|
||||
magnetId: number
|
||||
): Promise<AllDebridMagnetStatus> {
|
||||
try {
|
||||
logger.info("[AllDebrid] Checking magnet status for ID:", magnetId);
|
||||
|
||||
const response = await this.instance.get(`/magnet/status`, {
|
||||
params: {
|
||||
id: magnetId,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Check Magnet Status Raw Response:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("No response data received");
|
||||
}
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
// Verificăm noua structură a răspunsului
|
||||
const magnetData = response.data.data?.magnets;
|
||||
if (!magnetData || typeof magnetData !== "object") {
|
||||
logger.error(
|
||||
"[AllDebrid] Invalid response structure:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
throw new Error("Invalid magnet status response format");
|
||||
}
|
||||
|
||||
// Convertim răspunsul în formatul așteptat
|
||||
const magnetStatus: AllDebridMagnetStatus = {
|
||||
id: magnetData.id,
|
||||
filename: magnetData.filename,
|
||||
size: magnetData.size,
|
||||
status: magnetData.status,
|
||||
statusCode: magnetData.statusCode,
|
||||
downloaded: magnetData.downloaded,
|
||||
uploaded: magnetData.uploaded,
|
||||
seeders: magnetData.seeders,
|
||||
downloadSpeed: magnetData.downloadSpeed,
|
||||
uploadSpeed: magnetData.uploadSpeed,
|
||||
uploadDate: magnetData.uploadDate,
|
||||
completionDate: magnetData.completionDate,
|
||||
links: magnetData.links.map((link) => ({
|
||||
link: link.link,
|
||||
filename: link.filename,
|
||||
size: link.size,
|
||||
})),
|
||||
};
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet Status:",
|
||||
JSON.stringify(magnetStatus, null, 2)
|
||||
);
|
||||
|
||||
return magnetStatus;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Check Magnet Status Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async unlockLink(link: string) {
|
||||
try {
|
||||
const response = await this.instance.get<{
|
||||
status: string;
|
||||
data?: { link: string };
|
||||
error?: AllDebridError;
|
||||
}>("/link/unlock", {
|
||||
params: {
|
||||
link,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
const unlockedLink = response.data.data?.link;
|
||||
if (!unlockedLink) {
|
||||
throw new Error("No download link received from AllDebrid");
|
||||
}
|
||||
|
||||
return unlockedLink;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Unlock Link Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getDownloadUrls(
|
||||
uri: string
|
||||
): Promise<AllDebridDownloadUrl[]> {
|
||||
try {
|
||||
logger.info("[AllDebrid] Getting download URLs for URI:", uri);
|
||||
|
||||
if (uri.startsWith("magnet:")) {
|
||||
logger.info("[AllDebrid] Detected magnet link, uploading...");
|
||||
// 1. Upload magnet
|
||||
const magnetId = await this.uploadMagnet(uri);
|
||||
logger.info("[AllDebrid] Magnet uploaded, ID:", magnetId);
|
||||
|
||||
// 2. Verificăm statusul până când avem link-uri
|
||||
let retries = 0;
|
||||
let magnetStatus: AllDebridMagnetStatus;
|
||||
|
||||
do {
|
||||
magnetStatus = await this.checkMagnetStatus(magnetId);
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet status:",
|
||||
magnetStatus.status,
|
||||
"statusCode:",
|
||||
magnetStatus.statusCode
|
||||
);
|
||||
|
||||
if (magnetStatus.statusCode === 4) {
|
||||
// Ready
|
||||
// Deblocăm fiecare link în parte și aruncăm eroare dacă oricare eșuează
|
||||
const unlockedLinks = await Promise.all(
|
||||
magnetStatus.links.map(async (link) => {
|
||||
try {
|
||||
const unlockedLink = await this.unlockLink(link.link);
|
||||
logger.info(
|
||||
"[AllDebrid] Successfully unlocked link:",
|
||||
unlockedLink
|
||||
);
|
||||
return {
|
||||
link: unlockedLink,
|
||||
size: link.size,
|
||||
filename: link.filename,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[AllDebrid] Failed to unlock link:",
|
||||
link.link,
|
||||
error
|
||||
);
|
||||
throw new Error("Failed to unlock all links");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Got unlocked download links:",
|
||||
unlockedLinks
|
||||
);
|
||||
return unlockedLinks;
|
||||
}
|
||||
|
||||
if (retries++ > 30) {
|
||||
// Maximum 30 de încercări
|
||||
throw new Error("Timeout waiting for magnet to be ready");
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // Așteptăm 2 secunde între verificări
|
||||
} while (magnetStatus.statusCode !== 4);
|
||||
} else {
|
||||
logger.info("[AllDebrid] Regular link, unlocking...");
|
||||
// Pentru link-uri normale, doar debridam link-ul
|
||||
const downloadUrl = await this.unlockLink(uri);
|
||||
logger.info("[AllDebrid] Got unlocked download URL:", downloadUrl);
|
||||
return [
|
||||
{
|
||||
link: downloadUrl,
|
||||
},
|
||||
];
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Get Download URLs Error:", error);
|
||||
throw error;
|
||||
}
|
||||
return []; // Add default return for TypeScript
|
||||
}
|
||||
}
|
|
@ -16,6 +16,7 @@ import { logger } from "../logger";
|
|||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { TorBoxClient } from "./torbox";
|
||||
import { AllDebridClient } from "./all-debrid";
|
||||
|
||||
export class DownloadManager {
|
||||
private static downloadingGameId: string | null = null;
|
||||
|
@ -32,6 +33,7 @@ export class DownloadManager {
|
|||
})
|
||||
: undefined,
|
||||
downloadsToSeed?.map((download) => ({
|
||||
action: "seed",
|
||||
game_id: levelKeys.game(download.shop, download.objectId),
|
||||
url: download.uri,
|
||||
save_path: download.downloadPath,
|
||||
|
@ -314,6 +316,27 @@ export class DownloadManager {
|
|||
save_path: download.downloadPath,
|
||||
};
|
||||
}
|
||||
case Downloader.AllDebrid: {
|
||||
const downloadUrls = await AllDebridClient.getDownloadUrls(
|
||||
download.uri
|
||||
);
|
||||
|
||||
if (!downloadUrls.length)
|
||||
throw new Error(DownloadError.NotCachedInAllDebrid);
|
||||
|
||||
const totalSize = downloadUrls.reduce(
|
||||
(total, url) => total + (url.size || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
url: downloadUrls.map((d) => d.link),
|
||||
save_path: download.downloadPath,
|
||||
total_size: totalSize,
|
||||
};
|
||||
}
|
||||
case Downloader.Torrent:
|
||||
return {
|
||||
action: "start",
|
||||
|
|
|
@ -17,17 +17,24 @@ export const calculateETA = (
|
|||
};
|
||||
|
||||
export const getDirSize = async (dir: string): Promise<number> => {
|
||||
const getItemSize = async (filePath: string): Promise<number> => {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
try {
|
||||
const stat = await fs.promises.stat(dir);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return getDirSize(filePath);
|
||||
// If it's a file, return its size directly
|
||||
if (!stat.isDirectory()) {
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
return stat.size;
|
||||
};
|
||||
const getItemSize = async (filePath: string): Promise<number> => {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return getDirSize(filePath);
|
||||
}
|
||||
|
||||
return stat.size;
|
||||
};
|
||||
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const filePaths = files.map((file) => path.join(dir, file));
|
||||
const sizes = await Promise.all(filePaths.map(getItemSize));
|
||||
|
|
|
@ -10,9 +10,13 @@ import { Readable } from "node:stream";
|
|||
import { app, dialog } from "electron";
|
||||
|
||||
interface GamePayload {
|
||||
action: string;
|
||||
game_id: string;
|
||||
url: string;
|
||||
url: string | string[];
|
||||
save_path: string;
|
||||
header?: string;
|
||||
out?: string;
|
||||
total_size?: number;
|
||||
}
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
|
|
|
@ -92,6 +92,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||
ipcRenderer.invoke("autoLaunch", autoLaunchProps),
|
||||
authenticateRealDebrid: (apiToken: string) =>
|
||||
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
|
||||
authenticateAllDebrid: (apiKey: string) =>
|
||||
ipcRenderer.invoke("authenticateAllDebrid", apiKey),
|
||||
authenticateTorBox: (apiToken: string) =>
|
||||
ipcRenderer.invoke("authenticateTorBox", apiToken),
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ export const DOWNLOADER_NAME = {
|
|||
[Downloader.Datanodes]: "Datanodes",
|
||||
[Downloader.Mediafire]: "Mediafire",
|
||||
[Downloader.TorBox]: "TorBox",
|
||||
[Downloader.AllDebrid]: "All-Debrid",
|
||||
};
|
||||
|
||||
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
|
4
src/renderer/src/declaration.d.ts
vendored
4
src/renderer/src/declaration.d.ts
vendored
|
@ -29,6 +29,7 @@ import type {
|
|||
LibraryGame,
|
||||
GameRunning,
|
||||
TorBoxUser,
|
||||
AllDebridUser,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type disk from "diskusage";
|
||||
|
@ -150,6 +151,9 @@ declare global {
|
|||
minimized: boolean;
|
||||
}) => Promise<void>;
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
authenticateAllDebrid: (
|
||||
apiKey: string
|
||||
) => Promise<AllDebridUser | { error_code: string }>;
|
||||
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
|
||||
onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
|
|
|
@ -112,6 +112,15 @@ export function DownloadGroup({
|
|||
);
|
||||
}
|
||||
|
||||
if (download.downloader === Downloader.AllDebrid) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
<p>{t("alldebrid_size_not_supported")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
|
@ -154,6 +163,15 @@ export function DownloadGroup({
|
|||
}
|
||||
|
||||
if (download.status === "active") {
|
||||
if (download.downloader === Downloader.AllDebrid) {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
<p>{t("alldebrid_size_not_supported")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
|
@ -240,7 +258,9 @@ export function DownloadGroup({
|
|||
(download?.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(download?.downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken);
|
||||
!userPreferences?.torBoxApiToken) ||
|
||||
(download?.downloader === Downloader.AllDebrid &&
|
||||
!userPreferences?.allDebridApiKey);
|
||||
|
||||
return [
|
||||
{
|
||||
|
|
|
@ -196,7 +196,6 @@ export function HeroPanelActions() {
|
|||
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
onClick={() => setShowGameOptionsModal(true)}
|
||||
theme="outline"
|
||||
|
|
|
@ -87,6 +87,8 @@ export function DownloadSettingsModal({
|
|||
return userPreferences?.realDebridApiToken;
|
||||
if (downloader === Downloader.TorBox)
|
||||
return userPreferences?.torBoxApiToken;
|
||||
if (downloader === Downloader.AllDebrid)
|
||||
return userPreferences?.allDebridApiKey;
|
||||
return true;
|
||||
});
|
||||
|
||||
|
@ -100,6 +102,8 @@ export function DownloadSettingsModal({
|
|||
userPreferences?.downloadsPath,
|
||||
downloaders,
|
||||
userPreferences?.realDebridApiToken,
|
||||
userPreferences?.torBoxApiToken,
|
||||
userPreferences?.allDebridApiKey,
|
||||
]);
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
|
@ -163,8 +167,12 @@ export function DownloadSettingsModal({
|
|||
selectedDownloader === downloader ? "primary" : "outline"
|
||||
}
|
||||
disabled={
|
||||
downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken
|
||||
(downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(downloader === Downloader.AllDebrid &&
|
||||
!userPreferences?.allDebridApiKey) ||
|
||||
(downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken)
|
||||
}
|
||||
onClick={() => setSelectedDownloader(downloader)}
|
||||
>
|
||||
|
|
12
src/renderer/src/pages/settings/settings-all-debrid.scss
Normal file
12
src/renderer/src/pages/settings/settings-all-debrid.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
.settings-all-debrid {
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
129
src/renderer/src/pages/settings/settings-all-debrid.tsx
Normal file
129
src/renderer/src/pages/settings/settings-all-debrid.tsx
Normal file
|
@ -0,0 +1,129 @@
|
|||
import { useContext, useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
|
||||
import "./settings-all-debrid.scss";
|
||||
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
|
||||
import { settingsContext } from "@renderer/context";
|
||||
|
||||
const ALL_DEBRID_API_TOKEN_URL = "https://alldebrid.com/apikeys";
|
||||
|
||||
export function SettingsAllDebrid() {
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const { updateUserPreferences } = useContext(settingsContext);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
useAllDebrid: false,
|
||||
allDebridApiKey: null as string | null,
|
||||
});
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences) {
|
||||
setForm({
|
||||
useAllDebrid: Boolean(userPreferences.allDebridApiKey),
|
||||
allDebridApiKey: userPreferences.allDebridApiKey ?? null,
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = async (
|
||||
event
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
if (form.useAllDebrid) {
|
||||
if (!form.allDebridApiKey) {
|
||||
showErrorToast(t("alldebrid_missing_key"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electron.authenticateAllDebrid(
|
||||
form.allDebridApiKey
|
||||
);
|
||||
|
||||
if ("error_code" in result) {
|
||||
showErrorToast(t(result.error_code));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.isPremium) {
|
||||
showErrorToast(
|
||||
t("all_debrid_free_account_error", { username: result.username })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessToast(
|
||||
t("all_debrid_account_linked"),
|
||||
t("debrid_linked_message", { username: result.username })
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(t("changes_saved"));
|
||||
}
|
||||
|
||||
updateUserPreferences({
|
||||
allDebridApiKey: form.useAllDebrid ? form.allDebridApiKey : null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
showErrorToast(t("alldebrid_unknown_error"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled =
|
||||
(form.useAllDebrid && !form.allDebridApiKey) || isLoading;
|
||||
|
||||
return (
|
||||
<form className="settings-all-debrid__form" onSubmit={handleFormSubmit}>
|
||||
<p className="settings-all-debrid__description">
|
||||
{t("all_debrid_description")}
|
||||
</p>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_all_debrid")}
|
||||
checked={form.useAllDebrid}
|
||||
onChange={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
useAllDebrid: !form.useAllDebrid,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
{form.useAllDebrid && (
|
||||
<TextField
|
||||
label={t("api_token")}
|
||||
value={form.allDebridApiKey ?? ""}
|
||||
type="password"
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, allDebridApiKey: event.target.value })
|
||||
}
|
||||
rightContent={
|
||||
<Button type="submit" disabled={isButtonDisabled}>
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
}
|
||||
placeholder="API Key"
|
||||
hint={
|
||||
<Trans i18nKey="debrid_api_token_hint" ns="settings">
|
||||
<Link to={ALL_DEBRID_API_TOKEN_URL} />
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { Button } from "@renderer/components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||
import { SettingsAllDebrid } from "./settings-all-debrid";
|
||||
import { SettingsGeneral } from "./settings-general";
|
||||
import { SettingsBehavior } from "./settings-behavior";
|
||||
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
|
||||
|
@ -35,6 +36,7 @@ export default function Settings() {
|
|||
contentTitle: "TorBox",
|
||||
},
|
||||
{ tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
|
||||
{ tabLabel: "All-Debrid", contentTitle: "All-Debrid" },
|
||||
];
|
||||
|
||||
if (userDetails)
|
||||
|
@ -70,6 +72,10 @@ export default function Settings() {
|
|||
return <SettingsRealDebrid />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 5) {
|
||||
return <SettingsAllDebrid />;
|
||||
}
|
||||
|
||||
return <SettingsAccount />;
|
||||
};
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ export enum Downloader {
|
|||
Datanodes,
|
||||
Mediafire,
|
||||
TorBox,
|
||||
AllDebrid,
|
||||
}
|
||||
|
||||
export enum DownloadSourceStatus {
|
||||
|
@ -54,6 +55,7 @@ export enum AuthPage {
|
|||
export enum DownloadError {
|
||||
NotCachedInRealDebrid = "download_error_not_cached_in_real_debrid",
|
||||
NotCachedInTorbox = "download_error_not_cached_in_torbox",
|
||||
NotCachedInAllDebrid = "download_error_not_cached_in_alldebrid",
|
||||
GofileQuotaExceeded = "download_error_gofile_quota_exceeded",
|
||||
RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized",
|
||||
}
|
||||
|
|
|
@ -95,7 +95,12 @@ export const getDownloadersForUri = (uri: string) => {
|
|||
return [Downloader.RealDebrid];
|
||||
|
||||
if (uri.startsWith("magnet:")) {
|
||||
return [Downloader.Torrent, Downloader.TorBox, Downloader.RealDebrid];
|
||||
return [
|
||||
Downloader.Torrent,
|
||||
Downloader.TorBox,
|
||||
Downloader.RealDebrid,
|
||||
Downloader.AllDebrid,
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
|
|
|
@ -174,3 +174,11 @@ export interface SeedingStatus {
|
|||
status: DownloadStatus;
|
||||
uploadSpeed: number;
|
||||
}
|
||||
|
||||
/* All-Debrid */
|
||||
export interface AllDebridUser {
|
||||
username: string;
|
||||
email: string;
|
||||
isPremium: boolean;
|
||||
premiumUntil: string;
|
||||
}
|
||||
|
|
|
@ -70,6 +70,7 @@ export interface UserPreferences {
|
|||
downloadsPath?: string | null;
|
||||
language?: string;
|
||||
realDebridApiToken?: string | null;
|
||||
allDebridApiKey?: string | null;
|
||||
torBoxApiToken?: string | null;
|
||||
preferQuitInsteadOfHiding?: boolean;
|
||||
runAtStartup?: boolean;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue