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_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",

View File

@ -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",

View File

@ -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);

View File

@ -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",

View File

@ -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}`,
},
});
}
}

View File

@ -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<string> {
const response: AxiosResponse<string> = 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");
}
}

View File

@ -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()}`

View File

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

View File

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

View File

@ -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) => {

View File

@ -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) => {

View File

@ -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,
})
);
},

View File

@ -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",

View File

@ -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 (
<CloudSyncContextProvider
objectId={objectId!}
shop={shop! as GameShop}
>
<CloudSyncContextProvider objectId={objectId!} shop={shop}>
<CloudSyncContextConsumer>
{({
showCloudSyncModal,

View File

@ -18,7 +18,7 @@ export interface DownloadSettingsModalProps {
repack: GameRepack,
downloader: Downloader,
downloadPath: string
) => Promise<void>;
) => Promise<{ ok: boolean; error?: string }>;
repack: GameRepack | null;
}
@ -27,7 +27,7 @@ export function DownloadSettingsModal({
onClose,
startDownload,
repack,
}: DownloadSettingsModalProps) {
}: Readonly<DownloadSettingsModalProps>) {
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);
}
}
};

View File

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

View File

@ -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",
}