diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py index 71e4b57e..ed7d31c3 100644 --- a/python_rpc/http_downloader.py +++ b/python_rpc/http_downloader.py @@ -1,48 +1,162 @@ import aria2p +from typing import Union, List +import logging +import os +from pathlib import Path +from aria2p import API, Client, Download +import requests + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) class HttpDownloader: def __init__(self): - self.download = None - self.aria2 = aria2p.API( - aria2p.Client( - host="http://localhost", - port=6800, - secret="" - ) - ) + self.downloads = [] # vom păstra toate download-urile active + self.aria2 = API(Client(host="http://localhost", port=6800)) + self.download = None # pentru compatibilitate cu codul vechi - def start_download(self, url: str, save_path: str, header: str, out: str = None): - if self.download: - self.aria2.resume([self.download]) - else: - downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out}) + def unlock_alldebrid_link(self, link: str) -> str: + """Deblochează un link AllDebrid și returnează link-ul real de descărcare.""" + api_key = os.getenv('ALLDEBRID_API_KEY') + if not api_key: + logger.error("AllDebrid API key nu a fost găsită în variabilele de mediu") + return link + + try: + response = requests.post( + "https://api.alldebrid.com/v4/link/unlock", + params={ + "agent": "hydra", + "apikey": api_key, + "link": link + } + ) + data = response.json() - self.download = downloads[0] + if data.get("status") == "success": + return data["data"]["link"] + else: + logger.error(f"Eroare la deblocarea link-ului AllDebrid: {data.get('error', {}).get('message', 'Unknown error')}") + return link + except Exception as e: + logger.error(f"Eroare la apelul API AllDebrid: {str(e)}") + return link + + def start_download(self, url: Union[str, List[str]], save_path: str, header: str = None, out: str = None): + logger.info(f"Starting download with URL: {url}, save_path: {save_path}, header: {header}, out: {out}") + + # Pentru AllDebrid care returnează un link per fișier + if isinstance(url, list): + logger.info(f"Multiple URLs detected: {len(url)} files to download") + self.downloads = [] + + # Deblocăm toate link-urile AllDebrid + unlocked_urls = [] + for single_url in url: + logger.info(f"Unlocking AllDebrid URL: {single_url}") + unlocked_url = self.unlock_alldebrid_link(single_url) + if unlocked_url: + unlocked_urls.append(unlocked_url) + logger.info(f"URL deblocat cu succes: {unlocked_url}") + + # Descărcăm folosind link-urile deblocate + for unlocked_url in unlocked_urls: + logger.info(f"Adding download for unlocked URL: {unlocked_url}") + options = { + "dir": save_path + } + if header: + if isinstance(header, list): + options["header"] = header + else: + options["header"] = [header] + + try: + download = self.aria2.add_uris([unlocked_url], options=options) + logger.info(f"Download added successfully: {download.gid}") + self.downloads.append(download) + except Exception as e: + logger.error(f"Error adding download for URL {unlocked_url}: {str(e)}") + + if self.downloads: + self.download = self.downloads[0] # păstrăm primul pentru referință + else: + logger.error("No downloads were successfully added!") + + # Pentru RealDebrid/alte servicii care returnează un singur link pentru tot + else: + logger.info(f"Single URL download: {url}") + options = { + "dir": save_path + } + if header: + if isinstance(header, list): + options["header"] = header + else: + options["header"] = [header] + if out: + options["out"] = out + + try: + download = self.aria2.add_uris([url], options=options) + self.download = download + self.downloads = [self.download] + logger.info(f"Single download added successfully: {self.download.gid}") + except Exception as e: + logger.error(f"Error adding single download: {str(e)}") def pause_download(self): - if self.download: - self.aria2.pause([self.download]) + try: + for download in self.downloads: + download.pause() + except Exception as e: + logger.error(f"Error pausing downloads: {str(e)}") def cancel_download(self): - if self.download: - self.aria2.remove([self.download]) - self.download = None + try: + for download in self.downloads: + download.remove() + except Exception as e: + logger.error(f"Error canceling downloads: {str(e)}") def get_download_status(self): - if self.download == None: + try: + if not self.downloads: + return None + + total_size = 0 + downloaded = 0 + download_speed = 0 + active_downloads = [] + + for download in self.downloads: + try: + download.update() + if download.is_active: + active_downloads.append(download) + total_size += download.total_length + downloaded += download.completed_length + download_speed += download.download_speed + except Exception as e: + logger.error(f"Error updating download status for {download.gid}: {str(e)}") + + if not active_downloads: + return None + + # Folosim primul download pentru numele folderului + folder_path = os.path.dirname(active_downloads[0].files[0].path) + folder_name = os.path.basename(folder_path) + + return { + "progress": downloaded / total_size if total_size > 0 else 0, + "numPeers": 0, # nu este relevant pentru HTTP + "numSeeds": 0, # nu este relevant pentru HTTP + "downloadSpeed": download_speed, + "bytesDownloaded": downloaded, + "fileSize": total_size, + "folderName": folder_name, + "status": "downloading" + } + except Exception as e: + logger.error(f"Error getting download status: {str(e)}") return None - - download = self.aria2.get_download(self.download.gid) - - response = { - 'folderName': 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 index 2deb2029..efedc5c9 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -23,19 +23,27 @@ torrent_session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port= if start_download_payload: initial_download = json.loads(urllib.parse.unquote(start_download_payload)) downloading_game_id = initial_download['game_id'] + url = initial_download['url'] - if initial_download['url'].startswith('magnet'): + # Verificăm dacă avem un URL de tip magnet (fie direct, fie primul dintr-o listă) + is_magnet = False + if isinstance(url, str): + is_magnet = url.startswith('magnet') + elif isinstance(url, list) and url: + is_magnet = False # Pentru AllDebrid, chiar dacă vine dintr-un magnet, primim HTTP links + + if is_magnet: torrent_downloader = TorrentDownloader(torrent_session) downloads[initial_download['game_id']] = torrent_downloader try: - torrent_downloader.start_download(initial_download['url'], initial_download['save_path']) + torrent_downloader.start_download(url, initial_download['save_path']) except Exception as e: print("Error starting torrent download", e) else: http_downloader = HttpDownloader() downloads[initial_download['game_id']] = http_downloader try: - http_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get("out")) + http_downloader.start_download(url, initial_download['save_path'], initial_download.get('header'), initial_download.get("out")) except Exception as e: print("Error starting http download", e) @@ -135,10 +143,18 @@ def action(): if action == 'start': url = data.get('url') + print(f"Starting download with URL: {url}") existing_downloader = downloads.get(game_id) - if url.startswith('magnet'): + # Verificăm dacă avem un URL de tip magnet (fie direct, fie primul dintr-o listă) + is_magnet = False + if isinstance(url, str): + is_magnet = url.startswith('magnet') + elif isinstance(url, list) and url: + is_magnet = False # Pentru AllDebrid, chiar dacă vine dintr-un magnet, primim HTTP links + + if is_magnet: if existing_downloader and isinstance(existing_downloader, TorrentDownloader): existing_downloader.start_download(url, data['save_path']) else: @@ -172,7 +188,6 @@ def action(): downloader = downloads.get(game_id) if downloader: downloader.cancel_download() - else: return jsonify({"error": "Invalid action"}), 400 diff --git a/src/main/services/download/all-debrid.ts b/src/main/services/download/all-debrid.ts index 864710b8..fc7b64a3 100644 --- a/src/main/services/download/all-debrid.ts +++ b/src/main/services/download/all-debrid.ts @@ -2,6 +2,31 @@ 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; +} + export class AllDebridClient { private static instance: AxiosInstance; private static readonly baseURL = "https://api.alldebrid.com/v4"; @@ -9,11 +34,11 @@ export class AllDebridClient { static authorize(apiKey: string) { logger.info("[AllDebrid] Authorizing with key:", apiKey ? "***" : "empty"); this.instance = axios.create({ - baseURL: this.baseURL, - params: { - agent: "hydra", - apikey: apiKey - } + baseURL: this.baseURL, + params: { + agent: "hydra", + apikey: apiKey + } }); } @@ -22,10 +47,7 @@ export class AllDebridClient { const response = await this.instance.get<{ status: string; data?: { user: AllDebridUser }; - error?: { - code: string; - message: string; - }; + error?: AllDebridError; }>("/user"); logger.info("[AllDebrid] API Response:", response.data); @@ -63,4 +85,175 @@ export class AllDebridClient { 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 { + 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 { + 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 unlockedLink; + } 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 (true); + } 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 [downloadUrl]; + } + } catch (error: any) { + logger.error("[AllDebrid] Get Download URLs Error:", error); + throw error; + } + } } diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 247d5c75..88c7ce1f 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -16,6 +16,8 @@ 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"; +import { spawn } from "child_process"; export class DownloadManager { private static downloadingGameId: string | null = null; @@ -333,6 +335,18 @@ 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); + + return { + action: "start", + game_id: downloadId, + url: downloadUrls, + save_path: download.downloadPath, + }; + } case Downloader.TorBox: { const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index 22e60461..38b4e81a 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -8,6 +8,8 @@ import crypto from "node:crypto"; import { pythonRpcLogger } from "./logger"; import { Readable } from "node:stream"; import { app, dialog } from "electron"; +import { db, levelKeys } from "@main/level"; +import type { UserPreferences } from "@types"; interface GamePayload { game_id: string; @@ -42,7 +44,7 @@ export class PythonRPC { readable.on("data", pythonRpcLogger.log); } - public static spawn( + public static async spawn( initialDownload?: GamePayload, initialSeeding?: GamePayload[] ) { @@ -54,6 +56,15 @@ export class PythonRPC { initialSeeding ? JSON.stringify(initialSeeding) : "", ]; + const userPreferences = await db.get(levelKeys.userPreferences, { + valueEncoding: "json", + }); + + const env = { + ...process.env, + ALLDEBRID_API_KEY: userPreferences?.allDebridApiKey || "" + }; + if (app.isPackaged) { const binaryName = binaryNameByPlatform[process.platform]!; const binaryPath = path.join( @@ -74,6 +85,7 @@ export class PythonRPC { const childProcess = cp.spawn(binaryPath, commonArgs, { windowsHide: true, stdio: ["inherit", "inherit"], + env }); this.logStderr(childProcess.stderr); @@ -90,6 +102,7 @@ export class PythonRPC { const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], { stdio: ["inherit", "inherit"], + env }); this.logStderr(childProcess.stderr); diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index 1d7aa1b1..c9c730ec 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -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; diff --git a/src/renderer/src/constants/downloader.ts b/src/renderer/src/constants/downloader.ts new file mode 100644 index 00000000..0f94d594 --- /dev/null +++ b/src/renderer/src/constants/downloader.ts @@ -0,0 +1,13 @@ +import { Downloader } from "@shared"; + +export const DOWNLOADER_NAME: Record = { + [Downloader.Gofile]: "Gofile", + [Downloader.PixelDrain]: "PixelDrain", + [Downloader.Qiwi]: "Qiwi", + [Downloader.Datanodes]: "Datanodes", + [Downloader.Mediafire]: "Mediafire", + [Downloader.Torrent]: "Torrent", + [Downloader.RealDebrid]: "Real-Debrid", + [Downloader.AllDebrid]: "All-Debrid", + [Downloader.TorBox]: "TorBox", +}; \ No newline at end of file diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index d5e568fb..86d7a97b 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -240,7 +240,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 [ { diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.scss b/src/renderer/src/pages/game-details/modals/download-settings-modal.scss index 1b7c51e8..0917b120 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.scss +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.scss @@ -27,6 +27,11 @@ &__downloader-option { position: relative; + padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 6); + text-align: left; + display: flex; + align-items: center; + min-width: 150px; &:only-child { grid-column: 1 / -1; @@ -36,6 +41,8 @@ &__downloader-icon { position: absolute; left: calc(globals.$spacing-unit * 2); + top: 50%; + transform: translateY(-50%); } &__path-error { diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index 214af1d1..6af0788f 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -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,10 @@ export function DownloadSettingsModal({ selectedDownloader === downloader ? "primary" : "outline" } disabled={ - downloader === Downloader.RealDebrid && - !userPreferences?.realDebridApiToken + (downloader === Downloader.RealDebrid && + !userPreferences?.realDebridApiToken) || + (downloader === Downloader.AllDebrid && + !userPreferences?.allDebridApiKey) } onClick={() => setSelectedDownloader(downloader)} > diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 550c1097..979b2927 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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", } diff --git a/src/shared/index.ts b/src/shared/index.ts index 01d7cb06..7af5c7f2 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -95,7 +95,7 @@ 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 []; diff --git a/src/types/download.types.ts b/src/types/download.types.ts index 7f3ef442..9550627a 100644 --- a/src/types/download.types.ts +++ b/src/types/download.types.ts @@ -182,3 +182,30 @@ export interface AllDebridUser { isPremium: boolean; premiumUntil: string; } + +export enum Downloader { + Gofile = "gofile", + PixelDrain = "pixeldrain", + Qiwi = "qiwi", + Datanodes = "datanodes", + Mediafire = "mediafire", + Torrent = "torrent", + RealDebrid = "realdebrid", + AllDebrid = "alldebrid", + TorBox = "torbox", +} + +export enum DownloadError { + NotCachedInRealDebrid = "not_cached_in_realdebrid", + NotCachedInAllDebrid = "not_cached_in_alldebrid", + // ... alte erori existente +} + +export interface GamePayload { + action: string; + game_id: string; + url: string | string[]; // Modificăm pentru a accepta și array de URL-uri + save_path: string; + header?: string; + out?: string; +}