diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 704f82a7..bbe922f2 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -184,7 +184,11 @@ "reset_achievements_description": "This will reset all achievements for {{game}}", "reset_achievements_title": "Are you sure?", "reset_achievements_success": "Achievements successfully reset", - "reset_achievements_error": "Failed to reset achievements" + "reset_achievements_error": "Failed to reset achievements", + "download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.", + "download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.", + "download_error_not_cached_in_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.", + "download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available." }, "activation": { "title": "Activate Hydra", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 403754db..b34f1874 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -173,7 +173,11 @@ "reset_achievements_title": "Tem certeza?", "reset_achievements_success": "Conquistas resetadas com sucesso", "reset_achievements_error": "Falha ao resetar conquistas", - "no_write_permission": "Não é possível baixar nesse diretório. Clique aqui para saber mais." + "no_write_permission": "Não é possível baixar nesse diretório. Clique aqui para saber mais.", + "download_error_gofile_quota_exceeded": "Você excedeu sua cota mensal do Gofile. Por favor, aguarde a cota resetar.", + "download_error_real_debrid_account_not_authorized": "Sua conta do Real-Debrid não está autorizada a fazer novos downloads. Por favor, verifique sua assinatura e tente novamente.", + "download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.", + "download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível." }, "activation": { "title": "Ativação", diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index fddda3f3..96e3499a 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -1,11 +1,12 @@ import { registerEvent } from "../register-event"; import type { Download, StartGameDownloadPayload } from "@types"; -import { DownloadManager, HydraApi } from "@main/services"; +import { DownloadManager, HydraApi, logger } from "@main/services"; import { steamGamesWorker } from "@main/workers"; import { createGame } from "@main/services/library-sync"; -import { steamUrlBuilder } from "@shared"; +import { Downloader, DownloadError, steamUrlBuilder } from "@shared"; import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; +import { AxiosError } from "axios"; const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -77,21 +78,49 @@ const startGameDownload = async ( await downloadsSublevel.put(gameKey, download); - await DownloadManager.startDownload(download); + try { + await DownloadManager.startDownload(download); - const updatedGame = await gamesSublevel.get(gameKey); + const updatedGame = await gamesSublevel.get(gameKey); - await Promise.all([ - createGame(updatedGame!).catch(() => {}), - HydraApi.post( - "/games/download", - { - objectId, - shop, - }, - { needsAuth: false } - ).catch(() => {}), - ]); + await Promise.all([ + createGame(updatedGame!).catch(() => {}), + HydraApi.post( + "/games/download", + { + objectId, + shop, + }, + { needsAuth: false } + ).catch(() => {}), + ]); + + return { ok: true }; + } catch (err: unknown) { + logger.error("Failed to start download", err); + + if (err instanceof AxiosError) { + if (err.response?.status === 429 && downloader === Downloader.Gofile) { + return { ok: false, error: DownloadError.GofileQuotaExceeded }; + } + + if ( + err.response?.status === 403 && + downloader === Downloader.RealDebrid + ) { + return { + ok: false, + error: DownloadError.RealDebridAccountNotAuthorized, + }; + } + } + + if (err instanceof Error) { + return { ok: false, error: err.message }; + } + + return { ok: false }; + } }; registerEvent("startGameDownload", startGameDownload); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index d109663e..5c19c1b1 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -1,4 +1,4 @@ -import { Downloader } from "@shared"; +import { Downloader, DownloadError } from "@shared"; import { WindowManager } from "../window-manager"; import { publishDownloadCompleteNotification } from "../notifications"; import type { Download, DownloadProgress, UserPreferences } from "@types"; @@ -264,6 +264,8 @@ export class DownloadManager { const token = await GofileApi.authorize(); const downloadLink = await GofileApi.getDownloadLink(id!); + await GofileApi.checkDownloadUrl(downloadLink); + return { action: "start", game_id: downloadId, @@ -320,10 +322,7 @@ export class DownloadManager { case Downloader.RealDebrid: { const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); - if (!downloadUrl) - throw new Error( - "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available." - ); + if (!downloadUrl) throw new Error(DownloadError.NotCachedInRealDebrid); return { action: "start", diff --git a/src/main/services/hosters/gofile.ts b/src/main/services/hosters/gofile.ts index 2c23556f..5560ad31 100644 --- a/src/main/services/hosters/gofile.ts +++ b/src/main/services/hosters/gofile.ts @@ -60,4 +60,12 @@ export class GofileApi { throw new Error("Failed to get download link"); } + + public static async checkDownloadUrl(url: string) { + return axios.head(url, { + headers: { + Cookie: `accountToken=${this.token}`, + }, + }); + } } diff --git a/src/main/services/hosters/mediafire.ts b/src/main/services/hosters/mediafire.ts index 41f021cb..babb7e7d 100644 --- a/src/main/services/hosters/mediafire.ts +++ b/src/main/services/hosters/mediafire.ts @@ -1,39 +1,54 @@ -import axios, { AxiosResponse } from "axios"; -import { JSDOM } from "jsdom"; +import fetch from "node-fetch"; export class MediafireApi { - private static readonly session = axios.create(); + private static readonly validMediafireIdentifierDL = /^[a-zA-Z0-9]+$/m; + private static readonly validMediafirePreDL = + /(?<=['"])(https?:)?(\/\/)?(www\.)?mediafire\.com\/(file|view|download)\/[^'"?]+\?dkey=[^'"]+(?=['"])/; + private static readonly validDynamicDL = + /(?<=['"])https?:\/\/download\d+\.mediafire\.com\/[^'"]+(?=['"])/; + private static readonly checkHTTP = /^https?:\/\//m; public static async getDownloadUrl(mediafireUrl: string): Promise { - const response: AxiosResponse = await this.session.get( - mediafireUrl, - { - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - }, - maxRedirects: 0, - validateStatus: (status: number) => status === 200 || status === 302, - } - ); + try { + const processedUrl = this.processUrl(mediafireUrl); + const response = await fetch(processedUrl); - if (response.status === 302) { - const location = response.headers["location"]; - if (!location) { - throw new Error("Missing location header in 302 redirect response"); - } - return location; + if (!response.ok) throw new Error("Failed to fetch Mediafire page"); + + const html = await response.text(); + return this.extractDirectUrl(html); + } catch (error) { + throw new Error(`Failed to get download URL`); + } + } + + private static processUrl(url: string): string { + let processed = url.replace("http://", "https://"); + + if (this.validMediafireIdentifierDL.test(processed)) { + processed = `https://mediafire.com/?${processed}`; } - const dom = new JSDOM(response.data); - const downloadButton = dom.window.document.querySelector( - "a#downloadButton" - ) as HTMLAnchorElement; - - if (!downloadButton?.href) { - throw new Error("Download button URL not found in page content"); + if (!this.checkHTTP.test(processed)) { + processed = processed.startsWith("//") + ? `https:${processed}` + : `https://${processed}`; } - return downloadButton.href; + return processed; + } + + private static extractDirectUrl(html: string): string { + const preMatch = this.validMediafirePreDL.exec(html); + if (preMatch?.[0]) { + return preMatch[0]; + } + + const dlMatch = this.validDynamicDL.exec(html); + if (dlMatch?.[0]) { + return dlMatch[0]; + } + + throw new Error("No valid download links found"); } } diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 00a24f64..2d0bf24d 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -128,7 +128,8 @@ export class WindowManager { this.mainWindow.removeMenu(); this.mainWindow.on("ready-to-show", () => { - if (!app.isPackaged) WindowManager.mainWindow?.webContents.openDevTools(); + if (!app.isPackaged || isStaging) + WindowManager.mainWindow?.webContents.openDevTools(); WindowManager.mainWindow?.show(); }); @@ -173,9 +174,7 @@ export class WindowManager { authWindow.removeMenu(); - if (!app.isPackaged || isStaging) { - authWindow.webContents.openDevTools(); - } + if (!app.isPackaged) authWindow.webContents.openDevTools(); authWindow.loadURL( `${import.meta.env.MAIN_VITE_AUTH_URL}${page}?${searchParams.toString()}` diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 00a1d399..c50e5017 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -282,6 +282,7 @@ export function App() { message={toast.message} type={toast.type} onClose={handleToastClose} + duration={toast.duration} /> diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index c6db8649..c1923562 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -41,7 +41,9 @@ declare global { interface Electron { /* Torrenting */ - startGameDownload: (payload: StartGameDownloadPayload) => Promise; + startGameDownload: ( + payload: StartGameDownloadPayload + ) => Promise<{ ok: boolean; error?: string }>; cancelGameDownload: (shop: GameShop, objectId: string) => Promise; pauseGameDownload: (shop: GameShop, objectId: string) => Promise; resumeGameDownload: (shop: GameShop, objectId: string) => Promise; diff --git a/src/renderer/src/features/toast-slice.ts b/src/renderer/src/features/toast-slice.ts index 44abf53a..f5df1d1c 100644 --- a/src/renderer/src/features/toast-slice.ts +++ b/src/renderer/src/features/toast-slice.ts @@ -6,6 +6,7 @@ export interface ToastState { title: string; message?: string; type: ToastProps["type"]; + duration?: number; visible: boolean; } @@ -13,6 +14,7 @@ const initialState: ToastState = { title: "", message: "", type: "success", + duration: 5000, visible: false, }; @@ -24,6 +26,7 @@ export const toastSlice = createSlice({ state.title = action.payload.title; state.message = action.payload.message; state.type = action.payload.type; + state.duration = action.payload.duration ?? 5000; state.visible = true; }, closeToast: (state) => { diff --git a/src/renderer/src/hooks/use-download.ts b/src/renderer/src/hooks/use-download.ts index 2a21dea2..b84ac515 100644 --- a/src/renderer/src/hooks/use-download.ts +++ b/src/renderer/src/hooks/use-download.ts @@ -29,10 +29,11 @@ export function useDownload() { const startDownload = async (payload: StartGameDownloadPayload) => { dispatch(clearDownload()); - const game = await window.electron.startGameDownload(payload); + const response = await window.electron.startGameDownload(payload); - await updateLibrary(); - return game; + if (response.ok) updateLibrary(); + + return response; }; const pauseDownload = async (shop: GameShop, objectId: string) => { diff --git a/src/renderer/src/hooks/use-toast.ts b/src/renderer/src/hooks/use-toast.ts index 5e08a7ab..8b4c3e0f 100644 --- a/src/renderer/src/hooks/use-toast.ts +++ b/src/renderer/src/hooks/use-toast.ts @@ -6,12 +6,13 @@ export function useToast() { const dispatch = useAppDispatch(); const showSuccessToast = useCallback( - (title: string, message?: string) => { + (title: string, message?: string, duration?: number) => { dispatch( showToast({ title, message, type: "success", + duration, }) ); }, @@ -19,12 +20,13 @@ export function useToast() { ); const showErrorToast = useCallback( - (title: string, message?: string) => { + (title: string, message?: string, duration?: number) => { dispatch( showToast({ title, message, type: "error", + duration, }) ); }, @@ -32,12 +34,13 @@ export function useToast() { ); const showWarningToast = useCallback( - (title: string, message?: string) => { + (title: string, message?: string, duration?: number) => { dispatch( showToast({ title, message, type: "warning", + duration, }) ); }, diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index c9a2a9e5..b4f8c20b 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -8,7 +8,7 @@ import * as styles from "./downloads.css"; import { DeleteGameModal } from "./delete-game-modal"; import { DownloadGroup } from "./download-group"; import type { GameShop, LibraryGame, SeedingStatus } from "@types"; -import { orderBy } from "lodash-es"; +import { orderBy, sortBy } from "lodash-es"; import { ArrowDownIcon } from "@primer/octicons-react"; export default function Downloads() { @@ -58,21 +58,24 @@ export default function Downloads() { complete: [], }; - const result = library.reduce((prev, next) => { - /* Game has been manually added to the library or has been canceled */ - if (!next.download?.status || next.download?.status === "removed") - return prev; + const result = sortBy(library, (game) => game.download?.timestamp).reduce( + (prev, next) => { + /* Game has been manually added to the library or has been canceled */ + if (!next.download?.status || next.download?.status === "removed") + return prev; - /* Is downloading */ - if (lastPacket?.gameId === next.id) - return { ...prev, downloading: [...prev.downloading, next] }; + /* Is downloading */ + if (lastPacket?.gameId === next.id) + return { ...prev, downloading: [...prev.downloading, next] }; - /* Is either queued or paused */ - if (next.download.queued || next.download?.status === "paused") - return { ...prev, queued: [...prev.queued, next] }; + /* Is either queued or paused */ + if (next.download.queued || next.download?.status === "paused") + return { ...prev, queued: [...prev.queued, next] }; - return { ...prev, complete: [...prev.complete, next] }; - }, initialValue); + return { ...prev, complete: [...prev.complete, next] }; + }, + initialValue + ); const queued = orderBy(result.queued, (game) => game.download?.timestamp, [ "desc", diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index 4fbcc855..e778ffef 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -102,19 +102,23 @@ export default function GameDetails() { downloader: Downloader, downloadPath: string ) => { - await startDownload({ + const response = await startDownload({ repackId: repack.id, objectId: objectId!, title: gameTitle, downloader, - shop: shop as GameShop, + shop, downloadPath, uri: selectRepackUri(repack, downloader), }); - await updateGame(); - setShowRepacksModal(false); - setShowGameOptionsModal(false); + if (response.ok) { + await updateGame(); + setShowRepacksModal(false); + setShowGameOptionsModal(false); + } + + return response; }; const handleNSFWContentRefuse = () => { @@ -123,10 +127,7 @@ export default function GameDetails() { }; return ( - + {({ showCloudSyncModal, 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 dae20d4c..fa6a9306 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 @@ -18,7 +18,7 @@ export interface DownloadSettingsModalProps { repack: GameRepack, downloader: Downloader, downloadPath: string - ) => Promise; + ) => Promise<{ ok: boolean; error?: string }>; repack: GameRepack | null; } @@ -27,7 +27,7 @@ export function DownloadSettingsModal({ onClose, startDownload, repack, -}: DownloadSettingsModalProps) { +}: Readonly) { const { t } = useTranslation("game_details"); const { showErrorToast } = useToast(); @@ -117,20 +117,30 @@ export function DownloadSettingsModal({ } }; - const handleStartClick = () => { + const handleStartClick = async () => { if (repack) { setDownloadStarting(true); - startDownload(repack, selectedDownloader!, selectedPath) - .then(() => { + try { + const response = await startDownload( + repack, + selectedDownloader!, + selectedPath + ); + + if (response.ok) { onClose(); - }) - .catch((error) => { - showErrorToast(t("download_error"), error.message); - }) - .finally(() => { - setDownloadStarting(false); - }); + return; + } else if (response.error) { + showErrorToast(t("download_error"), t(response.error), 4_000); + } + } catch (error) { + if (error instanceof Error) { + showErrorToast(t("download_error"), error.message, 4_000); + } + } finally { + setDownloadStarting(false); + } } }; 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 b2b7b6f8..7d176de7 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -19,7 +19,7 @@ export interface RepacksModalProps { repack: GameRepack, downloader: Downloader, downloadPath: string - ) => Promise; + ) => Promise<{ ok: boolean; error?: string }>; onClose: () => void; } @@ -27,7 +27,7 @@ export function RepacksModal({ visible, startDownload, onClose, -}: RepacksModalProps) { +}: Readonly) { const [filteredRepacks, setFilteredRepacks] = useState([]); const [repack, setRepack] = useState(null); const [showSelectFolderModal, setShowSelectFolderModal] = useState(false); @@ -111,7 +111,7 @@ export function RepacksModal({

{repack.fileSize} - {repack.repacker} -{" "} - {repack.uploadDate ? formatDate(repack.uploadDate!) : ""} + {repack.uploadDate ? formatDate(repack.uploadDate) : ""}

); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 492cc2a3..550c1097 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -50,3 +50,10 @@ export enum AuthPage { UpdateEmail = "/update-email", UpdatePassword = "/update-password", } + +export enum DownloadError { + NotCachedInRealDebrid = "download_error_not_cached_in_real_debrid", + NotCachedInTorbox = "download_error_not_cached_in_torbox", + GofileQuotaExceeded = "download_error_gofile_quota_exceeded", + RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized", +}