Merge branch 'main' into feature/torbox-integration

This commit is contained in:
Zamitto 2025-02-01 21:04:40 -03:00
commit 9fe1d43145
17 changed files with 187 additions and 98 deletions

View File

@ -184,7 +184,11 @@
"reset_achievements_description": "This will reset all achievements for {{game}}", "reset_achievements_description": "This will reset all achievements for {{game}}",
"reset_achievements_title": "Are you sure?", "reset_achievements_title": "Are you sure?",
"reset_achievements_success": "Achievements successfully reset", "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": { "activation": {
"title": "Activate Hydra", "title": "Activate Hydra",

View File

@ -173,7 +173,11 @@
"reset_achievements_title": "Tem certeza?", "reset_achievements_title": "Tem certeza?",
"reset_achievements_success": "Conquistas resetadas com sucesso", "reset_achievements_success": "Conquistas resetadas com sucesso",
"reset_achievements_error": "Falha ao resetar conquistas", "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": { "activation": {
"title": "Ativação", "title": "Ativação",

View File

@ -1,11 +1,12 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { Download, StartGameDownloadPayload } from "@types"; 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 { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync"; 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 { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { AxiosError } from "axios";
const startGameDownload = async ( const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@ -77,21 +78,49 @@ const startGameDownload = async (
await downloadsSublevel.put(gameKey, download); 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([ await Promise.all([
createGame(updatedGame!).catch(() => {}), createGame(updatedGame!).catch(() => {}),
HydraApi.post( HydraApi.post(
"/games/download", "/games/download",
{ {
objectId, objectId,
shop, shop,
}, },
{ needsAuth: false } { needsAuth: false }
).catch(() => {}), ).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); registerEvent("startGameDownload", startGameDownload);

View File

@ -1,4 +1,4 @@
import { Downloader } from "@shared"; import { Downloader, DownloadError } from "@shared";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import { publishDownloadCompleteNotification } from "../notifications"; import { publishDownloadCompleteNotification } from "../notifications";
import type { Download, DownloadProgress, UserPreferences } from "@types"; import type { Download, DownloadProgress, UserPreferences } from "@types";
@ -264,6 +264,8 @@ export class DownloadManager {
const token = await GofileApi.authorize(); const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!); const downloadLink = await GofileApi.getDownloadLink(id!);
await GofileApi.checkDownloadUrl(downloadLink);
return { return {
action: "start", action: "start",
game_id: downloadId, game_id: downloadId,
@ -320,10 +322,7 @@ export class DownloadManager {
case Downloader.RealDebrid: { case Downloader.RealDebrid: {
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) if (!downloadUrl) throw new Error(DownloadError.NotCachedInRealDebrid);
throw new Error(
"This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available."
);
return { return {
action: "start", action: "start",

View File

@ -60,4 +60,12 @@ export class GofileApi {
throw new Error("Failed to get download link"); throw new Error("Failed to get download link");
} }
public static async checkDownloadUrl(url: string) {
return axios.head(url, {
headers: {
Cookie: `accountToken=${this.token}`,
},
});
}
} }

View File

@ -1,39 +1,54 @@
import axios, { AxiosResponse } from "axios"; import fetch from "node-fetch";
import { JSDOM } from "jsdom";
export class MediafireApi { 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<string> { public static async getDownloadUrl(mediafireUrl: string): Promise<string> {
const response: AxiosResponse<string> = await this.session.get( try {
mediafireUrl, const processedUrl = this.processUrl(mediafireUrl);
{ const response = await fetch(processedUrl);
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,
}
);
if (response.status === 302) { if (!response.ok) throw new Error("Failed to fetch Mediafire page");
const location = response.headers["location"];
if (!location) { const html = await response.text();
throw new Error("Missing location header in 302 redirect response"); return this.extractDirectUrl(html);
} } catch (error) {
return location; 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); if (!this.checkHTTP.test(processed)) {
const downloadButton = dom.window.document.querySelector( processed = processed.startsWith("//")
"a#downloadButton" ? `https:${processed}`
) as HTMLAnchorElement; : `https://${processed}`;
if (!downloadButton?.href) {
throw new Error("Download button URL not found in page content");
} }
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");
} }
} }

View File

@ -128,7 +128,8 @@ export class WindowManager {
this.mainWindow.removeMenu(); this.mainWindow.removeMenu();
this.mainWindow.on("ready-to-show", () => { this.mainWindow.on("ready-to-show", () => {
if (!app.isPackaged) WindowManager.mainWindow?.webContents.openDevTools(); if (!app.isPackaged || isStaging)
WindowManager.mainWindow?.webContents.openDevTools();
WindowManager.mainWindow?.show(); WindowManager.mainWindow?.show();
}); });
@ -173,9 +174,7 @@ export class WindowManager {
authWindow.removeMenu(); authWindow.removeMenu();
if (!app.isPackaged || isStaging) { if (!app.isPackaged) authWindow.webContents.openDevTools();
authWindow.webContents.openDevTools();
}
authWindow.loadURL( authWindow.loadURL(
`${import.meta.env.MAIN_VITE_AUTH_URL}${page}?${searchParams.toString()}` `${import.meta.env.MAIN_VITE_AUTH_URL}${page}?${searchParams.toString()}`

View File

@ -282,6 +282,7 @@ export function App() {
message={toast.message} message={toast.message}
type={toast.type} type={toast.type}
onClose={handleToastClose} onClose={handleToastClose}
duration={toast.duration}
/> />
</div> </div>

View File

@ -41,7 +41,9 @@ declare global {
interface Electron { interface Electron {
/* Torrenting */ /* Torrenting */
startGameDownload: (payload: StartGameDownloadPayload) => Promise<void>; startGameDownload: (
payload: StartGameDownloadPayload
) => Promise<{ ok: boolean; error?: string }>;
cancelGameDownload: (shop: GameShop, objectId: string) => Promise<void>; cancelGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
pauseGameDownload: (shop: GameShop, objectId: string) => Promise<void>; pauseGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
resumeGameDownload: (shop: GameShop, objectId: string) => Promise<void>; resumeGameDownload: (shop: GameShop, objectId: string) => Promise<void>;

View File

@ -6,6 +6,7 @@ export interface ToastState {
title: string; title: string;
message?: string; message?: string;
type: ToastProps["type"]; type: ToastProps["type"];
duration?: number;
visible: boolean; visible: boolean;
} }
@ -13,6 +14,7 @@ const initialState: ToastState = {
title: "", title: "",
message: "", message: "",
type: "success", type: "success",
duration: 5000,
visible: false, visible: false,
}; };
@ -24,6 +26,7 @@ export const toastSlice = createSlice({
state.title = action.payload.title; state.title = action.payload.title;
state.message = action.payload.message; state.message = action.payload.message;
state.type = action.payload.type; state.type = action.payload.type;
state.duration = action.payload.duration ?? 5000;
state.visible = true; state.visible = true;
}, },
closeToast: (state) => { closeToast: (state) => {

View File

@ -29,10 +29,11 @@ export function useDownload() {
const startDownload = async (payload: StartGameDownloadPayload) => { const startDownload = async (payload: StartGameDownloadPayload) => {
dispatch(clearDownload()); dispatch(clearDownload());
const game = await window.electron.startGameDownload(payload); const response = await window.electron.startGameDownload(payload);
await updateLibrary(); if (response.ok) updateLibrary();
return game;
return response;
}; };
const pauseDownload = async (shop: GameShop, objectId: string) => { const pauseDownload = async (shop: GameShop, objectId: string) => {

View File

@ -6,12 +6,13 @@ export function useToast() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const showSuccessToast = useCallback( const showSuccessToast = useCallback(
(title: string, message?: string) => { (title: string, message?: string, duration?: number) => {
dispatch( dispatch(
showToast({ showToast({
title, title,
message, message,
type: "success", type: "success",
duration,
}) })
); );
}, },
@ -19,12 +20,13 @@ export function useToast() {
); );
const showErrorToast = useCallback( const showErrorToast = useCallback(
(title: string, message?: string) => { (title: string, message?: string, duration?: number) => {
dispatch( dispatch(
showToast({ showToast({
title, title,
message, message,
type: "error", type: "error",
duration,
}) })
); );
}, },
@ -32,12 +34,13 @@ export function useToast() {
); );
const showWarningToast = useCallback( const showWarningToast = useCallback(
(title: string, message?: string) => { (title: string, message?: string, duration?: number) => {
dispatch( dispatch(
showToast({ showToast({
title, title,
message, message,
type: "warning", type: "warning",
duration,
}) })
); );
}, },

View File

@ -8,7 +8,7 @@ import * as styles from "./downloads.css";
import { DeleteGameModal } from "./delete-game-modal"; import { DeleteGameModal } from "./delete-game-modal";
import { DownloadGroup } from "./download-group"; import { DownloadGroup } from "./download-group";
import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { orderBy } from "lodash-es"; import { orderBy, sortBy } from "lodash-es";
import { ArrowDownIcon } from "@primer/octicons-react"; import { ArrowDownIcon } from "@primer/octicons-react";
export default function Downloads() { export default function Downloads() {
@ -58,21 +58,24 @@ export default function Downloads() {
complete: [], complete: [],
}; };
const result = library.reduce((prev, next) => { const result = sortBy(library, (game) => game.download?.timestamp).reduce(
/* Game has been manually added to the library or has been canceled */ (prev, next) => {
if (!next.download?.status || next.download?.status === "removed") /* Game has been manually added to the library or has been canceled */
return prev; if (!next.download?.status || next.download?.status === "removed")
return prev;
/* Is downloading */ /* Is downloading */
if (lastPacket?.gameId === next.id) if (lastPacket?.gameId === next.id)
return { ...prev, downloading: [...prev.downloading, next] }; return { ...prev, downloading: [...prev.downloading, next] };
/* Is either queued or paused */ /* Is either queued or paused */
if (next.download.queued || next.download?.status === "paused") if (next.download.queued || next.download?.status === "paused")
return { ...prev, queued: [...prev.queued, next] }; return { ...prev, queued: [...prev.queued, next] };
return { ...prev, complete: [...prev.complete, next] }; return { ...prev, complete: [...prev.complete, next] };
}, initialValue); },
initialValue
);
const queued = orderBy(result.queued, (game) => game.download?.timestamp, [ const queued = orderBy(result.queued, (game) => game.download?.timestamp, [
"desc", "desc",

View File

@ -102,19 +102,23 @@ export default function GameDetails() {
downloader: Downloader, downloader: Downloader,
downloadPath: string downloadPath: string
) => { ) => {
await startDownload({ const response = await startDownload({
repackId: repack.id, repackId: repack.id,
objectId: objectId!, objectId: objectId!,
title: gameTitle, title: gameTitle,
downloader, downloader,
shop: shop as GameShop, shop,
downloadPath, downloadPath,
uri: selectRepackUri(repack, downloader), uri: selectRepackUri(repack, downloader),
}); });
await updateGame(); if (response.ok) {
setShowRepacksModal(false); await updateGame();
setShowGameOptionsModal(false); setShowRepacksModal(false);
setShowGameOptionsModal(false);
}
return response;
}; };
const handleNSFWContentRefuse = () => { const handleNSFWContentRefuse = () => {
@ -123,10 +127,7 @@ export default function GameDetails() {
}; };
return ( return (
<CloudSyncContextProvider <CloudSyncContextProvider objectId={objectId!} shop={shop}>
objectId={objectId!}
shop={shop! as GameShop}
>
<CloudSyncContextConsumer> <CloudSyncContextConsumer>
{({ {({
showCloudSyncModal, showCloudSyncModal,

View File

@ -18,7 +18,7 @@ export interface DownloadSettingsModalProps {
repack: GameRepack, repack: GameRepack,
downloader: Downloader, downloader: Downloader,
downloadPath: string downloadPath: string
) => Promise<void>; ) => Promise<{ ok: boolean; error?: string }>;
repack: GameRepack | null; repack: GameRepack | null;
} }
@ -27,7 +27,7 @@ export function DownloadSettingsModal({
onClose, onClose,
startDownload, startDownload,
repack, repack,
}: DownloadSettingsModalProps) { }: Readonly<DownloadSettingsModalProps>) {
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { showErrorToast } = useToast(); const { showErrorToast } = useToast();
@ -117,20 +117,30 @@ export function DownloadSettingsModal({
} }
}; };
const handleStartClick = () => { const handleStartClick = async () => {
if (repack) { if (repack) {
setDownloadStarting(true); setDownloadStarting(true);
startDownload(repack, selectedDownloader!, selectedPath) try {
.then(() => { const response = await startDownload(
repack,
selectedDownloader!,
selectedPath
);
if (response.ok) {
onClose(); onClose();
}) return;
.catch((error) => { } else if (response.error) {
showErrorToast(t("download_error"), error.message); showErrorToast(t("download_error"), t(response.error), 4_000);
}) }
.finally(() => { } catch (error) {
setDownloadStarting(false); if (error instanceof Error) {
}); showErrorToast(t("download_error"), error.message, 4_000);
}
} finally {
setDownloadStarting(false);
}
} }
}; };

View File

@ -19,7 +19,7 @@ export interface RepacksModalProps {
repack: GameRepack, repack: GameRepack,
downloader: Downloader, downloader: Downloader,
downloadPath: string downloadPath: string
) => Promise<void>; ) => Promise<{ ok: boolean; error?: string }>;
onClose: () => void; onClose: () => void;
} }
@ -27,7 +27,7 @@ export function RepacksModal({
visible, visible,
startDownload, startDownload,
onClose, onClose,
}: RepacksModalProps) { }: Readonly<RepacksModalProps>) {
const [filteredRepacks, setFilteredRepacks] = useState<GameRepack[]>([]); const [filteredRepacks, setFilteredRepacks] = useState<GameRepack[]>([]);
const [repack, setRepack] = useState<GameRepack | null>(null); const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false); const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
@ -111,7 +111,7 @@ export function RepacksModal({
<p style={{ fontSize: "12px" }}> <p style={{ fontSize: "12px" }}>
{repack.fileSize} - {repack.repacker} -{" "} {repack.fileSize} - {repack.repacker} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate!) : ""} {repack.uploadDate ? formatDate(repack.uploadDate) : ""}
</p> </p>
</Button> </Button>
); );

View File

@ -50,3 +50,10 @@ export enum AuthPage {
UpdateEmail = "/update-email", UpdateEmail = "/update-email",
UpdatePassword = "/update-password", 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",
}