mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-02 16:23:48 +03:00
Merge branch 'main' into feature/torbox-integration
This commit is contained in:
commit
9fe1d43145
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
|
@ -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",
|
||||
|
@ -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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
@ -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()}`
|
||||
|
@ -282,6 +282,7 @@ export function App() {
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={handleToastClose}
|
||||
duration={toast.duration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
4
src/renderer/src/declaration.d.ts
vendored
4
src/renderer/src/declaration.d.ts
vendored
@ -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>;
|
||||
|
@ -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) => {
|
||||
|
@ -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) => {
|
||||
|
@ -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,
|
||||
})
|
||||
);
|
||||
},
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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",
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user