From 2a4221e7879ca432b4dadb61066e34ab7e783cb2 Mon Sep 17 00:00:00 2001 From: mircea32000 <36380975+mircea32000@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:28:35 +0200 Subject: [PATCH 01/11] All-debrid API key + ui Adds ui changes for alldebrid implementation, checks the api key and saves it with encryption like the real debrid implementation --- .env.example | 2 - src/locales/en/translation.json | 13 +- src/locales/ro/translation.json | 6 +- src/main/events/index.ts | 1 + .../authenticate-all-debrid.ts | 18 +++ .../user-preferences/get-user-preferences.ts | 6 + .../update-user-preferences.ts | 6 + src/main/main.ts | 16 ++- src/main/services/download/all-debrid.ts | 66 +++++++++ src/preload/index.ts | 2 + .../pages/settings/settings-all-debrid.scss | 12 ++ .../pages/settings/settings-all-debrid.tsx | 129 ++++++++++++++++++ src/renderer/src/pages/settings/settings.tsx | 6 + src/types/download.types.ts | 8 ++ src/types/level.types.ts | 1 + 15 files changed, 286 insertions(+), 6 deletions(-) delete mode 100644 .env.example create mode 100644 src/main/events/user-preferences/authenticate-all-debrid.ts create mode 100644 src/main/services/download/all-debrid.ts create mode 100644 src/renderer/src/pages/settings/settings-all-debrid.scss create mode 100644 src/renderer/src/pages/settings/settings-all-debrid.tsx diff --git a/.env.example b/.env.example deleted file mode 100644 index 3ef399f73..000000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -MAIN_VITE_API_URL=API_URL -MAIN_VITE_AUTH_URL=AUTH_URL diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 94b52c757..c71f263b1 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -306,7 +306,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", diff --git a/src/locales/ro/translation.json b/src/locales/ro/translation.json index 8a5403b5a..0815cc886 100644 --- a/src/locales/ro/translation.json +++ b/src/locales/ro/translation.json @@ -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ă", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index dc64b40e3..b3eafdc17 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -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"; diff --git a/src/main/events/user-preferences/authenticate-all-debrid.ts b/src/main/events/user-preferences/authenticate-all-debrid.ts new file mode 100644 index 000000000..6e153fe5e --- /dev/null +++ b/src/main/events/user-preferences/authenticate-all-debrid.ts @@ -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); \ No newline at end of file diff --git a/src/main/events/user-preferences/get-user-preferences.ts b/src/main/events/user-preferences/get-user-preferences.ts index c67f72b97..5dd3d57c2 100644 --- a/src/main/events/user-preferences/get-user-preferences.ts +++ b/src/main/events/user-preferences/get-user-preferences.ts @@ -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 diff --git a/src/main/events/user-preferences/update-user-preferences.ts b/src/main/events/user-preferences/update-user-preferences.ts index 275a6f276..90a2d56cb 100644 --- a/src/main/events/user-preferences/update-user-preferences.ts +++ b/src/main/events/user-preferences/update-user-preferences.ts @@ -30,6 +30,12 @@ const updateUserPreferences = async ( ); } + if (preferences.allDebridApiKey) { + preferences.allDebridApiKey = Crypto.encrypt( + preferences.allDebridApiKey + ); + } + if (preferences.torBoxApiToken) { preferences.torBoxApiToken = Crypto.encrypt(preferences.torBoxApiToken); } diff --git a/src/main/main.ts b/src/main/main.ts index 4824a1a57..e09c473b9 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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,8 +44,16 @@ export const loadState = async () => { ); } + if (userPreferences?.allDebridApiKey) { + AllDebridClient.authorize( + Crypto.decrypt(userPreferences.allDebridApiKey) + ); + } + if (userPreferences?.torBoxApiToken) { - TorBoxClient.authorize(Crypto.decrypt(userPreferences.torBoxApiToken)); + TorBoxClient.authorize( + Crypto.decrypt(userPreferences.torBoxApiToken) + ); } Ludusavi.addManifestToLudusaviConfig(); @@ -117,7 +126,7 @@ 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( levelKeys.userPreferences, @@ -126,6 +135,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, diff --git a/src/main/services/download/all-debrid.ts b/src/main/services/download/all-debrid.ts new file mode 100644 index 000000000..864710b80 --- /dev/null +++ b/src/main/services/download/all-debrid.ts @@ -0,0 +1,66 @@ +import axios, { AxiosInstance } from "axios"; +import type { AllDebridUser } from "@types"; +import { logger } from "@main/services"; + +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?: { + code: string; + message: string; + }; + }>("/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" }; + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index ef61cbb90..a6cdcf053 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/pages/settings/settings-all-debrid.scss b/src/renderer/src/pages/settings/settings-all-debrid.scss new file mode 100644 index 000000000..5efe1e66e --- /dev/null +++ b/src/renderer/src/pages/settings/settings-all-debrid.scss @@ -0,0 +1,12 @@ +.settings-all-debrid { + &__form { + display: flex; + flex-direction: column; + gap: 1rem; + } + + &__description { + margin: 0; + color: var(--text-secondary); + } +} \ No newline at end of file diff --git a/src/renderer/src/pages/settings/settings-all-debrid.tsx b/src/renderer/src/pages/settings/settings-all-debrid.tsx new file mode 100644 index 000000000..92b63404a --- /dev/null +++ b/src/renderer/src/pages/settings/settings-all-debrid.tsx @@ -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 = 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 ( +
+

+ {t("all_debrid_description")} +

+ + + setForm((prev) => ({ + ...prev, + useAllDebrid: !form.useAllDebrid, + })) + } + /> + + {form.useAllDebrid && ( + + setForm({ ...form, allDebridApiKey: event.target.value }) + } + rightContent={ + + } + placeholder="API Key" + hint={ + + + + } + /> + )} + + ); +} \ No newline at end of file diff --git a/src/renderer/src/pages/settings/settings.tsx b/src/renderer/src/pages/settings/settings.tsx index 4c94343c6..5ff85d0c3 100644 --- a/src/renderer/src/pages/settings/settings.tsx +++ b/src/renderer/src/pages/settings/settings.tsx @@ -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 ; } + if (currentCategoryIndex === 5) { + return ; + } + return ; }; diff --git a/src/types/download.types.ts b/src/types/download.types.ts index 8b7f20913..7f3ef442b 100644 --- a/src/types/download.types.ts +++ b/src/types/download.types.ts @@ -174,3 +174,11 @@ export interface SeedingStatus { status: DownloadStatus; uploadSpeed: number; } + +/* All-Debrid */ +export interface AllDebridUser { + username: string; + email: string; + isPremium: boolean; + premiumUntil: string; +} diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 2956165af..9abac9a3d 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -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; From 5e9aa2b0ea61d4d0970923aebfaa2d77a370ef38 Mon Sep 17 00:00:00 2001 From: mircea32000 <36380975+mircea32000@users.noreply.github.com> Date: Sat, 8 Feb 2025 18:02:06 +0200 Subject: [PATCH 02/11] Add Somewhat working logic --- python_rpc/http_downloader.py | 178 ++++++++++++--- python_rpc/main.py | 25 ++- src/main/services/download/all-debrid.ts | 211 +++++++++++++++++- .../services/download/download-manager.ts | 14 ++ src/main/services/python-rpc.ts | 15 +- src/renderer/src/constants.ts | 1 + src/renderer/src/constants/downloader.ts | 13 ++ .../src/pages/downloads/download-group.tsx | 4 +- .../modals/download-settings-modal.scss | 7 + .../modals/download-settings-modal.tsx | 10 +- src/shared/constants.ts | 2 + src/shared/index.ts | 2 +- src/types/download.types.ts | 27 +++ 13 files changed, 458 insertions(+), 51 deletions(-) create mode 100644 src/renderer/src/constants/downloader.ts diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py index 71e4b57ea..ed7d31c3d 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 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() + + 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: 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 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}") - self.download = downloads[0] + # 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: - return None + try: + if not self.downloads: + return None - download = self.aria2.get_download(self.download.gid) + total_size = 0 + downloaded = 0 + download_speed = 0 + active_downloads = [] - 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, - } + 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)}") - return response + 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 diff --git a/python_rpc/main.py b/python_rpc/main.py index 2deb20297..efedc5c92 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -23,19 +23,27 @@ 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 864710b80..fc7b64a34 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 247d5c750..88c7ce1fb 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 22e604617..38b4e81a3 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 1d7aa1b16..c9c730ecf 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 000000000..0f94d594d --- /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 d5e568fb8..86d7a97b9 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 1b7c51e85..0917b1205 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 214af1d15..6af0788f4 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 550c1097b..979b2927a 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 01d7cb063..7af5c7f20 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 7f3ef442b..9550627a3 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; +} From 897e2c319359896ef8187e77cc8a5e68bd9435fb Mon Sep 17 00:00:00 2001 From: mircea32000 <36380975+mircea32000@users.noreply.github.com> Date: Mon, 10 Feb 2025 21:05:39 +0200 Subject: [PATCH 03/11] Working? Lets hope so --- python_rpc/http_downloader.py | 182 ++++-------------- python_rpc/http_multi_link_downloader.py | 151 +++++++++++++++ python_rpc/main.py | 79 +++++--- src/main/services/download/all-debrid.ts | 18 +- .../services/download/download-manager.ts | 61 +++--- src/main/services/download/helpers.ts | 23 ++- src/main/services/python-rpc.ts | 17 +- 7 files changed, 292 insertions(+), 239 deletions(-) create mode 100644 python_rpc/http_multi_link_downloader.py diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py index ed7d31c3d..9dae7da3a 100644 --- a/python_rpc/http_downloader.py +++ b/python_rpc/http_downloader.py @@ -1,162 +1,48 @@ 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.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 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 - } + self.download = None + self.aria2 = aria2p.API( + aria2p.Client( + host="http://localhost", + port=6800, + secret="" ) - data = response.json() - - 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 + def start_download(self, url: str, save_path: str, header: str, out: str = None): + if self.download: + self.aria2.resume([self.download]) 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)}") + downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out}) + + self.download = downloads[0] def pause_download(self): - try: - for download in self.downloads: - download.pause() - except Exception as e: - logger.error(f"Error pausing downloads: {str(e)}") + if self.download: + self.aria2.pause([self.download]) def cancel_download(self): - try: - for download in self.downloads: - download.remove() - except Exception as e: - logger.error(f"Error canceling downloads: {str(e)}") + if self.download: + self.aria2.remove([self.download]) + self.download = None def get_download_status(self): - 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)}") + if self.download == None: 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, + } + print("HTTP_DOWNLOADER_STATUS: ", response) + return response \ No newline at end of file diff --git a/python_rpc/http_multi_link_downloader.py b/python_rpc/http_multi_link_downloader.py new file mode 100644 index 000000000..71087db25 --- /dev/null +++ b/python_rpc/http_multi_link_downloader.py @@ -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 [] \ No newline at end of file diff --git a/python_rpc/main.py b/python_rpc/main.py index efedc5c92..b7a144b85 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -2,6 +2,7 @@ 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 @@ -23,27 +24,27 @@ 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'] - # 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 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: - torrent_downloader.start_download(url, initial_download['save_path']) + torrent_downloader.start_download(initial_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(url, initial_download['save_path'], initial_download.get('header'), initial_download.get("out")) + http_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 http download", e) @@ -70,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() @@ -89,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, @@ -143,18 +169,18 @@ def action(): if action == 'start': url = data.get('url') - print(f"Starting download with URL: {url}") existing_downloader = downloads.get(game_id) - # 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 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: @@ -188,6 +214,7 @@ 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 fc7b64a34..63a674083 100644 --- a/src/main/services/download/all-debrid.ts +++ b/src/main/services/download/all-debrid.ts @@ -27,6 +27,12 @@ interface AllDebridError { 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"; @@ -201,7 +207,7 @@ export class AllDebridClient { } } - public static async getDownloadUrls(uri: string): Promise { + public static async getDownloadUrls(uri: string): Promise { try { logger.info("[AllDebrid] Getting download URLs for URI:", uri); @@ -226,7 +232,11 @@ export class AllDebridClient { try { const unlockedLink = await this.unlockLink(link.link); logger.info("[AllDebrid] Successfully unlocked link:", unlockedLink); - return 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"); @@ -249,7 +259,9 @@ export class AllDebridClient { // Pentru link-uri normale, doar debridam link-ul const downloadUrl = await this.unlockLink(uri); logger.info("[AllDebrid] Got unlocked download URL:", downloadUrl); - return [downloadUrl]; + return [{ + link: downloadUrl + }]; } } catch (error: any) { logger.error("[AllDebrid] Get Download URLs Error:", error); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 88c7ce1fb..b4109b7b3 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -19,6 +19,16 @@ import { TorBoxClient } from "./torbox"; import { AllDebridClient } from "./all-debrid"; import { spawn } from "child_process"; +interface GamePayload { + action: string; + game_id: string; + url: string | string[]; + save_path: string; + header?: string; + out?: string; + total_size?: number; +} + export class DownloadManager { private static downloadingGameId: string | null = null; @@ -135,45 +145,15 @@ export class DownloadManager { if (progress === 1 && download) { publishDownloadCompleteNotification(game); - if ( - userPreferences?.seedAfterDownloadComplete && - download.downloader === Downloader.Torrent - ) { - downloadsSublevel.put(gameId, { - ...download, - status: "seeding", - shouldSeed: true, - queued: false, - }); - } else { - downloadsSublevel.put(gameId, { - ...download, - status: "complete", - shouldSeed: false, - queued: false, - }); - - this.cancelDownload(gameId); - } + await downloadsSublevel.put(gameId, { + ...download, + status: "complete", + shouldSeed: false, + queued: false, + }); - const downloads = await downloadsSublevel - .values() - .all() - .then((games) => { - return sortBy( - games.filter((game) => game.status === "paused" && game.queued), - "timestamp", - "DESC" - ); - }); - - const [nextItemOnQueue] = downloads; - - if (nextItemOnQueue) { - this.resumeDownload(nextItemOnQueue); - } else { - this.downloadingGameId = null; - } + await this.cancelDownload(gameId); + this.downloadingGameId = null; } } } @@ -340,11 +320,14 @@ export class DownloadManager { 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, + url: downloadUrls.map(d => d.link), save_path: download.downloadPath, + total_size: totalSize }; } case Downloader.TorBox: { diff --git a/src/main/services/download/helpers.ts b/src/main/services/download/helpers.ts index 0856eb169..ae039adf3 100644 --- a/src/main/services/download/helpers.ts +++ b/src/main/services/download/helpers.ts @@ -17,17 +17,24 @@ export const calculateETA = ( }; export const getDirSize = async (dir: string): Promise => { - const getItemSize = async (filePath: string): Promise => { - const stat = await fs.promises.stat(filePath); - - if (stat.isDirectory()) { - return getDirSize(filePath); + try { + const stat = await fs.promises.stat(dir); + + // If it's a file, return its size directly + if (!stat.isDirectory()) { + return stat.size; } - return stat.size; - }; + const getItemSize = async (filePath: string): Promise => { + 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)); diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index 38b4e81a3..dcbca2816 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -8,8 +8,6 @@ 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; @@ -44,7 +42,7 @@ export class PythonRPC { readable.on("data", pythonRpcLogger.log); } - public static async spawn( + public static spawn( initialDownload?: GamePayload, initialSeeding?: GamePayload[] ) { @@ -56,15 +54,6 @@ 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( @@ -85,7 +74,6 @@ export class PythonRPC { const childProcess = cp.spawn(binaryPath, commonArgs, { windowsHide: true, stdio: ["inherit", "inherit"], - env }); this.logStderr(childProcess.stderr); @@ -102,7 +90,6 @@ export class PythonRPC { const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], { stdio: ["inherit", "inherit"], - env }); this.logStderr(childProcess.stderr); @@ -118,4 +105,4 @@ export class PythonRPC { this.pythonProcess = null; } } -} +} \ No newline at end of file From ce76ed1bfbbf9413929bed67e70273432e30a9ab Mon Sep 17 00:00:00 2001 From: mircea32000 <36380975+mircea32000@users.noreply.github.com> Date: Mon, 10 Feb 2025 22:45:41 +0200 Subject: [PATCH 04/11] i cant figure that shit out so take it out --- src/locales/en/translation.json | 3 ++- .../src/pages/downloads/download-group.tsx | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index c71f263b1..ea054f89d 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 86d7a97b9..e6ffded48 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -112,6 +112,15 @@ export function DownloadGroup({ ); } + if (download.downloader === Downloader.AllDebrid) { + return ( + <> +

{progress}

+

{t("alldebrid_size_not_supported")}

+ + ); + } + return ( <>

{progress}

@@ -154,6 +163,15 @@ export function DownloadGroup({ } if (download.status === "active") { + if (download.downloader === Downloader.AllDebrid) { + return ( + <> +

{formatDownloadProgress(download.progress)}

+

{t("alldebrid_size_not_supported")}

+ + ); + } + return ( <>

{formatDownloadProgress(download.progress)}

From fa9b6f0d385d2d1e2255d8b97db63658d0e7ded0 Mon Sep 17 00:00:00 2001 From: mircea32000 <36380975+mircea32000@users.noreply.github.com> Date: Mon, 10 Feb 2025 22:56:50 +0200 Subject: [PATCH 05/11] Fix torbox garbage --- .../pages/game-details/modals/download-settings-modal.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 6af0788f4..c2660782d 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 @@ -170,10 +170,13 @@ export function DownloadSettingsModal({ (downloader === Downloader.RealDebrid && !userPreferences?.realDebridApiToken) || (downloader === Downloader.AllDebrid && - !userPreferences?.allDebridApiKey) + !userPreferences?.allDebridApiKey) || + (downloader === Downloader.TorBox && + !userPreferences?.torBoxApiToken) } onClick={() => setSelectedDownloader(downloader)} > + {selectedDownloader === downloader && ( )} From c0964d96a1a0235c7c2264b57d17e409bf731870 Mon Sep 17 00:00:00 2001 From: mircea32000 <36380975+mircea32000@users.noreply.github.com> Date: Mon, 10 Feb 2025 23:21:18 +0200 Subject: [PATCH 06/11] Clean garbage --- python_rpc/http_downloader.py | 2 +- src/renderer/src/constants/downloader.ts | 13 --------- .../modals/download-settings-modal.scss | 9 +----- src/types/download.types.ts | 29 +------------------ 4 files changed, 3 insertions(+), 50 deletions(-) delete mode 100644 src/renderer/src/constants/downloader.ts diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py index 9dae7da3a..c24f8ec83 100644 --- a/python_rpc/http_downloader.py +++ b/python_rpc/http_downloader.py @@ -44,5 +44,5 @@ def get_download_status(self): 'status': download.status, 'bytesDownloaded': download.completed_length, } - print("HTTP_DOWNLOADER_STATUS: ", response) + return response \ No newline at end of file diff --git a/src/renderer/src/constants/downloader.ts b/src/renderer/src/constants/downloader.ts deleted file mode 100644 index 0f94d594d..000000000 --- a/src/renderer/src/constants/downloader.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/game-details/modals/download-settings-modal.scss b/src/renderer/src/pages/game-details/modals/download-settings-modal.scss index 0917b1205..a22183fa2 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,11 +27,6 @@ &__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; @@ -41,8 +36,6 @@ &__downloader-icon { position: absolute; left: calc(globals.$spacing-unit * 2); - top: 50%; - transform: translateY(-50%); } &__path-error { @@ -56,4 +49,4 @@ &__change-path-button { align-self: flex-end; } -} +} \ No newline at end of file diff --git a/src/types/download.types.ts b/src/types/download.types.ts index 9550627a3..07d066b9d 100644 --- a/src/types/download.types.ts +++ b/src/types/download.types.ts @@ -181,31 +181,4 @@ export interface AllDebridUser { email: string; 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; -} +} \ No newline at end of file From d08d14a9e4dbf51587d1148f8c68a12fb0d19aa8 Mon Sep 17 00:00:00 2001 From: mircea32000 <36380975+mircea32000@users.noreply.github.com> Date: Tue, 11 Feb 2025 00:19:02 +0200 Subject: [PATCH 07/11] fix oopsie --- env.example | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 env.example diff --git a/env.example b/env.example new file mode 100644 index 000000000..3ef399f73 --- /dev/null +++ b/env.example @@ -0,0 +1,2 @@ +MAIN_VITE_API_URL=API_URL +MAIN_VITE_AUTH_URL=AUTH_URL From 9e6143ebc90b6979887ca398395794c859a4cb70 Mon Sep 17 00:00:00 2001 From: mircea32000 <36380975+mircea32000@users.noreply.github.com> Date: Tue, 11 Feb 2025 00:58:47 +0200 Subject: [PATCH 08/11] Fix lints and add back the option to seed after download complete --- .../authenticate-all-debrid.ts | 8 +- .../update-user-preferences.ts | 4 +- src/main/main.ts | 11 +- src/main/services/download/all-debrid.ts | 507 ++++++++++-------- .../services/download/download-manager.ts | 90 ++-- src/main/services/download/helpers.ts | 2 +- src/main/services/python-rpc.ts | 8 +- src/renderer/src/declaration.d.ts | 2 + .../game-details/hero/hero-panel-actions.tsx | 1 - .../modals/download-settings-modal.scss | 2 +- .../modals/download-settings-modal.tsx | 1 - .../pages/settings/settings-all-debrid.scss | 2 +- .../pages/settings/settings-all-debrid.tsx | 4 +- src/shared/index.ts | 7 +- src/types/download.types.ts | 2 +- 15 files changed, 358 insertions(+), 293 deletions(-) diff --git a/src/main/events/user-preferences/authenticate-all-debrid.ts b/src/main/events/user-preferences/authenticate-all-debrid.ts index 6e153fe5e..d4bb36e84 100644 --- a/src/main/events/user-preferences/authenticate-all-debrid.ts +++ b/src/main/events/user-preferences/authenticate-all-debrid.ts @@ -7,12 +7,12 @@ const authenticateAllDebrid = async ( ) => { AllDebridClient.authorize(apiKey); const result = await AllDebridClient.getUser(); - - if ('error_code' in result) { + + if ("error_code" in result) { return { error_code: result.error_code }; } - + return result.user; }; -registerEvent("authenticateAllDebrid", authenticateAllDebrid); \ No newline at end of file +registerEvent("authenticateAllDebrid", authenticateAllDebrid); diff --git a/src/main/events/user-preferences/update-user-preferences.ts b/src/main/events/user-preferences/update-user-preferences.ts index 90a2d56cb..c1598f294 100644 --- a/src/main/events/user-preferences/update-user-preferences.ts +++ b/src/main/events/user-preferences/update-user-preferences.ts @@ -31,9 +31,7 @@ const updateUserPreferences = async ( } if (preferences.allDebridApiKey) { - preferences.allDebridApiKey = Crypto.encrypt( - preferences.allDebridApiKey - ); + preferences.allDebridApiKey = Crypto.encrypt(preferences.allDebridApiKey); } if (preferences.torBoxApiToken) { diff --git a/src/main/main.ts b/src/main/main.ts index e09c473b9..083c25484 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -45,15 +45,11 @@ export const loadState = async () => { } if (userPreferences?.allDebridApiKey) { - AllDebridClient.authorize( - Crypto.decrypt(userPreferences.allDebridApiKey) - ); + AllDebridClient.authorize(Crypto.decrypt(userPreferences.allDebridApiKey)); } if (userPreferences?.torBoxApiToken) { - TorBoxClient.authorize( - Crypto.decrypt(userPreferences.torBoxApiToken) - ); + TorBoxClient.authorize(Crypto.decrypt(userPreferences.torBoxApiToken)); } Ludusavi.addManifestToLudusaviConfig(); @@ -126,7 +122,8 @@ const migrateFromSqlite = async () => { .select("*") .then(async (userPreferences) => { if (userPreferences.length > 0) { - const { realDebridApiToken, allDebridApiKey, ...rest } = userPreferences[0]; + const { realDebridApiToken, allDebridApiKey, ...rest } = + userPreferences[0]; await db.put( levelKeys.userPreferences, diff --git a/src/main/services/download/all-debrid.ts b/src/main/services/download/all-debrid.ts index 63a674083..b1b04b7a6 100644 --- a/src/main/services/download/all-debrid.ts +++ b/src/main/services/download/all-debrid.ts @@ -3,269 +3,310 @@ import type { AllDebridUser } from "@types"; import { logger } from "@main/services"; interface AllDebridMagnetStatus { - id: number; + 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; - 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; + code: string; + message: string; } interface AllDebridDownloadUrl { - link: string; - size?: number; - filename?: string; + 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 - } - }); - } + private static instance: AxiosInstance; + private static readonly baseURL = "https://api.alldebrid.com/v4"; - 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" }; + 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] - } - }); + private static async uploadMagnet(magnet: string) { + try { + logger.info("[AllDebrid] Uploading magnet with params:", { magnet }); - logger.info("[AllDebrid] Upload Magnet Raw Response:", JSON.stringify(response.data, null, 2)); + const response = await this.instance.get("/magnet/upload", { + params: { + magnets: [magnet], + }, + }); - if (response.data.status === "error") { - throw new Error(response.data.error?.message || "Unknown error"); - } + logger.info( + "[AllDebrid] Upload Magnet Raw Response:", + JSON.stringify(response.data, null, 2) + ); - const magnetInfo = response.data.data.magnets[0]; - logger.info("[AllDebrid] Magnet Info:", JSON.stringify(magnetInfo, null, 2)); + if (response.data.status === "error") { + throw new Error(response.data.error?.message || "Unknown error"); + } - if (magnetInfo.error) { - throw new Error(magnetInfo.error.message); - } + const magnetInfo = response.data.data.magnets[0]; + logger.info( + "[AllDebrid] Magnet Info:", + JSON.stringify(magnetInfo, null, 2) + ); - return magnetInfo.id; - } catch (error: any) { - logger.error("[AllDebrid] Upload Magnet Error:", error); - throw error; - } + 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 - })) - }; + private static async checkMagnetStatus( + magnetId: number + ): Promise { + try { + logger.info("[AllDebrid] Checking magnet status for ID:", magnetId); - logger.info("[AllDebrid] Magnet Status:", JSON.stringify(magnetStatus, null, 2)); + const response = await this.instance.get(`/magnet/status`, { + params: { + id: magnetId, + }, + }); - return magnetStatus; - } catch (error: any) { - logger.error("[AllDebrid] Check Magnet Status Error:", error); - throw error; - } + 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 - } - }); + 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"); - } + 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"); - } + 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; - } + 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 { - 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 (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 [{ - link: downloadUrl - }]; - } - } catch (error: any) { - logger.error("[AllDebrid] Get Download URLs 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 { + 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 (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 [ + { + link: 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 b4109b7b3..c4ccc4302 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -17,17 +17,6 @@ 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"; - -interface GamePayload { - action: string; - game_id: string; - url: string | string[]; - save_path: string; - header?: string; - out?: string; - total_size?: number; -} export class DownloadManager { private static downloadingGameId: string | null = null; @@ -44,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, @@ -145,15 +135,45 @@ export class DownloadManager { if (progress === 1 && download) { publishDownloadCompleteNotification(game); - await downloadsSublevel.put(gameId, { - ...download, - status: "complete", - shouldSeed: false, - queued: false, - }); + if ( + userPreferences?.seedAfterDownloadComplete && + download.downloader === Downloader.Torrent + ) { + downloadsSublevel.put(gameId, { + ...download, + status: "seeding", + shouldSeed: true, + queued: false, + }); + } else { + downloadsSublevel.put(gameId, { + ...download, + status: "complete", + shouldSeed: false, + queued: false, + }); + + this.cancelDownload(gameId); + } - await this.cancelDownload(gameId); - this.downloadingGameId = null; + const downloads = await downloadsSublevel + .values() + .all() + .then((games) => { + return sortBy( + games.filter((game) => game.status === "paused" && game.queued), + "timestamp", + "DESC" + ); + }); + + const [nextItemOnQueue] = downloads; + + if (nextItemOnQueue) { + this.resumeDownload(nextItemOnQueue); + } else { + this.downloadingGameId = null; + } } } } @@ -296,6 +316,21 @@ 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", @@ -315,21 +350,6 @@ 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.TorBox: { const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); @@ -350,4 +370,4 @@ export class DownloadManager { await PythonRPC.rpc.post("/action", payload); this.downloadingGameId = levelKeys.game(download.shop, download.objectId); } -} +} \ No newline at end of file diff --git a/src/main/services/download/helpers.ts b/src/main/services/download/helpers.ts index ae039adf3..84db662e7 100644 --- a/src/main/services/download/helpers.ts +++ b/src/main/services/download/helpers.ts @@ -19,7 +19,7 @@ export const calculateETA = ( export const getDirSize = async (dir: string): Promise => { try { const stat = await fs.promises.stat(dir); - + // If it's a file, return its size directly if (!stat.isDirectory()) { return stat.size; diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index dcbca2816..7ed22ed6c 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -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> = { @@ -105,4 +109,4 @@ export class PythonRPC { this.pythonProcess = null; } } -} \ No newline at end of file +} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 8e31aa837..666ba121f 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -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,7 @@ declare global { minimized: boolean; }) => Promise; authenticateRealDebrid: (apiToken: string) => Promise; + authenticateAllDebrid: (apiKey: string) => Promise; authenticateTorBox: (apiToken: string) => Promise; onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer; diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index 37e0ff1f5..9dc531f4b 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -196,7 +196,6 @@ export function HeroPanelActions() { {game.favorite ? : } -