From 1746f14adb073abfaa11583c920f66fed6eff4f3 Mon Sep 17 00:00:00 2001
From: Gear <88455107+GearCzech@users.noreply.github.com>
Date: Mon, 7 Apr 2025 17:25:37 +0200
Subject: [PATCH 01/53] Updated czech translation
---
src/locales/cs/translation.json | 136 +++++++++++++++++++++++++++++---
1 file changed, 124 insertions(+), 12 deletions(-)
diff --git a/src/locales/cs/translation.json b/src/locales/cs/translation.json
index ec240a0f..f7fd5f07 100644
--- a/src/locales/cs/translation.json
+++ b/src/locales/cs/translation.json
@@ -44,11 +44,19 @@
"downloading_metadata": "Stahuji metadata: {{title}}…",
"downloading": "Stahuji {{title}}… ({{percentage}} staženo) - Odhadovaný čas {{eta}} - {{speed}}",
"calculating_eta": "Stahuji {{title}}… ({{percentage}} staženo) - Počítám zbývající čas…",
- "checking_files": "Kontroluji soubory: {{title}}… ({{percentage}} ověřeno)"
+ "checking_files": "Kontroluji soubory: {{title}}… ({{percentage}} ověřeno)",
+ "installing_common_redist": "{{log}}…",
+ "installation_complete": "Instalace dokončena",
+ "installation_complete_message": "Běžné redistribuovatelné komponenty byly úspěšně nainstalovány"
},
"catalogue": {
- "next_page": "Další strana",
- "previous_page": "Předchozí strana"
+ "genres": "Žánry",
+ "tags": "Tagy",
+ "publishers": "Vydavatelé",
+ "download_sources": "Zdroje stahování",
+ "result_count": "Výsledky: {{resultCount}}",
+ "filter_count": "Dostupné: {{filterCount}}",
+ "clear_filters": "Vyčistit vybrané filtry: {{filterCount}}"
},
"game_details": {
"open_download_options": "Otevřít možnosti stahování",
@@ -160,6 +168,9 @@
"loading_save_preview": "Hledání uložených her...",
"wine_prefix": "Wine Prefix",
"wine_prefix_description": "Wine Prefix použit pro spuštění této hry",
+ "launch_options": "Možnosti spuštění",
+ "launch_options_description": "Pokročilí uživatelé mohou zadat speciální parametry spuštění (experimentální funkce)",
+ "launch_options_placeholder": "Žádné parametery nebyly specifikovány",
"no_download_option_info": "Žádné informace nejsou dostupny",
"backup_deletion_failed": "Nepovedlo se odstranit zálohu",
"max_number_of_artifacts_reached": "Dosáhli jste maximálního počtu záloh pro tuto hru",
@@ -167,7 +178,23 @@
"manage_files_description": "Spravovat, které soubory budou zálohovány a obnoveny",
"select_folder": "Vybrat složku",
"backup_from": "Zálohy z {{date}}",
- "custom_backup_location_set": "Vlastní umístění záloh nastaveno"
+ "custom_backup_location_set": "Vlastní umístění záloh nastaveno",
+ "automatic_backup_from": "Automatická záloha z {{date}}",
+ "enable_automatic_cloud_sync": "Povolit automatické zálohy v cloudu",
+ "no_directory_selected": "Žádná složka není zvolena",
+ "no_write_permission": "Nemohu stahovat do této složky. Klikni sem pro více informací.",
+ "reset_achievements": "Resetovat achievementy",
+ "reset_achievements_description": "Toto zresetuje všechny achievementy pro hru {{game}}",
+ "reset_achievements_title": "Jste si jisti?",
+ "reset_achievements_success": "Achievementy úspěšně resetovány",
+ "reset_achievements_error": "Nepodařilo se resetovat achievementy",
+ "download_error_gofile_quota_exceeded": "Překročili jste vaši měsíční GoFile kvótu. Prosím vyčkejte na resetování kvóty.",
+ "download_error_real_debrid_account_not_authorized": "Váš Real-Debrid účet není autorizován pro vytváření nových stahování. Prosím zkontrolujte nastavení vašeho účtu a zkuste to znovu.",
+ "download_error_not_cached_in_real_debrid": "Toto stahování není dostupné na Real-Debrid a získávání informací o stahování z Real-Debrid není zatím dostupné.",
+ "download_error_not_cached_in_torbox": "Toto stahování není dostupné na Torbox a získávání informací o stahování z Torbox není zatím dostupné.",
+ "game_removed_from_favorites": "Hra odebrána z oblíbených",
+ "game_added_to_favorites": "Hra přidána do oblíbených",
+ "automatically_extract_downloaded_files": "Automaticky rozbalit stažené soubory"
},
"activation": {
"title": "Aktivovat hydru",
@@ -200,7 +227,13 @@
"queued": "V řadě",
"no_downloads_title": "Prázdno..",
"no_downloads_description": "Ještě jsi zatím nic nestáhl přes Hydru, ale furt není pozdě začít.",
- "checking_files": "Kontroluji soubory…"
+ "checking_files": "Kontroluji soubory…",
+ "seeding": "Seedování",
+ "stop_seeding": "Zastavování seedování",
+ "resume_seeding": "Obnovit seedování",
+ "options": "Spravovat",
+ "extract": "Rozbalit soubory",
+ "extracting": "Rozbalování souborů…"
},
"settings": {
"downloads_path": "Umístění stahování",
@@ -261,9 +294,64 @@
"must_be_valid_url": "Zdroj musí být platký odkaz URL",
"blocked_users": "Zablokovaní uživatelé",
"user_unblocked": "Uživatel byl odblokován",
- "enable_achievement_notifications": "Když je odemknut achievement",
+ "enable_achievement_notifications": "Když je odemčen achievement",
"launch_minimized": "Spustit v minimalizovaném režimu",
- "disable_nsfw_alert": "Deaktivovat upozornění na nevhodný obsah"
+ "disable_nsfw_alert": "Deaktivovat upozornění na nevhodný obsah",
+ "seed_after_download_complete": "Seedovat až skončí stahování",
+ "show_hidden_achievement_description": "Zobrazit popisy skrytých achievementů dříve, než jsou odemčeny",
+ "account": "Účet",
+ "no_users_blocked": "Nemáte žádného uživatele zablokovaného",
+ "subscription_active_until": "Vaše Hydra cloud předplatné je platné do {{date}}",
+ "manage_subscription": "Spravovat předplatné",
+ "update_email": "Změnit email",
+ "update_password": "Změnit heslo",
+ "current_email": "Současný email:",
+ "no_email_account": "Zatím nemáte nastavený email",
+ "account_data_updated_successfully": "Data vašeho účtu byly úspěšně upraveny",
+ "renew_subscription": "Obnovit předplatné Hydra Cloud",
+ "subscription_expired_at": "Vaše předplatné vypršelo {{date}}",
+ "no_subscription": "Užijte si Hydru v nejlepší možné podobě",
+ "become_subscriber": "Připojit se k předplatnému Hydra Cloud",
+ "subscription_renew_cancelled": "Automatické obnovování je zrušenu",
+ "subscription_renews_on": "Vaše předplatné se obnoví {{date}}",
+ "bill_sent_until": "Vaše příští faktura bude odeslána nejpozději do tohoto dne",
+ "no_themes": "Vypadá to že ještě nemáte žádné vzhledy, ale nebojte, klikněte sem pro vytvoření vašeho prvního mistrovského díla!",
+ "editor_tab_code": "Kód",
+ "editor_tab_info": "Info",
+ "editor_tab_save": "Uložit",
+ "web_store": "Webový obchod",
+ "clear_themes": "Vyčistit",
+ "create_theme": "Vytvořit",
+ "create_theme_modal_title": "Vytvořit vlastní vzhled",
+ "create_theme_modal_description": "Vytvořte si vlastní styl, abyste si mohli ozdobit Hydru",
+ "theme_name": "Název",
+ "insert_theme_name": "Vložte název vzhledu",
+ "set_theme": "Nastavit vzhled",
+ "unset_theme": "Zrušit vzhled",
+ "delete_theme": "Odstranit vzhled",
+ "edit_theme": "Upravit vzhled",
+ "delete_all_themes": "Smazat všechny vzhledy",
+ "delete_all_themes_description": "Toto smaže všechny vaše vzhledy",
+ "delete_theme_description": "Toto smaže vzhled {{theme}}",
+ "cancel": "Zrušit",
+ "appearance": "Vzhled",
+ "enable_torbox": "Povolit TorBox",
+ "torbox_description": "TorBox je prémiový seedbox server který se vyrovná i těm nejlepším seedbox serverům na trhu.",
+ "torbox_account_linked": "TorBox účet propojen",
+ "create_real_debrid_account": "Klikni sem pokud ještě nemáš Real-Debrid účet",
+ "create_torbox_account": "Klikni sem pokud ještě nemáš TorBox účet",
+ "real_debrid_account_linked": "Real-Debrid účet propojen",
+ "name_min_length": "Název vzhledu musí být minimálně 3 znaky dlouhý",
+ "import_theme": "Vložit vzhled",
+ "import_theme_description": "Chystáte se vložit vzhled {{theme}} z obchodu vzhledů",
+ "error_importing_theme": "Nastala chyba při vkládání vzhledu",
+ "theme_imported": "Vzhled úspěšně vložen",
+ "enable_friend_request_notifications": "Při obdržení žádosti o přátelství",
+ "enable_auto_install": "Automaticky stahovat aktualizace",
+ "common_redist": "Běžné redistribuovatelné komponenty",
+ "common_redist_description": "Běžné redistribuovatelné komponenty jsou potřeba pro spuštění určitých her. Je doporučeno je nainstalovat, aby se předešlo problémům.",
+ "install_common_redist": "Nainstalovat",
+ "installing_common_redist": "Instalování…"
},
"notifications": {
"download_complete": "Stahování dokončeno",
@@ -273,14 +361,20 @@
"repack_count_other": "{{count}} repacky přidány",
"new_update_available": "Version {{version}} je dostupná",
"restart_to_install_update": "Restartuj Hydru pro aktualizaci",
- "notification_achievement_unlocked_title": "Achievement pro {{game}} byl odemknut",
- "notification_achievement_unlocked_body": "{{achievement}} a dalších {{count}} byly odemknuty"
+ "notification_achievement_unlocked_title": "Achievement pro {{game}} byl odemčen",
+ "notification_achievement_unlocked_body": "{{achievement}} a dalších {{count}} byly odemčeny",
+ "new_friend_request_description": "Obdrželi jste novou žádost o přátelství",
+ "new_friend_request_title": "Nová žádost o přátelství",
+ "extraction_complete": "Rozbalování dokončeno",
+ "game_extracted": "{{title}} úspěšně rozbaleno"
},
"system_tray": {
"open": "Otevřít Hydru",
"quit": "Odejít"
},
"game_card": {
+ "available_one": "Dostupné",
+ "available_other": "Dostupné",
"no_downloads": "Žádné možnosti stahování nenalezeny"
},
"binary_not_found_modal": {
@@ -363,7 +457,17 @@
"your_friend_code": "Tvůj kód přítele:",
"upload_banner": "Nahrát banner profilu",
"uploading_banner": "Nahrávání banneru",
- "background_image_updated": "Obrázek pozadí byl změněn"
+ "background_image_updated": "Obrázek pozadí byl změněn",
+ "stats": "Statistiky",
+ "achievements": "Achievementy",
+ "games": "Hry",
+ "top_percentile": "Top {{percentile}}%",
+ "ranking_updated_weekly": "Žebříčky jsou aktualizovány každý týden",
+ "playing": "Hraje {{game}}",
+ "achievements_unlocked": "Achievements odemčen",
+ "earned_points": "Získané body",
+ "show_achievements_on_profile": "Zobrazit vaše odemčené achievementy na profilu",
+ "show_points_on_profile": "Zobrazit vaše získané body na profilu"
},
"achievement": {
"achievement_unlocked": "Achievement odemčen",
@@ -373,7 +477,12 @@
"subscription_needed": "Je vyžadováno předplatné Hydra Cloud pro zobrazení tohoto obsahu",
"new_achievements_unlocked": "Odemčeno {{achievementCount}} nových achievementů z {{gameCount}} her",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementů",
- "achievements_unlocked_for_game": "Odemčeno {{achievementCount}} nových achievementů pro {{gameTitle}}"
+ "achievements_unlocked_for_game": "Odemčeno {{achievementCount}} nových achievementů pro {{gameTitle}}",
+ "hidden_achievement_tooltip": "Toho je skrytý achievement",
+ "achievement_earn_points": "Získej {{points}} bodů tímto achievementem",
+ "earned_points": "Získané body",
+ "available_points": "Dostupné body:",
+ "how_to_earn_achievements_points": "Jak získat body achievementů?"
},
"hydra_cloud": {
"subscription_tour_title": "Předplatné Hydra Cloud",
@@ -383,6 +492,9 @@
"animated_profile_picture": "Animované profilové obrázky",
"premium_support": "Prémiová podpora",
"show_and_compare_achievements": "Zobraz a porovnej achievementy s ostatními uživateli",
- "animated_profile_banner": "Animovaný banner na profilu"
+ "animated_profile_banner": "Animovaný banner na profilu",
+ "hydra_cloud": "Hydra Cloud",
+ "hydra_cloud_feature_found": "Právě jste objevili funkci předplatného Hydra Cloud!",
+ "learn_more": "Zjistit více"
}
}
From 1835adf8b4d4197965067f11cb11cfa630bf8cf9 Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Wed, 9 Apr 2025 07:57:00 +0100
Subject: [PATCH 02/53] fix: fixing multiple connections
---
python_rpc/http_downloader.py | 93 +++++++++++--------
python_rpc/main.py | 4 +-
scripts/postinstall.cjs | 7 ++
src/main/services/7zip.ts | 4 +-
src/main/services/aria2.ts | 6 --
.../services/download/download-manager.ts | 2 +
6 files changed, 66 insertions(+), 50 deletions(-)
diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py
index 71e4b57e..88334fe9 100644
--- a/python_rpc/http_downloader.py
+++ b/python_rpc/http_downloader.py
@@ -1,48 +1,61 @@
import aria2p
class HttpDownloader:
- def __init__(self):
- self.download = None
- self.aria2 = aria2p.API(
- aria2p.Client(
- host="http://localhost",
- port=6800,
- secret=""
- )
- )
+ def __init__(self):
+ self.download = None
+ self.aria2 = aria2p.API(
+ aria2p.Client(
+ host="http://localhost",
+ port=6800,
+ secret=""
+ )
+ )
- def start_download(self, url: str, save_path: str, header: str, 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})
-
- self.download = downloads[0]
-
- def pause_download(self):
- if self.download:
- self.aria2.pause([self.download])
-
- def cancel_download(self):
- if self.download:
- self.aria2.remove([self.download])
- self.download = None
+ def start_download(self, url: str, save_path: str, header: str, out: str = None, allow_multiple_connections: bool = False):
+ if self.download:
+ self.aria2.resume([self.download])
+ else:
+ options = {
+ "header": header,
+ "dir": save_path,
+ "out": out
+ }
- def get_download_status(self):
- if self.download == None:
- return None
+ if allow_multiple_connections:
+ options.update({
+ "split": "16",
+ "max-connection-per-server": "16",
+ "min-split-size": "1M"
+ })
+
+ downloads = self.aria2.add(url, options=options)
+
+ self.download = downloads[0]
+
+ def pause_download(self):
+ if self.download:
+ self.aria2.pause([self.download])
+
+ def cancel_download(self):
+ if self.download:
+ self.aria2.remove([self.download])
+ self.download = None
- download = self.aria2.get_download(self.download.gid)
+ def get_download_status(self):
+ if self.download == None:
+ return None
- 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,
- }
+ download = self.aria2.get_download(self.download.gid)
- return response
+ response = {
+ 'folderName': download.name,
+ 'fileSize': download.total_length,
+ 'progress': download.completed_length / download.total_length if download.total_length else 0,
+ 'downloadSpeed': download.download_speed,
+ 'numPeers': 0,
+ 'numSeeds': 0,
+ 'status': download.status,
+ 'bytesDownloaded': download.completed_length,
+ }
+
+ return response
\ No newline at end of file
diff --git a/python_rpc/main.py b/python_rpc/main.py
index 94c34e17..915f1670 100644
--- a/python_rpc/main.py
+++ b/python_rpc/main.py
@@ -147,11 +147,11 @@ def action():
torrent_downloader.start_download(url, data['save_path'])
else:
if existing_downloader and isinstance(existing_downloader, HttpDownloader):
- existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
+ existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'), data.get('allow_multiple_connections', False))
else:
http_downloader = HttpDownloader()
downloads[game_id] = http_downloader
- http_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
+ http_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'), data.get('allow_multiple_connections', False))
downloading_game_id = game_id
diff --git a/scripts/postinstall.cjs b/scripts/postinstall.cjs
index fc3f69dd..6df98f54 100644
--- a/scripts/postinstall.cjs
+++ b/scripts/postinstall.cjs
@@ -123,3 +123,10 @@ const copyAria2 = () => {
copyAria2();
downloadLudusavi();
+
+if (process.platform !== "win32") {
+ const binariesPath = path.join(__dirname, "..", "binaries");
+
+ fs.chmodSync(path.join(binariesPath, "7zz"), 0o755);
+ fs.chmodSync(path.join(binariesPath, "7zzs"), 0o755);
+}
diff --git a/src/main/services/7zip.ts b/src/main/services/7zip.ts
index 08abf389..20315b2f 100644
--- a/src/main/services/7zip.ts
+++ b/src/main/services/7zip.ts
@@ -45,13 +45,13 @@ export class SevenZip {
args.push("-o" + outputPath);
}
+ console.log(this.binaryPath, args);
+
const child = cp.execFile(this.binaryPath, args, {
cwd,
});
child.once("exit", (code) => {
- console.log("EXIT CALLED", code, filePath);
-
if (code === 0) {
successCb();
return;
diff --git a/src/main/services/aria2.ts b/src/main/services/aria2.ts
index a927a1bd..98fd0e13 100644
--- a/src/main/services/aria2.ts
+++ b/src/main/services/aria2.ts
@@ -16,12 +16,6 @@ export class Aria2 {
"--rpc-listen-all",
"--file-allocation=none",
"--allow-overwrite=true",
- "-s",
- "16",
- "-x",
- "16",
- "-k",
- "1M",
],
{ stdio: "inherit", windowsHide: true }
);
diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts
index 9eba39f3..35841d33 100644
--- a/src/main/services/download/download-manager.ts
+++ b/src/main/services/download/download-manager.ts
@@ -371,6 +371,7 @@ export class DownloadManager {
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
+ allow_multiple_connections: true,
};
}
case Downloader.TorBox: {
@@ -383,6 +384,7 @@ export class DownloadManager {
url,
save_path: download.downloadPath,
out: name,
+ allow_multiple_connections: true,
};
}
}
From fbce53d61a36fb490bb93675905f59a4494d87a0 Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Wed, 9 Apr 2025 07:59:34 +0100
Subject: [PATCH 03/53] fix: removing console log
---
src/main/services/7zip.ts | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/main/services/7zip.ts b/src/main/services/7zip.ts
index 20315b2f..9a9f85be 100644
--- a/src/main/services/7zip.ts
+++ b/src/main/services/7zip.ts
@@ -45,8 +45,6 @@ export class SevenZip {
args.push("-o" + outputPath);
}
- console.log(this.binaryPath, args);
-
const child = cp.execFile(this.binaryPath, args, {
cwd,
});
From 97cf02577a19dcfb513859609131ce9131d4eedb Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Wed, 9 Apr 2025 08:00:15 +0100
Subject: [PATCH 04/53] fix: removing console log
---
python_rpc/http_downloader.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py
index 88334fe9..4358373e 100644
--- a/python_rpc/http_downloader.py
+++ b/python_rpc/http_downloader.py
@@ -58,4 +58,4 @@ class HttpDownloader:
'bytesDownloaded': download.completed_length,
}
- return response
\ No newline at end of file
+ return response
From 98ed07d6d2a1aa703190fe0b7273a79d001f9da2 Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Wed, 9 Apr 2025 13:02:22 +0100
Subject: [PATCH 05/53] feat: adding hydra debrid
---
src/main/events/index.ts | 1 +
.../torrenting/check-debrid-availability.ts | 11 ++++
.../services/download/download-manager.ts | 16 ++++++
src/main/services/download/hydra-debrid.ts | 25 +++++++++
src/preload/index.ts | 2 +
src/renderer/src/assets/meteor.svg | 24 +++++++++
.../components/debrid-badge/debrid-badge.scss | 11 ++++
.../components/debrid-badge/debrid-badge.tsx | 15 ++++++
src/renderer/src/components/index.ts | 1 +
src/renderer/src/constants.ts | 1 +
src/renderer/src/declaration.d.ts | 3 ++
.../src/pages/downloads/download-group.tsx | 15 ++++++
.../modals/download-settings-modal.tsx | 4 ++
.../game-details/modals/repacks-modal.tsx | 52 +++++++++++++++++--
src/shared/constants.ts | 2 +
src/shared/index.ts | 7 ++-
16 files changed, 186 insertions(+), 4 deletions(-)
create mode 100644 src/main/events/torrenting/check-debrid-availability.ts
create mode 100644 src/main/services/download/hydra-debrid.ts
create mode 100644 src/renderer/src/assets/meteor.svg
create mode 100644 src/renderer/src/components/debrid-badge/debrid-badge.scss
create mode 100644 src/renderer/src/components/debrid-badge/debrid-badge.tsx
diff --git a/src/main/events/index.ts b/src/main/events/index.ts
index 8465843f..a0b2296b 100644
--- a/src/main/events/index.ts
+++ b/src/main/events/index.ts
@@ -47,6 +47,7 @@ import "./torrenting/resume-game-download";
import "./torrenting/start-game-download";
import "./torrenting/pause-game-seed";
import "./torrenting/resume-game-seed";
+import "./torrenting/check-debrid-availability";
import "./user-preferences/get-user-preferences";
import "./user-preferences/update-user-preferences";
import "./user-preferences/auto-launch";
diff --git a/src/main/events/torrenting/check-debrid-availability.ts b/src/main/events/torrenting/check-debrid-availability.ts
new file mode 100644
index 00000000..447c3e45
--- /dev/null
+++ b/src/main/events/torrenting/check-debrid-availability.ts
@@ -0,0 +1,11 @@
+import { HydraDebridClient } from "@main/services/download/hydra-debrid";
+import { registerEvent } from "../register-event";
+
+const checkDebridAvailability = async (
+ _event: Electron.IpcMainInvokeEvent,
+ magnets: string[]
+) => {
+ return HydraDebridClient.getAvailableMagnets(magnets);
+};
+
+registerEvent("checkDebridAvailability", checkDebridAvailability);
diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts
index 35841d33..7401683e 100644
--- a/src/main/services/download/download-manager.ts
+++ b/src/main/services/download/download-manager.ts
@@ -23,6 +23,7 @@ import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es";
import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager";
+import { HydraDebridClient } from "./hydra-debrid";
export class DownloadManager {
private static downloadingGameId: string | null = null;
@@ -387,6 +388,21 @@ export class DownloadManager {
allow_multiple_connections: true,
};
}
+ case Downloader.Hydra: {
+ const downloadUrl = await HydraDebridClient.getDownloadUrl(
+ download.uri
+ );
+
+ if (!downloadUrl) throw new Error(DownloadError.NotCachedInHydra);
+
+ return {
+ action: "start",
+ game_id: downloadId,
+ url: downloadUrl,
+ save_path: download.downloadPath,
+ allow_multiple_connections: true,
+ };
+ }
}
}
diff --git a/src/main/services/download/hydra-debrid.ts b/src/main/services/download/hydra-debrid.ts
new file mode 100644
index 00000000..de754947
--- /dev/null
+++ b/src/main/services/download/hydra-debrid.ts
@@ -0,0 +1,25 @@
+import { HydraApi } from "../hydra-api";
+
+export class HydraDebridClient {
+ public static getAvailableMagnets(
+ magnets: string[]
+ ): Promise> {
+ return HydraApi.put(
+ "/debrid/check-availability",
+ {
+ magnets,
+ },
+ { needsAuth: false }
+ );
+ }
+
+ public static getDownloadUrl(magnet: string) {
+ try {
+ return HydraApi.post("/debrid/request-file", {
+ magnet,
+ }).then((response) => response.downloadUrl);
+ } catch (error) {
+ return null;
+ }
+ }
+}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 280c0cc4..a7e06f90 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -55,6 +55,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("on-seeding-status", listener);
return () => ipcRenderer.removeListener("on-seeding-status", listener);
},
+ checkDebridAvailability: (magnets: string[]) =>
+ ipcRenderer.invoke("checkDebridAvailability", magnets),
/* Catalogue */
searchGames: (payload: CatalogueSearchPayload, take: number, skip: number) =>
diff --git a/src/renderer/src/assets/meteor.svg b/src/renderer/src/assets/meteor.svg
new file mode 100644
index 00000000..95174efa
--- /dev/null
+++ b/src/renderer/src/assets/meteor.svg
@@ -0,0 +1,24 @@
+
diff --git a/src/renderer/src/components/debrid-badge/debrid-badge.scss b/src/renderer/src/components/debrid-badge/debrid-badge.scss
new file mode 100644
index 00000000..16ef5464
--- /dev/null
+++ b/src/renderer/src/components/debrid-badge/debrid-badge.scss
@@ -0,0 +1,11 @@
+.debrid-badge {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border-radius: 4px;
+ border: 1px solid rgba(12, 241, 202, 0.3);
+ background: rgba(12, 241, 202, 0.05);
+ color: #0cf1ca;
+ padding: 4px 8px;
+ font-size: 12px;
+}
diff --git a/src/renderer/src/components/debrid-badge/debrid-badge.tsx b/src/renderer/src/components/debrid-badge/debrid-badge.tsx
new file mode 100644
index 00000000..de8e3b75
--- /dev/null
+++ b/src/renderer/src/components/debrid-badge/debrid-badge.tsx
@@ -0,0 +1,15 @@
+import Meteor from "@renderer/assets/meteor.svg?react";
+import "./debrid-badge.scss";
+
+export interface DebridBadgeProps {
+ collapsed?: boolean;
+}
+
+export function DebridBadge({ collapsed }: Readonly) {
+ return (
+
+
+ {!collapsed && "Baixe até 2x mais rápido com Nimbus"}
+
+ );
+}
diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts
index 65d07440..8373e0dc 100644
--- a/src/renderer/src/components/index.ts
+++ b/src/renderer/src/components/index.ts
@@ -14,3 +14,4 @@ export * from "./toast/toast";
export * from "./badge/badge";
export * from "./confirmation-modal/confirmation-modal";
export * from "./suspense-wrapper/suspense-wrapper";
+export * from "./debrid-badge/debrid-badge";
diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts
index 3706d7d2..472ed3a7 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.Hydra]: "Nimbus",
};
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts
index 791370a2..dd2f24d7 100644
--- a/src/renderer/src/declaration.d.ts
+++ b/src/renderer/src/declaration.d.ts
@@ -59,6 +59,9 @@ declare global {
cb: (value: SeedingStatus[]) => void
) => () => Electron.IpcRenderer;
onHardDelete: (cb: () => void) => () => Electron.IpcRenderer;
+ checkDebridAvailability: (
+ magnets: string[]
+ ) => Promise>;
/* Catalogue */
searchGames: (
diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx
index 33f4b812..fa0be02a 100644
--- a/src/renderer/src/pages/downloads/download-group.tsx
+++ b/src/renderer/src/pages/downloads/download-group.tsx
@@ -374,6 +374,21 @@ export function DownloadGroup({
)}
+
+ {game.download?.downloader === Downloader.Hydra && (
+
+ )}
);
})}
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 0200609f..d2237439 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
@@ -83,6 +83,10 @@ export function DownloadSettingsModal({
const getDefaultDownloader = useCallback(
(availableDownloaders: Downloader[]) => {
+ if (availableDownloaders.includes(Downloader.Hydra)) {
+ return Downloader.Hydra;
+ }
+
if (availableDownloaders.includes(Downloader.TorBox)) {
return Downloader.TorBox;
}
diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
index b3879758..5b225380 100644
--- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
+++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
@@ -1,7 +1,13 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
-import { Badge, Button, Modal, TextField } from "@renderer/components";
+import {
+ Badge,
+ Button,
+ DebridBadge,
+ Modal,
+ TextField,
+} from "@renderer/components";
import type { GameRepack } from "@types";
import { DownloadSettingsModal } from "./download-settings-modal";
@@ -31,16 +37,52 @@ export function RepacksModal({
const [repack, setRepack] = useState(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
+ const [hashesInDebrid, setHashesInDebrid] = useState>(
+ {}
+ );
+
const { repacks, game } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const { formatDate } = useDate();
- const sortedRepacks = useMemo(() => {
- return orderBy(repacks, (repack) => repack.uploadDate, "desc");
+ const getHashFromMagnet = (magnet: string) => {
+ if (!magnet || typeof magnet !== "string") {
+ return null;
+ }
+
+ const hashRegex = /xt=urn:btih:([a-zA-Z0-9]+)/i;
+ const match = magnet.match(hashRegex);
+
+ return match ? match[1].toLowerCase() : null;
+ };
+
+ useEffect(() => {
+ const magnets = repacks.flatMap((repack) =>
+ repack.uris.filter((uri) => uri.startsWith("magnet:"))
+ );
+
+ window.electron.checkDebridAvailability(magnets).then((availableHashes) => {
+ setHashesInDebrid(availableHashes);
+ });
}, [repacks]);
+ const sortedRepacks = useMemo(() => {
+ return orderBy(
+ repacks,
+ [
+ (repack) => {
+ const magnet = repack.uris.find((uri) => uri.startsWith("magnet:"));
+ const hash = magnet ? getHashFromMagnet(magnet) : null;
+ return hash ? (hashesInDebrid[hash] ?? false) : false;
+ },
+ (repack) => repack.uploadDate,
+ ],
+ ["desc", "desc"]
+ );
+ }, [repacks, hashesInDebrid]);
+
useEffect(() => {
setFilteredRepacks(sortedRepacks);
}, [sortedRepacks, visible, game]);
@@ -110,6 +152,10 @@ export function RepacksModal({
{repack.fileSize} - {repack.repacker} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
+
+ {hashesInDebrid[getHashFromMagnet(repack.uris[0]) ?? ""] && (
+
+ )}
);
})}
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 1b3dc9a4..54827ce3 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -7,6 +7,7 @@ export enum Downloader {
Datanodes,
Mediafire,
TorBox,
+ Hydra,
}
export enum DownloadSourceStatus {
@@ -56,6 +57,7 @@ export enum DownloadError {
NotCachedInTorbox = "download_error_not_cached_in_torbox",
GofileQuotaExceeded = "download_error_gofile_quota_exceeded",
RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized",
+ NotCachedInHydra = "download_error_not_cached_in_hydra",
}
export const FILE_EXTENSIONS_TO_EXTRACT = [".rar", ".zip", ".7z"];
diff --git a/src/shared/index.ts b/src/shared/index.ts
index 0470728c..f5e097bc 100644
--- a/src/shared/index.ts
+++ b/src/shared/index.ts
@@ -111,7 +111,12 @@ export const getDownloadersForUri = (uri: string) => {
return [Downloader.RealDebrid];
if (uri.startsWith("magnet:")) {
- return [Downloader.Torrent, Downloader.TorBox, Downloader.RealDebrid];
+ return [
+ Downloader.Torrent,
+ Downloader.Hydra,
+ Downloader.TorBox,
+ Downloader.RealDebrid,
+ ];
}
return [];
From 2ee3bebfc78aad5795ceeba1ed6e64b262e68131 Mon Sep 17 00:00:00 2001
From: Hachi-R
Date: Wed, 9 Apr 2025 11:20:42 -0300
Subject: [PATCH 06/53] fix: remove allow_multiple_connections from download
options
---
python_rpc/http_downloader.py | 9 +--------
src/main/services/download/download-manager.ts | 3 ---
2 files changed, 1 insertion(+), 11 deletions(-)
diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py
index 4358373e..c5d5f85b 100644
--- a/python_rpc/http_downloader.py
+++ b/python_rpc/http_downloader.py
@@ -11,7 +11,7 @@ class HttpDownloader:
)
)
- def start_download(self, url: str, save_path: str, header: str, out: str = None, allow_multiple_connections: bool = False):
+ def start_download(self, url: str, save_path: str, header: str, out: str = None):
if self.download:
self.aria2.resume([self.download])
else:
@@ -20,13 +20,6 @@ class HttpDownloader:
"dir": save_path,
"out": out
}
-
- if allow_multiple_connections:
- options.update({
- "split": "16",
- "max-connection-per-server": "16",
- "min-split-size": "1M"
- })
downloads = self.aria2.add(url, options=options)
diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts
index 7401683e..424617df 100644
--- a/src/main/services/download/download-manager.ts
+++ b/src/main/services/download/download-manager.ts
@@ -372,7 +372,6 @@ export class DownloadManager {
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
- allow_multiple_connections: true,
};
}
case Downloader.TorBox: {
@@ -385,7 +384,6 @@ export class DownloadManager {
url,
save_path: download.downloadPath,
out: name,
- allow_multiple_connections: true,
};
}
case Downloader.Hydra: {
@@ -400,7 +398,6 @@ export class DownloadManager {
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
- allow_multiple_connections: true,
};
}
}
From 7c468ac9bbbc22e42a22bc7de7840356aff88bfe Mon Sep 17 00:00:00 2001
From: Hachi-R
Date: Wed, 9 Apr 2025 11:29:12 -0300
Subject: [PATCH 07/53] fix: remove allow_multiple_connections from download
method
---
python_rpc/main.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/python_rpc/main.py b/python_rpc/main.py
index 915f1670..94c34e17 100644
--- a/python_rpc/main.py
+++ b/python_rpc/main.py
@@ -147,11 +147,11 @@ def action():
torrent_downloader.start_download(url, data['save_path'])
else:
if existing_downloader and isinstance(existing_downloader, HttpDownloader):
- existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'), data.get('allow_multiple_connections', False))
+ existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
else:
http_downloader = HttpDownloader()
downloads[game_id] = http_downloader
- http_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'), data.get('allow_multiple_connections', False))
+ http_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
downloading_game_id = game_id
From 4da0dac0e680c87c93a9a4571311743397db1660 Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Wed, 9 Apr 2025 16:02:50 +0100
Subject: [PATCH 08/53] feat: adding hydra debrid
---
python_rpc/http_downloader.py | 2 +-
requirements.txt | 1 +
src/main/services/aria2.ts | 1 +
3 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py
index 4358373e..e0f9ba60 100644
--- a/python_rpc/http_downloader.py
+++ b/python_rpc/http_downloader.py
@@ -23,7 +23,7 @@ class HttpDownloader:
if allow_multiple_connections:
options.update({
- "split": "16",
+ "split": "4",
"max-connection-per-server": "16",
"min-split-size": "1M"
})
diff --git a/requirements.txt b/requirements.txt
index ffdfb59b..4fb02d9d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,3 +6,4 @@ psutil
Pillow
flask
aria2p
+requests
diff --git a/src/main/services/aria2.ts b/src/main/services/aria2.ts
index 98fd0e13..76087c3b 100644
--- a/src/main/services/aria2.ts
+++ b/src/main/services/aria2.ts
@@ -16,6 +16,7 @@ export class Aria2 {
"--rpc-listen-all",
"--file-allocation=none",
"--allow-overwrite=true",
+ "--disk-cache=64M",
],
{ stdio: "inherit", windowsHide: true }
);
From 622fc393fca91e732b6caa7ba9e081fa1806adc8 Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Wed, 9 Apr 2025 17:06:35 +0100
Subject: [PATCH 09/53] fix: vibe coding
---
python_rpc/http_downloader.py | 61 -----------------------------------
1 file changed, 61 deletions(-)
delete mode 100644 python_rpc/http_downloader.py
diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py
deleted file mode 100644
index 60c63ce7..00000000
--- a/python_rpc/http_downloader.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import aria2p
-
-class HttpDownloader:
- def __init__(self):
- self.download = None
- self.aria2 = aria2p.API(
- aria2p.Client(
- host="http://localhost",
- port=6800,
- secret=""
- )
- )
-
- def start_download(self, url: str, save_path: str, header: str, out: str = None, allow_multiple_connections: bool = False):
- if self.download:
- self.aria2.resume([self.download])
- else:
- options = {
- "header": header,
- "dir": save_path,
- "out": out
- }
-
- if allow_multiple_connections:
- options.update({
- "split": "4",
- "max-connection-per-server": "16",
- "min-split-size": "2M"
- })
-
- downloads = self.aria2.add(url, options=options)
-
- self.download = downloads[0]
-
- def pause_download(self):
- if self.download:
- self.aria2.pause([self.download])
-
- def cancel_download(self):
- if self.download:
- self.aria2.remove([self.download])
- self.download = None
-
- def get_download_status(self):
- if self.download == None:
- return None
-
- download = self.aria2.get_download(self.download.gid)
-
- response = {
- 'folderName': download.name,
- 'fileSize': download.total_length,
- 'progress': download.completed_length / download.total_length if download.total_length else 0,
- 'downloadSpeed': download.download_speed,
- 'numPeers': 0,
- 'numSeeds': 0,
- 'status': download.status,
- 'bytesDownloaded': download.completed_length,
- }
-
- return response
From 5b0ea980de29fddec48ffdeddf280a330b495985 Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Wed, 9 Apr 2025 17:07:45 +0100
Subject: [PATCH 10/53] fix: vibe coding
---
python_rpc/http_downloader.py | 184 ++++++++++++++++++++++++++++++++++
1 file changed, 184 insertions(+)
create mode 100644 python_rpc/http_downloader.py
diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py
new file mode 100644
index 00000000..c2a617e8
--- /dev/null
+++ b/python_rpc/http_downloader.py
@@ -0,0 +1,184 @@
+import os
+import requests
+import threading
+import time
+import urllib.parse
+import re
+from typing import Dict, Optional
+
+
+class HttpDownloader:
+ def __init__(self):
+ self.download = None
+ self.thread = None
+ self.stop_download = False
+ self.download_info = None
+
+ def start_download(self, url: str, save_path: str, header: str, out: str = None, allow_multiple_connections: bool = False):
+ """Start a download with the given parameters"""
+ # Parse header string into dictionary
+ headers = {}
+ if header:
+ for line in header.split('\n'):
+ if ':' in line:
+ key, value = line.split(':', 1)
+ headers[key.strip()] = value.strip()
+
+ # Determine output filename
+ if out:
+ filename = out
+ else:
+ # Extract filename from URL
+ raw_filename = self._extract_filename_from_url(url)
+ if not raw_filename:
+ filename = 'download'
+ else:
+ filename = raw_filename
+
+ # Create full path
+ if not os.path.exists(save_path):
+ os.makedirs(save_path)
+
+ full_path = os.path.join(save_path, filename)
+
+ # Initialize download info
+ self.download_info = {
+ 'url': url,
+ 'save_path': save_path,
+ 'full_path': full_path,
+ 'headers': headers,
+ 'filename': filename,
+ 'folderName': filename,
+ 'fileSize': 0,
+ 'progress': 0,
+ 'downloadSpeed': 0,
+ 'status': 'waiting',
+ 'bytesDownloaded': 0,
+ 'start_time': time.time()
+ }
+
+ # Start download in a separate thread
+ self.stop_download = False
+ self.thread = threading.Thread(target=self._download_worker)
+ self.thread.daemon = True
+ self.thread.start()
+
+ def _download_worker(self):
+ """Worker thread that performs the actual download"""
+ url = self.download_info['url']
+ full_path = self.download_info['full_path']
+ headers = self.download_info['headers']
+
+ try:
+ # Start with a HEAD request to get file size
+ head_response = requests.head(url, headers=headers, allow_redirects=True)
+ total_size = int(head_response.headers.get('content-length', 0))
+ self.download_info['fileSize'] = total_size
+
+ # Open the request as a stream
+ self.download_info['status'] = 'active'
+ response = requests.get(url, headers=headers, stream=True, allow_redirects=True)
+ response.raise_for_status()
+
+ # If we didn't get file size from HEAD request, try from GET
+ if total_size == 0:
+ total_size = int(response.headers.get('content-length', 0))
+ self.download_info['fileSize'] = total_size
+
+ downloaded = 0
+ start_time = time.time()
+ last_update_time = start_time
+ bytes_since_last_update = 0
+
+ with open(full_path, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self.stop_download:
+ self.download_info['status'] = 'paused'
+ return
+
+ if chunk:
+ f.write(chunk)
+ downloaded += len(chunk)
+ bytes_since_last_update += len(chunk)
+
+ # Update progress and speed every 0.5 seconds
+ current_time = time.time()
+ if current_time - last_update_time >= 0.5:
+ elapsed = current_time - last_update_time
+ speed = bytes_since_last_update / elapsed if elapsed > 0 else 0
+
+ self.download_info['bytesDownloaded'] = downloaded
+ self.download_info['progress'] = downloaded / total_size if total_size > 0 else 0
+ self.download_info['downloadSpeed'] = speed
+
+ last_update_time = current_time
+ bytes_since_last_update = 0
+
+ # Download completed
+ self.download_info['status'] = 'complete'
+ self.download_info['progress'] = 1.0
+ self.download_info['bytesDownloaded'] = total_size
+
+ except requests.exceptions.RequestException as e:
+ self.download_info['status'] = 'error'
+ print(f"Download error: {str(e)}")
+
+ def pause_download(self):
+ """Pause the current download (actually stops it)"""
+ if self.thread and self.thread.is_alive():
+ self.stop_download = True
+ if self.download_info:
+ self.download_info['status'] = 'paused'
+
+ def cancel_download(self):
+ """Cancel the current download and reset the download object"""
+ self.pause_download()
+ if self.download_info:
+ # Attempt to delete the partial file
+ try:
+ if os.path.exists(self.download_info['full_path']):
+ os.remove(self.download_info['full_path'])
+ except:
+ pass
+ self.download_info['status'] = 'removed'
+ self.download_info = None
+
+ def _extract_filename_from_url(self, url: str) -> str:
+ """Extract a clean filename from URL, handling URL encoding and query parameters"""
+ # Parse the URL to get the path
+ parsed_url = urllib.parse.urlparse(url)
+
+ # Extract the path component
+ path = parsed_url.path
+
+ # Get the last part of the path (filename with potential URL encoding)
+ encoded_filename = os.path.basename(path)
+
+ # URL decode the filename
+ decoded_filename = urllib.parse.unquote(encoded_filename)
+
+ # Remove query parameters if present
+ if '?' in decoded_filename:
+ decoded_filename = decoded_filename.split('?')[0]
+
+ # If we get an empty string, use the domain as a fallback
+ if not decoded_filename:
+ return 'download'
+
+ return decoded_filename
+
+ def get_download_status(self) -> Optional[Dict]:
+ """Get the current status of the download"""
+ if not self.download_info:
+ return None
+
+ return {
+ 'folderName': self.download_info['filename'],
+ 'fileSize': self.download_info['fileSize'],
+ 'progress': self.download_info['progress'],
+ 'downloadSpeed': self.download_info['downloadSpeed'],
+ 'numPeers': 0, # Not applicable for HTTP
+ 'numSeeds': 0, # Not applicable for HTTP
+ 'status': self.download_info['status'],
+ 'bytesDownloaded': self.download_info['bytesDownloaded'],
+ }
From 9264fa3664b8bc0c312053f8ce9a7c8278d4c9c6 Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Wed, 9 Apr 2025 17:10:57 +0100
Subject: [PATCH 11/53] fix: vibe coding
---
python_rpc/http_downloader.py | 121 +++++++++++++++++++++++++++-------
1 file changed, 97 insertions(+), 24 deletions(-)
diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py
index c2a617e8..42d200ba 100644
--- a/python_rpc/http_downloader.py
+++ b/python_rpc/http_downloader.py
@@ -4,14 +4,15 @@ import threading
import time
import urllib.parse
import re
-from typing import Dict, Optional
+from typing import Dict, Optional, Union
class HttpDownloader:
def __init__(self):
self.download = None
self.thread = None
- self.stop_download = False
+ self.pause_event = threading.Event()
+ self.cancel_event = threading.Event()
self.download_info = None
def start_download(self, url: str, save_path: str, header: str, out: str = None, allow_multiple_connections: bool = False):
@@ -54,11 +55,15 @@ class HttpDownloader:
'downloadSpeed': 0,
'status': 'waiting',
'bytesDownloaded': 0,
- 'start_time': time.time()
+ 'start_time': time.time(),
+ 'supports_resume': False
}
+ # Reset events
+ self.pause_event.clear()
+ self.cancel_event.clear()
+
# Start download in a separate thread
- self.stop_download = False
self.thread = threading.Thread(target=self._download_worker)
self.thread.daemon = True
self.thread.start()
@@ -67,14 +72,44 @@ class HttpDownloader:
"""Worker thread that performs the actual download"""
url = self.download_info['url']
full_path = self.download_info['full_path']
- headers = self.download_info['headers']
+ headers = self.download_info['headers'].copy()
try:
- # Start with a HEAD request to get file size
+ # Start with a HEAD request to get file size and check if server supports range requests
head_response = requests.head(url, headers=headers, allow_redirects=True)
total_size = int(head_response.headers.get('content-length', 0))
self.download_info['fileSize'] = total_size
+ # Check if server supports range requests
+ accept_ranges = head_response.headers.get('accept-ranges', '')
+ supports_resume = accept_ranges.lower() == 'bytes' and total_size > 0
+ self.download_info['supports_resume'] = supports_resume
+
+ # Check if we're resuming a download
+ file_exists = os.path.exists(full_path)
+ downloaded = 0
+
+ if file_exists and supports_resume:
+ # Get current file size for resume
+ downloaded = os.path.getsize(full_path)
+
+ # If file is already complete, mark as done
+ if downloaded >= total_size and total_size > 0:
+ self.download_info['status'] = 'complete'
+ self.download_info['progress'] = 1.0
+ self.download_info['bytesDownloaded'] = total_size
+ return
+
+ # Add range header for resuming
+ if downloaded > 0:
+ headers['Range'] = f'bytes={downloaded}-'
+ self.download_info['bytesDownloaded'] = downloaded
+ self.download_info['progress'] = downloaded / total_size if total_size > 0 else 0
+ elif file_exists:
+ # If server doesn't support resume but file exists, delete and start over
+ os.remove(full_path)
+ downloaded = 0
+
# Open the request as a stream
self.download_info['status'] = 'active'
response = requests.get(url, headers=headers, stream=True, allow_redirects=True)
@@ -83,19 +118,39 @@ class HttpDownloader:
# If we didn't get file size from HEAD request, try from GET
if total_size == 0:
total_size = int(response.headers.get('content-length', 0))
+ if 'content-range' in response.headers:
+ content_range = response.headers['content-range']
+ match = re.search(r'bytes \d+-\d+/(\d+)', content_range)
+ if match:
+ total_size = int(match.group(1))
+
self.download_info['fileSize'] = total_size
- downloaded = 0
+ # Setup for tracking speed
start_time = time.time()
last_update_time = start_time
bytes_since_last_update = 0
- with open(full_path, 'wb') as f:
+ # Open file in append mode if resuming, otherwise write mode
+ mode = 'ab' if downloaded > 0 and supports_resume else 'wb'
+ with open(full_path, mode) as f:
for chunk in response.iter_content(chunk_size=8192):
- if self.stop_download:
- self.download_info['status'] = 'paused'
+ # Check if cancelled
+ if self.cancel_event.is_set():
+ self.download_info['status'] = 'cancelled'
return
+ # Check if paused
+ if self.pause_event.is_set():
+ self.download_info['status'] = 'paused'
+ # Wait until resumed or cancelled
+ while self.pause_event.is_set() and not self.cancel_event.is_set():
+ time.sleep(0.5)
+
+ # Update status if resumed
+ if not self.cancel_event.is_set():
+ self.download_info['status'] = 'active'
+
if chunk:
f.write(chunk)
downloaded += len(chunk)
@@ -124,23 +179,40 @@ class HttpDownloader:
print(f"Download error: {str(e)}")
def pause_download(self):
- """Pause the current download (actually stops it)"""
+ """Pause the current download"""
if self.thread and self.thread.is_alive():
- self.stop_download = True
- if self.download_info:
- self.download_info['status'] = 'paused'
+ self.pause_event.set()
+ self.download_info['status'] = 'pausing' # Intermediate state until worker confirms
+
+ def resume_download(self):
+ """Resume a paused download"""
+ if self.download_info and self.download_info['status'] == 'paused':
+ self.pause_event.clear()
+ # If thread is no longer alive, restart it
+ if not self.thread or not self.thread.is_alive():
+ self.thread = threading.Thread(target=self._download_worker)
+ self.thread.daemon = True
+ self.thread.start()
def cancel_download(self):
"""Cancel the current download and reset the download object"""
- self.pause_download()
- if self.download_info:
- # Attempt to delete the partial file
- try:
- if os.path.exists(self.download_info['full_path']):
- os.remove(self.download_info['full_path'])
- except:
- pass
- self.download_info['status'] = 'removed'
+ if self.thread and self.thread.is_alive():
+ self.cancel_event.set()
+ self.pause_event.clear() # Clear pause if it was set
+
+ # Give the thread a moment to clean up
+ self.thread.join(timeout=2.0)
+
+ if self.download_info:
+ # Attempt to delete the partial file if not resumable
+ if not self.download_info.get('supports_resume', False):
+ try:
+ if os.path.exists(self.download_info['full_path']):
+ os.remove(self.download_info['full_path'])
+ except:
+ pass
+ self.download_info['status'] = 'cancelled'
+
self.download_info = None
def _extract_filename_from_url(self, url: str) -> str:
@@ -181,4 +253,5 @@ class HttpDownloader:
'numSeeds': 0, # Not applicable for HTTP
'status': self.download_info['status'],
'bytesDownloaded': self.download_info['bytesDownloaded'],
- }
+ 'supports_resume': self.download_info.get('supports_resume', False)
+ }
\ No newline at end of file
From 84600ea0dcfbe859672673fcee99fd1a504cef80 Mon Sep 17 00:00:00 2001
From: Hachi-R
Date: Thu, 10 Apr 2025 15:43:38 -0300
Subject: [PATCH 12/53] feat: implement hydra-httpdl for download management
---
python_rpc/http_downloader.py | 140 ++++++++++++++++++++--------------
1 file changed, 84 insertions(+), 56 deletions(-)
diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py
index 4358373e..4a71607c 100644
--- a/python_rpc/http_downloader.py
+++ b/python_rpc/http_downloader.py
@@ -1,61 +1,89 @@
-import aria2p
+import os
+import subprocess
+import json
class HttpDownloader:
- def __init__(self):
- self.download = None
- self.aria2 = aria2p.API(
- aria2p.Client(
- host="http://localhost",
- port=6800,
- secret=""
- )
- )
+ def __init__(self):
+ self.binaries_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "binaries")
+ self.hydra_exe = os.path.join(self.binaries_path, "hydra-httpdl.exe")
+ self.process = None
+ self.last_status = None
- def start_download(self, url: str, save_path: str, header: str, out: str = None, allow_multiple_connections: bool = False):
- if self.download:
- self.aria2.resume([self.download])
- else:
- options = {
- "header": header,
- "dir": save_path,
- "out": out
- }
+ def start_download(self, url: str, save_path: str, header: str = None, out: str = None, allow_multiple_connections: bool = False):
+ cmd = [self.hydra_exe]
+
+ cmd.append(url)
+
+ cmd.extend([
+ "--chunk-size", "10",
+ "--buffer-size", "16",
+ "--json-output",
+ "--silent"
+ ])
+
+ if allow_multiple_connections:
+ cmd.extend(["--connections", "24"])
+
+ print(f"running hydra-httpdl: {' '.join(cmd)}")
+
+ try:
+ self.process = subprocess.Popen(
+ cmd,
+ cwd=save_path,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ universal_newlines=True
+ )
+ except Exception as e:
+ print(f"error running hydra-httpdl: {e}")
- if allow_multiple_connections:
- options.update({
- "split": "16",
- "max-connection-per-server": "16",
- "min-split-size": "1M"
- })
-
- downloads = self.aria2.add(url, options=options)
+
+
+ def get_download_status(self):
+
+ if not self.process:
+ return None
+
+ try:
+ line = self.process.stdout.readline()
+ if line:
+ status = json.loads(line.strip())
+ self.last_status = status
+ elif self.last_status:
+ status = self.last_status
+ else:
+ return None
+
+ response = {
+ "status": "active",
+ "progress": status["progress"] / 100,
+ "downloadSpeed": status["download_speed"],
+ "numPeers": 0,
+ "numSeeds": 0,
+ "bytesDownloaded": status["bytes_downloaded"],
+ "fileSize": status["file_size"],
+ "folderName": status["file_name"]
+ }
+
+ if status["progress"] == 100.0:
+ response["status"] = "complete"
+
+ return response
+
+ except Exception as e:
+ print(f"error getting download status: {e}")
+ return None
- self.download = downloads[0]
-
- def pause_download(self):
- if self.download:
- self.aria2.pause([self.download])
-
- def cancel_download(self):
- if self.download:
- self.aria2.remove([self.download])
- self.download = None
-
- def get_download_status(self):
- if self.download == None:
- return None
-
- download = self.aria2.get_download(self.download.gid)
-
- response = {
- 'folderName': download.name,
- 'fileSize': download.total_length,
- 'progress': download.completed_length / download.total_length if download.total_length else 0,
- 'downloadSpeed': download.download_speed,
- 'numPeers': 0,
- 'numSeeds': 0,
- 'status': download.status,
- 'bytesDownloaded': download.completed_length,
- }
-
- return response
+
+
+ def stop_download(self):
+ if self.process:
+ self.process.terminate()
+ self.process = None
+ self.last_status = None
+
+ def pause_download(self):
+ self.stop_download()
+
+ def cancel_download(self):
+ self.stop_download()
From 96d59a0fd75ed67358f563d929012ccbbbcb3be5 Mon Sep 17 00:00:00 2001
From: Hachi-R
Date: Thu, 10 Apr 2025 15:43:55 -0300
Subject: [PATCH 13/53] fix: improve game folder deletion logic
---
src/main/events/library/delete-game-folder.ts | 31 ++++++++++++-------
1 file changed, 19 insertions(+), 12 deletions(-)
diff --git a/src/main/events/library/delete-game-folder.ts b/src/main/events/library/delete-game-folder.ts
index 9c290fe0..b9cef25b 100644
--- a/src/main/events/library/delete-game-folder.ts
+++ b/src/main/events/library/delete-game-folder.ts
@@ -13,35 +13,42 @@ const deleteGameFolder = async (
objectId: string
): Promise => {
const downloadKey = levelKeys.game(shop, objectId);
-
const download = await downloadsSublevel.get(downloadKey);
- if (!download) return;
+ if (!download?.folderName) return;
- if (download.folderName) {
- const folderPath = path.join(
- download.downloadPath ?? (await getDownloadsPath()),
- download.folderName
- );
+ const folderPath = path.join(
+ download.downloadPath ?? (await getDownloadsPath()),
+ download.folderName
+ );
- if (fs.existsSync(folderPath)) {
+ const metaPath = `${folderPath}.meta`;
+
+ const deleteFile = async (filePath: string, isDirectory = false) => {
+ if (fs.existsSync(filePath)) {
await new Promise((resolve, reject) => {
fs.rm(
- folderPath,
- { recursive: true, force: true, maxRetries: 5, retryDelay: 200 },
+ filePath,
+ {
+ recursive: isDirectory,
+ force: true,
+ maxRetries: 5,
+ retryDelay: 200,
+ },
(error) => {
if (error) {
logger.error(error);
reject();
}
-
resolve();
}
);
});
}
- }
+ };
+ await deleteFile(folderPath, true);
+ await deleteFile(metaPath);
await downloadsSublevel.del(downloadKey);
};
From d28bb825a3a3d884b223fdb8c3dbcd6abd19f4d2 Mon Sep 17 00:00:00 2001
From: Hachi-R
Date: Thu, 10 Apr 2025 15:44:24 -0300
Subject: [PATCH 14/53] feat: add hydra-httpdl executable
---
binaries/hydra-httpdl.exe | Bin 0 -> 2045952 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 binaries/hydra-httpdl.exe
diff --git a/binaries/hydra-httpdl.exe b/binaries/hydra-httpdl.exe
new file mode 100644
index 0000000000000000000000000000000000000000..ae81a0c97435fcfac5481d483416ab3d21e6aac8
GIT binary patch
literal 2045952
zcmeZ`n!v!!z`(%5z`*eTKLf)K1_*F~P^1JvLws4+R+`;H`RxuzdHVtlKX?Vp#&FAFwP1)6ZGfgXw218^P)yv2uaw$E^E9AG5|^ddRXo^f7BtW^x9|
zEU57e0j>-TDQ&C_zJI=_!^A!?C^2v|@o6(KNH8#%v4e&GScx+*2!NRmj35fcW@2Dq
z1F;zpG(&+rB(NA57?>Fn8ey8CdKobJa1pRccjn76=zuK%a~T*e$T1isL)jq8L7pK+
zje%i-f*eB#h>wmH