diff --git a/python_rpc/http_multi_link_downloader.py b/python_rpc/http_multi_link_downloader.py deleted file mode 100644 index 3968d77c..00000000 --- a/python_rpc/http_multi_link_downloader.py +++ /dev/null @@ -1,151 +0,0 @@ -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 99dd0d8c..295d4eff 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -3,7 +3,6 @@ import sys, json, urllib.parse, psutil from torrent_downloader import TorrentDownloader from http_downloader import HttpDownloader from profile_image_processor import ProfileImageProcessor -from http_multi_link_downloader import HttpMultiLinkDownloader import libtorrent as lt app = Flask(__name__) @@ -25,15 +24,7 @@ if start_download_payload: initial_download = json.loads(urllib.parse.unquote(start_download_payload)) downloading_game_id = initial_download['game_id'] - 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'): + if initial_download['url'].startswith('magnet'): torrent_downloader = TorrentDownloader(torrent_session) downloads[initial_download['game_id']] = torrent_downloader try: @@ -78,14 +69,6 @@ def 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"]) @@ -104,21 +87,7 @@ def seed_status(): if not response: continue - 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 + if response.get('status') == 5: # Torrent seeding check seed_status.append({ 'gameId': game_id, **response, @@ -180,15 +149,7 @@ def action(): existing_downloader = downloads.get(game_id) - 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 url.startswith('magnet'): if existing_downloader and isinstance(existing_downloader, TorrentDownloader): existing_downloader.start_download(url, data['save_path']) else: diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 1b69bc82..669808b8 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -404,6 +404,10 @@ "completed": "Completed", "removed": "Not downloaded", "cancel": "Cancel", + "cancel_download": "Cancel download?", + "cancel_download_description": "Are you sure you want to cancel this download? All downloaded files will be deleted.", + "keep_downloading": "No, keep downloading", + "yes_cancel": "Yes, cancel", "filter": "Filter downloaded games", "remove": "Remove", "downloading_metadata": "Downloading metadata…", @@ -594,7 +598,10 @@ "notification_preview": "Achievement Notification Preview", "enable_friend_start_game_notifications": "When a friend starts playing a game", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", - "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup" + "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", + "downloads": "Downloads", + "use_native_http_downloader": "Use native HTTP downloader (experimental)", + "cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress" }, "notifications": { "download_complete": "Download complete", diff --git a/src/main/main.ts b/src/main/main.ts index 82ea7c47..e06d67ed 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -2,7 +2,7 @@ import { downloadsSublevel } from "./level/sublevels/downloads"; import { orderBy } from "lodash-es"; import { Downloader } from "@shared"; import { levelKeys, db } from "./level"; -import type { UserPreferences } from "@types"; +import type { Download, UserPreferences } from "@types"; import { SystemPath, CommonRedistManager, @@ -18,6 +18,7 @@ import { DeckyPlugin, DownloadSourcesChecker, WSClient, + logger, } from "@main/services"; import { migrateDownloadSources } from "./helpers/migrate-download-sources"; @@ -71,18 +72,47 @@ export const loadState = async () => { return orderBy(games, "timestamp", "desc"); }); - downloads.forEach((download) => { + let interruptedDownload: Download | null = null; + + for (const download of downloads) { + const downloadKey = levelKeys.game(download.shop, download.objectId); + + // Reset extracting state if (download.extracting) { - downloadsSublevel.put(levelKeys.game(download.shop, download.objectId), { + await downloadsSublevel.put(downloadKey, { ...download, extracting: false, }); } - }); - const [nextItemOnQueue] = downloads.filter((game) => game.queued); + // Find interrupted active download (download that was running when app closed) + // Mark it as paused but remember it for auto-resume + if (download.status === "active" && !interruptedDownload) { + interruptedDownload = download; + await downloadsSublevel.put(downloadKey, { + ...download, + status: "paused", + }); + } else if (download.status === "active") { + // Mark other active downloads as paused + await downloadsSublevel.put(downloadKey, { + ...download, + status: "paused", + }); + } + } - const downloadsToSeed = downloads.filter( + // Re-fetch downloads after status updates + const updatedDownloads = await downloadsSublevel + .values() + .all() + .then((games) => orderBy(games, "timestamp", "desc")); + + // Prioritize interrupted download, then queued downloads + const downloadToResume = + interruptedDownload ?? updatedDownloads.find((game) => game.queued); + + const downloadsToSeed = updatedDownloads.filter( (game) => game.shouldSeed && game.downloader === Downloader.Torrent && @@ -90,7 +120,23 @@ export const loadState = async () => { game.uri !== null ); - await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed); + // For torrents or if JS downloader is disabled, use Python RPC + const isTorrent = downloadToResume?.downloader === Downloader.Torrent; + // Default to true - native HTTP downloader is enabled by default + const useJsDownloader = + (userPreferences?.useNativeHttpDownloader ?? true) && !isTorrent; + + if (useJsDownloader && downloadToResume) { + // Start Python RPC for seeding only, then resume HTTP download with JS + await DownloadManager.startRPC(undefined, downloadsToSeed); + await DownloadManager.startDownload(downloadToResume).catch((err) => { + // If resume fails, just log it - user can manually retry + logger.error("Failed to auto-resume download:", err); + }); + } else { + // Use Python RPC for everything (torrent or fallback) + await DownloadManager.startRPC(downloadToResume, downloadsToSeed); + } startMainLoop(); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 86bb15cf..0383c2d3 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -18,7 +18,7 @@ import { } from "./types"; import { calculateETA, getDirSize } from "./helpers"; import { RealDebridClient } from "./real-debrid"; -import path from "path"; +import path from "node:path"; import { logger } from "../logger"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { sortBy } from "lodash-es"; @@ -26,9 +26,13 @@ import { TorBoxClient } from "./torbox"; import { GameFilesManager } from "../game-files-manager"; import { HydraDebridClient } from "./hydra-debrid"; import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters"; +import { JsHttpDownloader } from "./js-http-downloader"; export class DownloadManager { private static downloadingGameId: string | null = null; + private static jsDownloader: JsHttpDownloader | null = null; + private static usingJsDownloader = false; + private static isPreparingDownload = false; private static extractFilename( url: string, @@ -52,7 +56,7 @@ export class DownloadManager { const urlObj = new URL(url); const pathname = urlObj.pathname; const pathParts = pathname.split("/"); - const filename = pathParts[pathParts.length - 1]; + const filename = pathParts.at(-1); if (filename?.includes(".") && filename.length > 0) { return decodeURIComponent(filename); @@ -68,6 +72,34 @@ export class DownloadManager { return filename.replaceAll(/[<>:"/\\|?*]/g, "_"); } + private static resolveFilename( + resumingFilename: string | undefined, + originalUrl: string, + downloadUrl: string + ): string | undefined { + if (resumingFilename) return resumingFilename; + + const extracted = + this.extractFilename(originalUrl, downloadUrl) || + this.extractFilename(downloadUrl); + + return extracted ? this.sanitizeFilename(extracted) : undefined; + } + + private static buildDownloadOptions( + url: string, + savePath: string, + filename: string | undefined, + headers?: Record + ) { + return { + url, + savePath, + filename, + headers, + }; + } + private static createDownloadPayload( directUrl: string, originalUrl: string, @@ -99,6 +131,19 @@ export class DownloadManager { }; } + private static async shouldUseJsDownloader(): Promise { + const userPreferences = await db.get( + levelKeys.userPreferences, + { valueEncoding: "json" } + ); + // Default to true - native HTTP downloader is enabled by default (opt-out) + return userPreferences?.useNativeHttpDownloader ?? true; + } + + private static isHttpDownloader(downloader: Downloader): boolean { + return downloader !== Downloader.Torrent; + } + public static async startRPC( download?: Download, downloadsToSeed?: Download[] @@ -123,7 +168,87 @@ export class DownloadManager { } } - private static async getDownloadStatus() { + private static async getDownloadStatusFromJs(): Promise { + if (!this.downloadingGameId) return null; + + const downloadId = this.downloadingGameId; + + // Return a "preparing" status while fetching download options + if (this.isPreparingDownload) { + try { + const download = await downloadsSublevel.get(downloadId); + if (!download) return null; + + return { + numPeers: 0, + numSeeds: 0, + downloadSpeed: 0, + timeRemaining: -1, + isDownloadingMetadata: true, // Use this to indicate "preparing" + isCheckingFiles: false, + progress: 0, + gameId: downloadId, + download, + }; + } catch { + return null; + } + } + + if (!this.jsDownloader) return null; + + const status = this.jsDownloader.getDownloadStatus(); + if (!status) return null; + + try { + const download = await downloadsSublevel.get(downloadId); + if (!download) return null; + + const { progress, downloadSpeed, bytesDownloaded, fileSize, folderName } = + status; + + // Only update fileSize in database if we actually know it (> 0) + // Otherwise keep the existing value to avoid showing "0 B" + const effectiveFileSize = fileSize > 0 ? fileSize : download.fileSize; + + const updatedDownload = { + ...download, + bytesDownloaded, + fileSize: effectiveFileSize, + progress, + folderName, + status: + status.status === "complete" + ? ("complete" as const) + : ("active" as const), + }; + + if (status.status === "active" || status.status === "complete") { + await downloadsSublevel.put(downloadId, updatedDownload); + } + + return { + numPeers: 0, + numSeeds: 0, + downloadSpeed, + timeRemaining: calculateETA( + effectiveFileSize ?? 0, + bytesDownloaded, + downloadSpeed + ), + isDownloadingMetadata: false, + isCheckingFiles: false, + progress, + gameId: downloadId, + download: updatedDownload, + }; + } catch (err) { + logger.error("[DownloadManager] Error getting JS download status:", err); + return null; + } + } + + private static async getDownloadStatusFromRpc(): Promise { const response = await PythonRPC.rpc.get( "/status" ); @@ -151,28 +276,14 @@ export class DownloadManager { if (!isDownloadingMetadata && !isCheckingFiles) { if (!download) return null; - const updatedDownload = { + await downloadsSublevel.put(downloadId, { ...download, bytesDownloaded, fileSize, progress, folderName, - status: "active" as const, - }; - - await downloadsSublevel.put(downloadId, updatedDownload); - - return { - numPeers, - numSeeds, - downloadSpeed, - timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed), - isDownloadingMetadata, - isCheckingFiles, - progress, - gameId: downloadId, - download: updatedDownload, - } as DownloadProgress; + status: "active", + }); } return { @@ -186,105 +297,141 @@ export class DownloadManager { gameId: downloadId, download, } as DownloadProgress; - } catch (err) { + } catch { return null; } } + private static async getDownloadStatus(): Promise { + if (this.usingJsDownloader) { + return this.getDownloadStatusFromJs(); + } + return this.getDownloadStatusFromRpc(); + } + public static async watchDownloads() { const status = await this.getDownloadStatus(); + if (!status) return; - if (status) { - const { gameId, progress } = status; + const { gameId, progress } = status; + const [download, game] = await Promise.all([ + downloadsSublevel.get(gameId), + gamesSublevel.get(gameId), + ]); - const [download, game] = await Promise.all([ - downloadsSublevel.get(gameId), - gamesSublevel.get(gameId), - ]); + if (!download || !game) return; - if (!download || !game) return; + this.sendProgressUpdate(progress, status, game); - const userPreferences = await db.get( - levelKeys.userPreferences, - { valueEncoding: "json" } + if (progress === 1) { + await this.handleDownloadCompletion(download, game, gameId); + } + } + + private static sendProgressUpdate( + progress: number, + status: DownloadProgress, + game: any + ) { + if (WindowManager.mainWindow) { + WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); + WindowManager.mainWindow.webContents.send( + "on-download-progress", + structuredClone({ ...status, game }) + ); + } + } + + private static async handleDownloadCompletion( + download: Download, + game: any, + gameId: string + ) { + publishDownloadCompleteNotification(game); + + const userPreferences = await db.get( + levelKeys.userPreferences, + { valueEncoding: "json" } + ); + + await this.updateDownloadStatus( + download, + gameId, + userPreferences?.seedAfterDownloadComplete + ); + + if (download.automaticallyExtract) { + this.handleExtraction(download, game); + } + + await this.processNextQueuedDownload(); + } + + private static async updateDownloadStatus( + download: Download, + gameId: string, + shouldSeed?: boolean + ) { + const shouldExtract = download.automaticallyExtract; + + if (shouldSeed && download.downloader === Downloader.Torrent) { + await downloadsSublevel.put(gameId, { + ...download, + status: "seeding", + shouldSeed: true, + queued: false, + extracting: shouldExtract, + }); + } else { + await downloadsSublevel.put(gameId, { + ...download, + status: "complete", + shouldSeed: false, + queued: false, + extracting: shouldExtract, + }); + this.cancelDownload(gameId); + } + } + + private static handleExtraction(download: Download, game: any) { + const gameFilesManager = new GameFilesManager(game.shop, game.objectId); + + if ( + FILE_EXTENSIONS_TO_EXTRACT.some((ext) => + download.folderName?.endsWith(ext) + ) + ) { + gameFilesManager.extractDownloadedFile(); + } else if (download.folderName) { + gameFilesManager + .extractFilesInDirectory( + path.join(download.downloadPath, download.folderName) + ) + .then(() => gameFilesManager.setExtractionComplete()); + } + } + + private static async processNextQueuedDownload() { + const downloads = await downloadsSublevel + .values() + .all() + .then((games) => + sortBy( + games.filter((game) => game.status === "paused" && game.queued), + "timestamp", + "DESC" + ) ); - if (WindowManager.mainWindow && download) { - WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); - WindowManager.mainWindow.webContents.send( - "on-download-progress", - JSON.parse(JSON.stringify({ ...status, game })) - ); - } + const [nextItemOnQueue] = downloads; - const shouldExtract = download.automaticallyExtract; - - if (progress === 1 && download) { - publishDownloadCompleteNotification(game); - - if ( - userPreferences?.seedAfterDownloadComplete && - download.downloader === Downloader.Torrent - ) { - await downloadsSublevel.put(gameId, { - ...download, - status: "seeding", - shouldSeed: true, - queued: false, - extracting: shouldExtract, - }); - } else { - await downloadsSublevel.put(gameId, { - ...download, - status: "complete", - shouldSeed: false, - queued: false, - extracting: shouldExtract, - }); - - this.cancelDownload(gameId); - } - - if (shouldExtract) { - const gameFilesManager = new GameFilesManager( - game.shop, - game.objectId - ); - - if ( - FILE_EXTENSIONS_TO_EXTRACT.some((ext) => - download.folderName?.endsWith(ext) - ) - ) { - gameFilesManager.extractDownloadedFile(); - } else if (download.folderName) { - gameFilesManager - .extractFilesInDirectory( - path.join(download.downloadPath, download.folderName) - ) - .then(() => gameFilesManager.setExtractionComplete()); - } - } - - const downloads = await downloadsSublevel - .values() - .all() - .then((games) => - sortBy( - games.filter((game) => game.status === "paused" && game.queued), - "timestamp", - "DESC" - ) - ); - - const [nextItemOnQueue] = downloads; - - if (nextItemOnQueue) { - this.resumeDownload(nextItemOnQueue); - } else { - this.downloadingGameId = null; - } - } + if (nextItemOnQueue) { + this.resumeDownload(nextItemOnQueue); + } else { + this.downloadingGameId = null; + this.usingJsDownloader = false; + this.jsDownloader = null; } } @@ -324,12 +471,17 @@ export class DownloadManager { } static async pauseDownload(downloadKey = this.downloadingGameId) { - await PythonRPC.rpc - .post("/action", { - action: "pause", - game_id: downloadKey, - } as PauseDownloadPayload) - .catch(() => {}); + if (this.usingJsDownloader && this.jsDownloader) { + logger.log("[DownloadManager] Pausing JS download"); + this.jsDownloader.pauseDownload(); + } else { + await PythonRPC.rpc + .post("/action", { + action: "pause", + game_id: downloadKey, + } as PauseDownloadPayload) + .catch(() => {}); + } if (downloadKey === this.downloadingGameId) { WindowManager.mainWindow?.setProgressBar(-1); @@ -342,14 +494,23 @@ export class DownloadManager { } static async cancelDownload(downloadKey = this.downloadingGameId) { - await PythonRPC.rpc - .post("/action", { action: "cancel", game_id: downloadKey }) - .catch((err) => logger.error("Failed to cancel game download", err)); + if (this.usingJsDownloader && this.jsDownloader) { + logger.log("[DownloadManager] Cancelling JS download"); + this.jsDownloader.cancelDownload(); + this.jsDownloader = null; + this.usingJsDownloader = false; + } else if (!this.isPreparingDownload) { + await PythonRPC.rpc + .post("/action", { action: "cancel", game_id: downloadKey }) + .catch((err) => logger.error("Failed to cancel game download", err)); + } if (downloadKey === this.downloadingGameId) { WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.webContents.send("on-download-progress", null); this.downloadingGameId = null; + this.isPreparingDownload = false; + this.usingJsDownloader = false; } } @@ -369,6 +530,241 @@ export class DownloadManager { }); } + private static async getJsDownloadOptions(download: Download): Promise<{ + url: string; + savePath: string; + filename?: string; + headers?: Record; + } | null> { + const resumingFilename = download.folderName || undefined; + + switch (download.downloader) { + case Downloader.Gofile: + return this.getGofileDownloadOptions(download, resumingFilename); + case Downloader.PixelDrain: + return this.getPixelDrainDownloadOptions(download, resumingFilename); + case Downloader.Datanodes: + return this.getDatanodesDownloadOptions(download, resumingFilename); + case Downloader.Buzzheavier: + return this.getBuzzheavierDownloadOptions(download, resumingFilename); + case Downloader.FuckingFast: + return this.getFuckingFastDownloadOptions(download, resumingFilename); + case Downloader.Mediafire: + return this.getMediafireDownloadOptions(download, resumingFilename); + case Downloader.RealDebrid: + return this.getRealDebridDownloadOptions(download, resumingFilename); + case Downloader.TorBox: + return this.getTorBoxDownloadOptions(download, resumingFilename); + case Downloader.Hydra: + return this.getHydraDownloadOptions(download, resumingFilename); + case Downloader.VikingFile: + return this.getVikingFileDownloadOptions(download, resumingFilename); + case Downloader.Rootz: + return this.getRootzDownloadOptions(download, resumingFilename); + default: + return null; + } + } + + private static async getGofileDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const id = download.uri.split("/").pop(); + const token = await GofileApi.authorize(); + const downloadLink = await GofileApi.getDownloadLink(id!); + await GofileApi.checkDownloadUrl(downloadLink); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadLink + ); + return this.buildDownloadOptions( + downloadLink, + download.downloadPath, + filename, + { Cookie: `accountToken=${token}` } + ); + } + + private static async getPixelDrainDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const id = download.uri.split("/").pop(); + const downloadUrl = await PixelDrainApi.getDownloadUrl(id!); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getDatanodesDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getBuzzheavierDownloadOptions( + download: Download, + resumingFilename?: string + ) { + logger.log( + `[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}` + ); + const directUrl = await BuzzheavierApi.getDirectLink(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + directUrl + ); + return this.buildDownloadOptions( + directUrl, + download.downloadPath, + filename + ); + } + + private static async getFuckingFastDownloadOptions( + download: Download, + resumingFilename?: string + ) { + logger.log( + `[DownloadManager] Processing FuckingFast download for URI: ${download.uri}` + ); + const directUrl = await FuckingFastApi.getDirectLink(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + directUrl + ); + return this.buildDownloadOptions( + directUrl, + download.downloadPath, + filename + ); + } + + private static async getMediafireDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const downloadUrl = await MediafireApi.getDownloadUrl(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getRealDebridDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); + if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getTorBoxDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); + if (!url) return null; + return this.buildDownloadOptions( + url, + download.downloadPath, + resumingFilename || name + ); + } + + private static async getHydraDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri); + if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getVikingFileDownloadOptions( + download: Download, + resumingFilename?: string + ) { + logger.log( + `[DownloadManager] Processing VikingFile download for URI: ${download.uri}` + ); + const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getRootzDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const downloadUrl = await RootzApi.getDownloadUrl(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + private static async getDownloadPayload(download: Download) { const downloadId = levelKeys.game(download.shop, download.objectId); @@ -509,24 +905,13 @@ export class DownloadManager { logger.log( `[DownloadManager] Processing VikingFile download for URI: ${download.uri}` ); - try { - const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); - logger.log(`[DownloadManager] VikingFile direct URL obtained`); - return { - action: "start", - game_id: downloadId, - url: downloadUrl, - save_path: download.downloadPath, - header: - "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", - }; - } catch (error) { - logger.error( - `[DownloadManager] Error processing VikingFile download:`, - error - ); - throw error; - } + const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); + return this.createDownloadPayload( + downloadUrl, + download.uri, + downloadId, + download.downloadPath + ); } case Downloader.Rootz: { const downloadUrl = await RootzApi.getDownloadUrl(download.uri); @@ -537,12 +922,54 @@ export class DownloadManager { save_path: download.downloadPath, }; } + default: + return undefined; } } static async startDownload(download: Download) { - const payload = await this.getDownloadPayload(download); - await PythonRPC.rpc.post("/action", payload); - this.downloadingGameId = levelKeys.game(download.shop, download.objectId); + const useJsDownloader = await this.shouldUseJsDownloader(); + const isHttp = this.isHttpDownloader(download.downloader); + const downloadId = levelKeys.game(download.shop, download.objectId); + + if (useJsDownloader && isHttp) { + logger.log("[DownloadManager] Using JS HTTP downloader"); + + // Set preparing state immediately so UI knows download is starting + this.downloadingGameId = downloadId; + this.isPreparingDownload = true; + this.usingJsDownloader = true; + + try { + const options = await this.getJsDownloadOptions(download); + + if (!options) { + this.isPreparingDownload = false; + this.usingJsDownloader = false; + this.downloadingGameId = null; + throw new Error("Failed to get download options for JS downloader"); + } + + this.jsDownloader = new JsHttpDownloader(); + this.isPreparingDownload = false; + + this.jsDownloader.startDownload(options).catch((err) => { + logger.error("[DownloadManager] JS download error:", err); + this.usingJsDownloader = false; + this.jsDownloader = null; + }); + } catch (err) { + this.isPreparingDownload = false; + this.usingJsDownloader = false; + this.downloadingGameId = null; + throw err; + } + } else { + logger.log("[DownloadManager] Using Python RPC downloader"); + const payload = await this.getDownloadPayload(download); + await PythonRPC.rpc.post("/action", payload); + this.downloadingGameId = downloadId; + this.usingJsDownloader = false; + } } } diff --git a/src/main/services/download/index.ts b/src/main/services/download/index.ts index f4e2eddc..cccd8bd4 100644 --- a/src/main/services/download/index.ts +++ b/src/main/services/download/index.ts @@ -1,3 +1,4 @@ export * from "./download-manager"; export * from "./real-debrid"; export * from "./torbox"; +export * from "./js-http-downloader"; diff --git a/src/main/services/download/js-http-downloader.ts b/src/main/services/download/js-http-downloader.ts new file mode 100644 index 00000000..c90c1a95 --- /dev/null +++ b/src/main/services/download/js-http-downloader.ts @@ -0,0 +1,380 @@ +import fs from "node:fs"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { logger } from "../logger"; + +export interface JsHttpDownloaderStatus { + folderName: string; + fileSize: number; + progress: number; + downloadSpeed: number; + numPeers: number; + numSeeds: number; + status: "active" | "paused" | "complete" | "error"; + bytesDownloaded: number; +} + +export interface JsHttpDownloaderOptions { + url: string; + savePath: string; + filename?: string; + headers?: Record; +} + +export class JsHttpDownloader { + private abortController: AbortController | null = null; + private writeStream: fs.WriteStream | null = null; + private currentOptions: JsHttpDownloaderOptions | null = null; + + private bytesDownloaded = 0; + private fileSize = 0; + private downloadSpeed = 0; + private status: "active" | "paused" | "complete" | "error" = "paused"; + private folderName = ""; + private lastSpeedUpdate = Date.now(); + private bytesAtLastSpeedUpdate = 0; + private isDownloading = false; + + async startDownload(options: JsHttpDownloaderOptions): Promise { + if (this.isDownloading) { + logger.log( + "[JsHttpDownloader] Download already in progress, resuming..." + ); + return this.resumeDownload(); + } + + this.currentOptions = options; + this.abortController = new AbortController(); + this.status = "active"; + this.isDownloading = true; + + const { url, savePath, filename, headers = {} } = options; + const { filePath, startByte, usedFallback } = this.prepareDownloadPath( + savePath, + filename, + url + ); + const requestHeaders = this.buildRequestHeaders(headers, startByte); + + try { + await this.executeDownload( + url, + requestHeaders, + filePath, + startByte, + savePath, + usedFallback + ); + } catch (err) { + this.handleDownloadError(err as Error); + } finally { + this.isDownloading = false; + this.cleanup(); + } + } + + private prepareDownloadPath( + savePath: string, + filename: string | undefined, + url: string + ): { filePath: string; startByte: number; usedFallback: boolean } { + const extractedFilename = filename || this.extractFilename(url); + const usedFallback = !extractedFilename; + const resolvedFilename = extractedFilename || "download"; + this.folderName = resolvedFilename; + const filePath = path.join(savePath, resolvedFilename); + + if (!fs.existsSync(savePath)) { + fs.mkdirSync(savePath, { recursive: true }); + } + + let startByte = 0; + if (fs.existsSync(filePath)) { + const stats = fs.statSync(filePath); + startByte = stats.size; + this.bytesDownloaded = startByte; + logger.log(`[JsHttpDownloader] Resuming download from byte ${startByte}`); + } + + this.resetSpeedTracking(); + return { filePath, startByte, usedFallback }; + } + + private buildRequestHeaders( + headers: Record, + startByte: number + ): Record { + const requestHeaders: Record = { ...headers }; + if (startByte > 0) { + requestHeaders["Range"] = `bytes=${startByte}-`; + } + return requestHeaders; + } + + private resetSpeedTracking(): void { + this.lastSpeedUpdate = Date.now(); + this.bytesAtLastSpeedUpdate = this.bytesDownloaded; + this.downloadSpeed = 0; + } + + private parseFileSize(response: Response, startByte: number): void { + const contentRange = response.headers.get("content-range"); + if (contentRange) { + const match = /bytes \d+-\d+\/(\d+)/.exec(contentRange); + if (match) { + this.fileSize = Number.parseInt(match[1], 10); + } + return; + } + + const contentLength = response.headers.get("content-length"); + if (contentLength) { + this.fileSize = startByte + Number.parseInt(contentLength, 10); + } + } + + private async executeDownload( + url: string, + requestHeaders: Record, + filePath: string, + startByte: number, + savePath: string, + usedFallback: boolean + ): Promise { + const response = await fetch(url, { + headers: requestHeaders, + signal: this.abortController?.signal, + }); + + // Handle 416 Range Not Satisfiable - existing file is larger than server file + // This happens when downloading same game from different source + if (response.status === 416 && startByte > 0) { + logger.log( + "[JsHttpDownloader] Range not satisfiable, deleting existing file and restarting" + ); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + this.bytesDownloaded = 0; + this.resetSpeedTracking(); + + // Retry without Range header + const headersWithoutRange = { ...requestHeaders }; + delete headersWithoutRange["Range"]; + + return this.executeDownload( + url, + headersWithoutRange, + filePath, + 0, + savePath, + usedFallback + ); + } + + if (!response.ok && response.status !== 206) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + this.parseFileSize(response, startByte); + + // If we used "download" fallback, try to get filename from Content-Disposition + let actualFilePath = filePath; + if (usedFallback && startByte === 0) { + const headerFilename = this.parseContentDisposition(response); + if (headerFilename) { + actualFilePath = path.join(savePath, headerFilename); + this.folderName = headerFilename; + logger.log( + `[JsHttpDownloader] Using filename from Content-Disposition: ${headerFilename}` + ); + } + } + + if (!response.body) { + throw new Error("Response body is null"); + } + + const flags = startByte > 0 ? "a" : "w"; + this.writeStream = fs.createWriteStream(actualFilePath, { flags }); + + const readableStream = this.createReadableStream(response.body.getReader()); + await pipeline(readableStream, this.writeStream); + + this.status = "complete"; + this.downloadSpeed = 0; + logger.log("[JsHttpDownloader] Download complete"); + } + + private parseContentDisposition(response: Response): string | undefined { + const header = response.headers.get("content-disposition"); + if (!header) return undefined; + + // Try to extract filename from Content-Disposition header + // Formats: attachment; filename="file.zip" or attachment; filename=file.zip + const filenameMatch = /filename\*?=['"]?(?:UTF-8'')?([^"';\n]+)['"]?/i.exec( + header + ); + if (filenameMatch?.[1]) { + try { + return decodeURIComponent(filenameMatch[1].trim()); + } catch { + return filenameMatch[1].trim(); + } + } + return undefined; + } + + private createReadableStream( + reader: ReadableStreamDefaultReader + ): Readable { + const onChunk = (length: number) => { + this.bytesDownloaded += length; + this.updateSpeed(); + }; + + return new Readable({ + read() { + reader + .read() + .then(({ done, value }) => { + if (done) { + this.push(null); + return; + } + onChunk(value.length); + this.push(Buffer.from(value)); + }) + .catch((err: Error) => { + if (err.name === "AbortError") { + this.push(null); + } else { + this.destroy(err); + } + }); + }, + }); + } + + private handleDownloadError(err: Error): void { + // Handle abort/cancellation errors - these are expected when user pauses/cancels + if ( + err.name === "AbortError" || + (err as NodeJS.ErrnoException).code === "ERR_STREAM_PREMATURE_CLOSE" + ) { + logger.log("[JsHttpDownloader] Download aborted"); + this.status = "paused"; + } else { + logger.error("[JsHttpDownloader] Download error:", err); + this.status = "error"; + throw err; + } + } + + private async resumeDownload(): Promise { + if (!this.currentOptions) { + throw new Error("No download options available for resume"); + } + this.isDownloading = false; + await this.startDownload(this.currentOptions); + } + + pauseDownload(): void { + if (this.abortController) { + logger.log("[JsHttpDownloader] Pausing download"); + this.abortController.abort(); + this.status = "paused"; + this.downloadSpeed = 0; + } + } + + cancelDownload(deleteFile = true): void { + if (this.abortController) { + logger.log("[JsHttpDownloader] Cancelling download"); + this.abortController.abort(); + } + + this.cleanup(); + + if (deleteFile && this.currentOptions && this.status !== "complete") { + const filePath = path.join(this.currentOptions.savePath, this.folderName); + if (fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath); + logger.log("[JsHttpDownloader] Deleted partial file"); + } catch (err) { + logger.error( + "[JsHttpDownloader] Failed to delete partial file:", + err + ); + } + } + } + + this.reset(); + } + + getDownloadStatus(): JsHttpDownloaderStatus | null { + if (!this.currentOptions && this.status !== "active") { + return null; + } + + return { + folderName: this.folderName, + fileSize: this.fileSize, + progress: this.fileSize > 0 ? this.bytesDownloaded / this.fileSize : 0, + downloadSpeed: this.downloadSpeed, + numPeers: 0, + numSeeds: 0, + status: this.status, + bytesDownloaded: this.bytesDownloaded, + }; + } + + private updateSpeed(): void { + const now = Date.now(); + const elapsed = (now - this.lastSpeedUpdate) / 1000; + + if (elapsed >= 1) { + const bytesDelta = this.bytesDownloaded - this.bytesAtLastSpeedUpdate; + this.downloadSpeed = bytesDelta / elapsed; + this.lastSpeedUpdate = now; + this.bytesAtLastSpeedUpdate = this.bytesDownloaded; + } + } + + private extractFilename(url: string): string | undefined { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const pathParts = pathname.split("/"); + const filename = pathParts.at(-1); + + if (filename?.includes(".") && filename.length > 0) { + return decodeURIComponent(filename); + } + } catch { + // Invalid URL + } + return undefined; + } + + private cleanup(): void { + if (this.writeStream) { + this.writeStream.close(); + this.writeStream = null; + } + this.abortController = null; + } + + private reset(): void { + this.currentOptions = null; + this.bytesDownloaded = 0; + this.fileSize = 0; + this.downloadSpeed = 0; + this.status = "paused"; + this.folderName = ""; + this.isDownloading = false; + } +} diff --git a/src/renderer/src/features/download-slice.ts b/src/renderer/src/features/download-slice.ts index f70421c0..702b1f6e 100644 --- a/src/renderer/src/features/download-slice.ts +++ b/src/renderer/src/features/download-slice.ts @@ -31,11 +31,16 @@ export const downloadSlice = createSlice({ reducers: { setLastPacket: (state, action: PayloadAction) => { state.lastPacket = action.payload; - if (!state.gameId && action.payload) state.gameId = action.payload.gameId; + + // Ensure payload exists and has a valid gameId before accessing + const payload = action.payload; + if (!state.gameId && payload?.gameId) { + state.gameId = payload.gameId; + } // Track peak speed and speed history atomically when packet arrives - if (action.payload?.gameId && action.payload.downloadSpeed != null) { - const { gameId, downloadSpeed } = action.payload; + if (payload?.gameId && payload.downloadSpeed != null) { + const { gameId, downloadSpeed } = payload; // Update peak speed if this is higher const currentPeak = state.peakSpeeds[gameId] || 0; diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index ce26a4b9..11c188b0 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,6 +1,6 @@ import type { GameShop, LibraryGame, SeedingStatus } from "@types"; -import { Badge, Button } from "@renderer/components"; +import { Badge, Button, ConfirmationModal } from "@renderer/components"; import { formatDownloadProgress, buildGameDetailsPath, @@ -219,7 +219,7 @@ interface HeroDownloadViewProps { calculateETA: () => string; pauseDownload: (shop: GameShop, objectId: string) => void; resumeDownload: (shop: GameShop, objectId: string) => void; - cancelDownload: (shop: GameShop, objectId: string) => void; + onCancelClick: (shop: GameShop, objectId: string) => void; t: (key: string) => string; } @@ -238,7 +238,7 @@ function HeroDownloadView({ calculateETA, pauseDownload, resumeDownload, - cancelDownload, + onCancelClick, t, }: Readonly) { const navigate = useNavigate(); @@ -353,7 +353,7 @@ function HeroDownloadView({ )} - -
+
    + {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { + return ( +
  • -
    -
    - - {DOWNLOADER_NAME[Number(game.download!.downloader)]} - -
    -
    - {extraction?.visibleId === game.id ? ( - - {t("extracting")} ( - {Math.round(extraction.progress * 100)}%) - - ) : ( - - - {size} - - )} - {game.download?.progress === 1 && seeding && ( - - {t("seeding")} - - )} -
    -
    -
- {isQueuedGroup && ( -
- - {formatDownloadProgress(progress)} - -
-
-
-
- )} - -
- {game.download?.progress === 1 && - (() => { - const actionType = - gameActionTypes[game.id] || "open-folder"; - const isInstall = actionType === "install"; - - return ( - - ); - })()} - {isQueuedGroup && game.download?.progress !== 1 && ( - +

+ {game.title} +

+ +
+
+ + {DOWNLOADER_NAME[Number(game.download!.downloader)]} + +
+
+ {extraction?.visibleId === game.id ? ( + + {t("extracting")} ( + {Math.round(extraction.progress * 100)}%) + + ) : ( + + + {size} + + )} + {game.download?.progress === 1 && seeding && ( + + {t("seeding")} + + )} +
+
+
+ + {isQueuedGroup && ( +
+ + {formatDownloadProgress(progress)} + +
+
+
+
)} - - - -
- - ); - })} - -
+ +
+ {game.download?.progress === 1 && + (() => { + const actionType = + gameActionTypes[game.id] || "open-folder"; + const isInstall = actionType === "install"; + + return ( + + ); + })()} + {isQueuedGroup && game.download?.progress !== 1 && ( + + )} + + + +
+ + ); + })} + + + ); } diff --git a/src/renderer/src/pages/notifications/notifications.scss b/src/renderer/src/pages/notifications/notifications.scss index 20fbc343..f858f577 100644 --- a/src/renderer/src/pages/notifications/notifications.scss +++ b/src/renderer/src/pages/notifications/notifications.scss @@ -91,6 +91,7 @@ display: flex; flex-direction: column; gap: globals.$spacing-unit; + padding-bottom: calc(globals.$spacing-unit * 3); } &__empty { @@ -134,5 +135,6 @@ display: flex; justify-content: center; padding: calc(globals.$spacing-unit * 2); + padding-bottom: calc(globals.$spacing-unit * 3); } } diff --git a/src/renderer/src/pages/settings/settings-general.scss b/src/renderer/src/pages/settings/settings-general.scss index 8a6a0ac1..9f9d698f 100644 --- a/src/renderer/src/pages/settings/settings-general.scss +++ b/src/renderer/src/pages/settings/settings-general.scss @@ -18,6 +18,13 @@ align-self: flex-start; } + &__disabled-hint { + font-size: 13px; + color: globals.$muted-color; + margin-top: calc(globals.$spacing-unit * -1); + font-style: italic; + } + &__volume-control { display: flex; flex-direction: column; diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index c81ced7d..52dd43f8 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -37,6 +37,12 @@ export function SettingsGeneral() { (state) => state.userPreferences.value ); + const lastPacket = useAppSelector((state) => state.download.lastPacket); + const hasActiveDownload = + lastPacket !== null && + lastPacket.progress < 1 && + !lastPacket.isDownloadingMetadata; + const [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false); const [installingCommonRedist, setInstallingCommonRedist] = useState(false); @@ -53,6 +59,7 @@ export function SettingsGeneral() { achievementSoundVolume: 15, language: "", customStyles: window.localStorage.getItem("customStyles") || "", + useNativeHttpDownloader: true, }); const [languageOptions, setLanguageOptions] = useState([]); @@ -131,6 +138,8 @@ export function SettingsGeneral() { friendStartGameNotificationsEnabled: userPreferences.friendStartGameNotificationsEnabled ?? true, language: language ?? "en", + useNativeHttpDownloader: + userPreferences.useNativeHttpDownloader ?? true, })); } }, [userPreferences, defaultDownloadsPath]); @@ -248,6 +257,25 @@ export function SettingsGeneral() { }))} /> +

{t("downloads")}

+ + + handleChange({ + useNativeHttpDownloader: !form.useNativeHttpDownloader, + }) + } + /> + + {hasActiveDownload && ( +

+ {t("cannot_change_downloader_while_downloading")} +

+ )} +

{t("notifications")}