mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-22 18:33:56 +00:00
Merge branch 'main' into feat/disabling-update-badges
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { app } from "electron";
|
||||
import cp from "node:child_process";
|
||||
import Seven, { CommandLineSwitches } from "node-7z";
|
||||
import path from "node:path";
|
||||
import { logger } from "./logger";
|
||||
|
||||
@@ -9,6 +9,17 @@ export const binaryName = {
|
||||
win32: "7z.exe",
|
||||
};
|
||||
|
||||
export interface ExtractionProgress {
|
||||
percent: number;
|
||||
fileCount: number;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export interface ExtractionResult {
|
||||
success: boolean;
|
||||
extractedFiles: string[];
|
||||
}
|
||||
|
||||
export class SevenZip {
|
||||
private static readonly binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, binaryName[process.platform])
|
||||
@@ -32,43 +43,109 @@ export class SevenZip {
|
||||
cwd?: string;
|
||||
passwords?: string[];
|
||||
},
|
||||
successCb: () => void,
|
||||
errorCb: () => void
|
||||
) {
|
||||
const tryPassword = (index = -1) => {
|
||||
const password = passwords[index] ?? "";
|
||||
logger.info(`Trying password ${password} on ${filePath}`);
|
||||
onProgress?: (progress: ExtractionProgress) => void
|
||||
): Promise<ExtractionResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tryPassword = (index = -1) => {
|
||||
const password = passwords[index] ?? "";
|
||||
logger.info(
|
||||
`Trying password "${password || "(empty)"}" on ${filePath}`
|
||||
);
|
||||
|
||||
const args = ["x", filePath, "-y", "-p" + password];
|
||||
const extractedFiles: string[] = [];
|
||||
let fileCount = 0;
|
||||
|
||||
if (outputPath) {
|
||||
args.push("-o" + outputPath);
|
||||
}
|
||||
const options: CommandLineSwitches = {
|
||||
$bin: this.binaryPath,
|
||||
$progress: true,
|
||||
yes: true,
|
||||
password: password || undefined,
|
||||
};
|
||||
|
||||
const child = cp.execFile(this.binaryPath, args, {
|
||||
cwd,
|
||||
});
|
||||
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) {
|
||||
successCb();
|
||||
return;
|
||||
if (outputPath) {
|
||||
options.outputDir = outputPath;
|
||||
}
|
||||
|
||||
if (index < passwords.length - 1) {
|
||||
const stream = Seven.extractFull(filePath, outputPath || cwd || ".", {
|
||||
...options,
|
||||
$spawnOptions: cwd ? { cwd } : undefined,
|
||||
});
|
||||
|
||||
stream.on("progress", (progress) => {
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
percent: progress.percent,
|
||||
fileCount: fileCount,
|
||||
file: progress.fileCount?.toString() || "",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("data", (data) => {
|
||||
if (data.file) {
|
||||
extractedFiles.push(data.file);
|
||||
fileCount++;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("end", () => {
|
||||
logger.info(
|
||||
`Failed to extract file: ${filePath} with password: ${password}. Trying next password...`
|
||||
`Successfully extracted ${filePath} (${extractedFiles.length} files)`
|
||||
);
|
||||
resolve({
|
||||
success: true,
|
||||
extractedFiles,
|
||||
});
|
||||
});
|
||||
|
||||
tryPassword(index + 1);
|
||||
} else {
|
||||
logger.info(`Failed to extract file: ${filePath}`);
|
||||
stream.on("error", (err) => {
|
||||
logger.error(`Extraction error for ${filePath}:`, err);
|
||||
|
||||
errorCb();
|
||||
if (index < passwords.length - 1) {
|
||||
logger.info(
|
||||
`Failed to extract file: ${filePath} with password: "${password}". Trying next password...`
|
||||
);
|
||||
tryPassword(index + 1);
|
||||
} else {
|
||||
logger.error(
|
||||
`Failed to extract file: ${filePath} after trying all passwords`
|
||||
);
|
||||
reject(new Error(`Failed to extract file: ${filePath}`));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tryPassword();
|
||||
});
|
||||
}
|
||||
|
||||
public static listFiles(
|
||||
filePath: string,
|
||||
password?: string
|
||||
): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const files: string[] = [];
|
||||
|
||||
const options: CommandLineSwitches = {
|
||||
$bin: this.binaryPath,
|
||||
password: password || undefined,
|
||||
};
|
||||
|
||||
const stream = Seven.list(filePath, options);
|
||||
|
||||
stream.on("data", (data) => {
|
||||
if (data.file) {
|
||||
files.push(data.file);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tryPassword();
|
||||
stream.on("end", () => {
|
||||
resolve(files);
|
||||
});
|
||||
|
||||
stream.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ export class Aria2 {
|
||||
private static process: cp.ChildProcess | null = null;
|
||||
|
||||
public static spawn() {
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2c")
|
||||
: path.join(__dirname, "..", "..", "binaries", "aria2c");
|
||||
const binaryPath =
|
||||
process.platform === "darwin"
|
||||
? "aria2c"
|
||||
: app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2c")
|
||||
: path.join(__dirname, "..", "..", "binaries", "aria2c");
|
||||
|
||||
this.process = cp.spawn(
|
||||
binaryPath,
|
||||
|
||||
@@ -74,21 +74,16 @@ export class DeckyPlugin {
|
||||
|
||||
await fs.promises.mkdir(extractPath, { recursive: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
SevenZip.extractFile(
|
||||
{
|
||||
filePath: zipPath,
|
||||
outputPath: extractPath,
|
||||
},
|
||||
() => {
|
||||
logger.log(`Plugin extracted to: ${extractPath}`);
|
||||
resolve(extractPath);
|
||||
},
|
||||
() => {
|
||||
reject(new Error("Failed to extract plugin"));
|
||||
}
|
||||
);
|
||||
});
|
||||
try {
|
||||
await SevenZip.extractFile({
|
||||
filePath: zipPath,
|
||||
outputPath: extractPath,
|
||||
});
|
||||
logger.log(`Plugin extracted to: ${extractPath}`);
|
||||
return extractPath;
|
||||
} catch {
|
||||
throw new Error("Failed to extract plugin");
|
||||
}
|
||||
}
|
||||
|
||||
private static needsSudo(): boolean {
|
||||
|
||||
@@ -20,14 +20,59 @@ import { RealDebridClient } from "./real-debrid";
|
||||
import path from "path";
|
||||
import { logger } from "../logger";
|
||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { TorBoxClient } from "./torbox";
|
||||
import { GameFilesManager } from "../game-files-manager";
|
||||
import { HydraDebridClient } from "./hydra-debrid";
|
||||
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
|
||||
|
||||
export class DownloadManager {
|
||||
private static downloadingGameId: string | null = null;
|
||||
|
||||
private static extractFilename(url: string, originalUrl?: string): string | undefined {
|
||||
if (originalUrl?.includes('#')) {
|
||||
const hashPart = originalUrl.split('#')[1];
|
||||
if (hashPart && !hashPart.startsWith('http')) return hashPart;
|
||||
}
|
||||
|
||||
if (url.includes('#')) {
|
||||
const hashPart = url.split('#')[1];
|
||||
if (hashPart && !hashPart.startsWith('http')) return hashPart;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const filename = urlObj.pathname.split('/').pop();
|
||||
if (filename?.length) return filename;
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static sanitizeFilename(filename: string): string {
|
||||
return filename.replace(/[<>:"/\\|?*]/g, '_');
|
||||
}
|
||||
|
||||
private static createDownloadPayload(directUrl: string, originalUrl: string, downloadId: string, savePath: string) {
|
||||
const filename = this.extractFilename(directUrl, originalUrl);
|
||||
const sanitizedFilename = filename ? this.sanitizeFilename(filename) : undefined;
|
||||
|
||||
if (sanitizedFilename) {
|
||||
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
|
||||
}
|
||||
|
||||
return {
|
||||
action: "start" as const,
|
||||
game_id: downloadId,
|
||||
url: directUrl,
|
||||
save_path: savePath,
|
||||
out: sanitizedFilename,
|
||||
allow_multiple_connections: true,
|
||||
};
|
||||
}
|
||||
|
||||
public static async startRPC(
|
||||
download?: Download,
|
||||
downloadsToSeed?: Download[]
|
||||
@@ -53,9 +98,7 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
private static async getDownloadStatus() {
|
||||
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
|
||||
"/status"
|
||||
);
|
||||
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>("/status");
|
||||
if (response.data === null || !this.downloadingGameId) return null;
|
||||
const downloadId = this.downloadingGameId;
|
||||
|
||||
@@ -71,8 +114,7 @@ export class DownloadManager {
|
||||
status,
|
||||
} = response.data;
|
||||
|
||||
const isDownloadingMetadata =
|
||||
status === LibtorrentStatus.DownloadingMetadata;
|
||||
const isDownloadingMetadata = status === LibtorrentStatus.DownloadingMetadata;
|
||||
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
|
||||
|
||||
const download = await downloadsSublevel.get(downloadId);
|
||||
@@ -121,21 +163,14 @@ export class DownloadManager {
|
||||
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
valueEncoding: "json",
|
||||
}
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
|
||||
if (WindowManager.mainWindow && download) {
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(
|
||||
JSON.stringify({
|
||||
...status,
|
||||
game,
|
||||
})
|
||||
)
|
||||
JSON.parse(JSON.stringify({ ...status, game }))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,10 +179,7 @@ export class DownloadManager {
|
||||
if (progress === 1 && download) {
|
||||
publishDownloadCompleteNotification(game);
|
||||
|
||||
if (
|
||||
userPreferences?.seedAfterDownloadComplete &&
|
||||
download.downloader === Downloader.Torrent
|
||||
) {
|
||||
if (userPreferences?.seedAfterDownloadComplete && download.downloader === Downloader.Torrent) {
|
||||
await downloadsSublevel.put(gameId, {
|
||||
...download,
|
||||
status: "seeding",
|
||||
@@ -168,38 +200,25 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
if (shouldExtract) {
|
||||
const gameFilesManager = new GameFilesManager(
|
||||
game.shop,
|
||||
game.objectId
|
||||
);
|
||||
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
|
||||
|
||||
if (
|
||||
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
|
||||
download.folderName?.endsWith(ext)
|
||||
)
|
||||
) {
|
||||
if (FILE_EXTENSIONS_TO_EXTRACT.some((ext) => download.folderName?.endsWith(ext))) {
|
||||
gameFilesManager.extractDownloadedFile();
|
||||
} else {
|
||||
gameFilesManager
|
||||
.extractFilesInDirectory(
|
||||
path.join(download.downloadPath, download.folderName!)
|
||||
)
|
||||
.then(() => {
|
||||
gameFilesManager.setExtractionComplete();
|
||||
});
|
||||
.extractFilesInDirectory(path.join(download.downloadPath, download.folderName!))
|
||||
.then(() => gameFilesManager.setExtractionComplete());
|
||||
}
|
||||
}
|
||||
|
||||
const downloads = await downloadsSublevel
|
||||
.values()
|
||||
.all()
|
||||
.then((games) => {
|
||||
return orderBy(
|
||||
games.filter((game) => game.status === "paused" && game.queued),
|
||||
"timestamp",
|
||||
"desc"
|
||||
);
|
||||
});
|
||||
.then((games) => sortBy(
|
||||
games.filter((game) => game.status === "paused" && game.queued),
|
||||
"timestamp",
|
||||
"DESC"
|
||||
));
|
||||
|
||||
const [nextItemOnQueue] = downloads;
|
||||
|
||||
@@ -226,9 +245,7 @@ export class DownloadManager {
|
||||
|
||||
if (!download) return;
|
||||
|
||||
const totalSize = await getDirSize(
|
||||
path.join(download.downloadPath, status.folderName)
|
||||
);
|
||||
const totalSize = await getDirSize(path.join(download.downloadPath, status.folderName));
|
||||
|
||||
if (totalSize < status.fileSize) {
|
||||
await this.cancelDownload(status.gameId);
|
||||
@@ -249,10 +266,7 @@ export class DownloadManager {
|
||||
|
||||
static async pauseDownload(downloadKey = this.downloadingGameId) {
|
||||
await PythonRPC.rpc
|
||||
.post("/action", {
|
||||
action: "pause",
|
||||
game_id: downloadKey,
|
||||
} as PauseDownloadPayload)
|
||||
.post("/action", { action: "pause", game_id: downloadKey } as PauseDownloadPayload)
|
||||
.catch(() => {});
|
||||
|
||||
if (downloadKey === this.downloadingGameId) {
|
||||
@@ -267,13 +281,8 @@ 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);
|
||||
});
|
||||
.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);
|
||||
@@ -306,7 +315,6 @@ export class DownloadManager {
|
||||
const id = download.uri.split("/").pop();
|
||||
const token = await GofileApi.authorize();
|
||||
const downloadLink = await GofileApi.getDownloadLink(id!);
|
||||
|
||||
await GofileApi.checkDownloadUrl(downloadLink);
|
||||
|
||||
return {
|
||||
@@ -348,9 +356,30 @@ export class DownloadManager {
|
||||
save_path: download.downloadPath,
|
||||
};
|
||||
}
|
||||
case Downloader.Buzzheavier: {
|
||||
logger.log(`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`);
|
||||
try {
|
||||
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
|
||||
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
|
||||
return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath);
|
||||
} catch (error) {
|
||||
logger.error(`[DownloadManager] Error processing Buzzheavier download:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
case Downloader.FuckingFast: {
|
||||
logger.log(`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`);
|
||||
try {
|
||||
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
|
||||
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
|
||||
return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath);
|
||||
} catch (error) {
|
||||
logger.error(`[DownloadManager] Error processing FuckingFast download:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
case Downloader.Mediafire: {
|
||||
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
@@ -367,7 +396,6 @@ export class DownloadManager {
|
||||
};
|
||||
case Downloader.RealDebrid: {
|
||||
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
||||
|
||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
|
||||
|
||||
return {
|
||||
@@ -380,7 +408,6 @@ export class DownloadManager {
|
||||
}
|
||||
case Downloader.TorBox: {
|
||||
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
||||
|
||||
if (!url) return;
|
||||
return {
|
||||
action: "start",
|
||||
@@ -392,10 +419,7 @@ export class DownloadManager {
|
||||
};
|
||||
}
|
||||
case Downloader.Hydra: {
|
||||
const downloadUrl = await HydraDebridClient.getDownloadUrl(
|
||||
download.uri
|
||||
);
|
||||
|
||||
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
|
||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,24 +3,58 @@ import fs from "node:fs";
|
||||
import type { GameShop } from "@types";
|
||||
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared";
|
||||
import { SevenZip } from "./7zip";
|
||||
import { SevenZip, ExtractionProgress } from "./7zip";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { publishExtractionCompleteNotification } from "./notifications";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const PROGRESS_THROTTLE_MS = 1000;
|
||||
|
||||
export class GameFilesManager {
|
||||
private lastProgressUpdate = 0;
|
||||
|
||||
constructor(
|
||||
private readonly shop: GameShop,
|
||||
private readonly objectId: string
|
||||
) {}
|
||||
|
||||
private async clearExtractionState() {
|
||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
||||
const download = await downloadsSublevel.get(gameKey);
|
||||
private get gameKey() {
|
||||
return levelKeys.game(this.shop, this.objectId);
|
||||
}
|
||||
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...download!,
|
||||
private async updateExtractionProgress(progress: number, force = false) {
|
||||
const now = Date.now();
|
||||
|
||||
if (!force && now - this.lastProgressUpdate < PROGRESS_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastProgressUpdate = now;
|
||||
|
||||
const download = await downloadsSublevel.get(this.gameKey);
|
||||
if (!download) return;
|
||||
|
||||
await downloadsSublevel.put(this.gameKey, {
|
||||
...download,
|
||||
extractionProgress: progress,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-extraction-progress",
|
||||
this.shop,
|
||||
this.objectId,
|
||||
progress
|
||||
);
|
||||
}
|
||||
|
||||
private async clearExtractionState() {
|
||||
const download = await downloadsSublevel.get(this.gameKey);
|
||||
if (!download) return;
|
||||
|
||||
await downloadsSublevel.put(this.gameKey, {
|
||||
...download,
|
||||
extracting: false,
|
||||
extractionProgress: 0,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
@@ -30,6 +64,10 @@ export class GameFilesManager {
|
||||
);
|
||||
}
|
||||
|
||||
private readonly handleProgress = (progress: ExtractionProgress) => {
|
||||
this.updateExtractionProgress(progress.percent / 100);
|
||||
};
|
||||
|
||||
async extractFilesInDirectory(directoryPath: string) {
|
||||
if (!fs.existsSync(directoryPath)) return;
|
||||
const files = await fs.promises.readdir(directoryPath);
|
||||
@@ -42,53 +80,66 @@ export class GameFilesManager {
|
||||
(file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
filesToExtract.map((file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
SevenZip.extractFile(
|
||||
{
|
||||
filePath: path.join(directoryPath, file),
|
||||
cwd: directoryPath,
|
||||
passwords: ["online-fix.me", "steamrip.com"],
|
||||
},
|
||||
() => {
|
||||
resolve(true);
|
||||
},
|
||||
() => {
|
||||
reject(new Error(`Failed to extract file: ${file}`));
|
||||
this.clearExtractionState();
|
||||
}
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
if (filesToExtract.length === 0) return;
|
||||
|
||||
compressedFiles.forEach((file) => {
|
||||
const extractionPath = path.join(directoryPath, file);
|
||||
await this.updateExtractionProgress(0, true);
|
||||
|
||||
if (fs.existsSync(extractionPath)) {
|
||||
fs.unlink(extractionPath, (err) => {
|
||||
if (err) {
|
||||
logger.error(`Failed to delete file: ${file}`, err);
|
||||
const totalFiles = filesToExtract.length;
|
||||
let completedFiles = 0;
|
||||
|
||||
this.clearExtractionState();
|
||||
for (const file of filesToExtract) {
|
||||
try {
|
||||
const result = await SevenZip.extractFile(
|
||||
{
|
||||
filePath: path.join(directoryPath, file),
|
||||
cwd: directoryPath,
|
||||
passwords: ["online-fix.me", "steamrip.com"],
|
||||
},
|
||||
(progress) => {
|
||||
const overallProgress =
|
||||
(completedFiles + progress.percent / 100) / totalFiles;
|
||||
this.updateExtractionProgress(overallProgress);
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
completedFiles++;
|
||||
await this.updateExtractionProgress(
|
||||
completedFiles / totalFiles,
|
||||
true
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to extract file: ${file}`, err);
|
||||
await this.clearExtractionState();
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const archivePaths = compressedFiles
|
||||
.map((file) => path.join(directoryPath, file))
|
||||
.filter((archivePath) => fs.existsSync(archivePath));
|
||||
|
||||
if (archivePaths.length > 0) {
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-archive-deletion-prompt",
|
||||
archivePaths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setExtractionComplete(publishNotification = true) {
|
||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
||||
|
||||
const [download, game] = await Promise.all([
|
||||
downloadsSublevel.get(gameKey),
|
||||
gamesSublevel.get(gameKey),
|
||||
downloadsSublevel.get(this.gameKey),
|
||||
gamesSublevel.get(this.gameKey),
|
||||
]);
|
||||
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...download!,
|
||||
if (!download) return;
|
||||
|
||||
await downloadsSublevel.put(this.gameKey, {
|
||||
...download,
|
||||
extracting: false,
|
||||
extractionProgress: 0,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
@@ -97,17 +148,15 @@ export class GameFilesManager {
|
||||
this.objectId
|
||||
);
|
||||
|
||||
if (publishNotification) {
|
||||
publishExtractionCompleteNotification(game!);
|
||||
if (publishNotification && game) {
|
||||
publishExtractionCompleteNotification(game);
|
||||
}
|
||||
}
|
||||
|
||||
async extractDownloadedFile() {
|
||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
||||
|
||||
const [download, game] = await Promise.all([
|
||||
downloadsSublevel.get(gameKey),
|
||||
gamesSublevel.get(gameKey),
|
||||
downloadsSublevel.get(this.gameKey),
|
||||
gamesSublevel.get(this.gameKey),
|
||||
]);
|
||||
|
||||
if (!download || !game) return false;
|
||||
@@ -119,39 +168,39 @@ export class GameFilesManager {
|
||||
path.parse(download.folderName!).name
|
||||
);
|
||||
|
||||
SevenZip.extractFile(
|
||||
{
|
||||
filePath,
|
||||
outputPath: extractionPath,
|
||||
passwords: ["online-fix.me", "steamrip.com"],
|
||||
},
|
||||
async () => {
|
||||
await this.updateExtractionProgress(0, true);
|
||||
|
||||
try {
|
||||
const result = await SevenZip.extractFile(
|
||||
{
|
||||
filePath,
|
||||
outputPath: extractionPath,
|
||||
passwords: ["online-fix.me", "steamrip.com"],
|
||||
},
|
||||
this.handleProgress
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
await this.extractFilesInDirectory(extractionPath);
|
||||
|
||||
if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) {
|
||||
fs.unlink(filePath, (err) => {
|
||||
if (err) {
|
||||
logger.error(
|
||||
`Failed to delete file: ${download.folderName}`,
|
||||
err
|
||||
);
|
||||
|
||||
this.clearExtractionState();
|
||||
}
|
||||
});
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-archive-deletion-prompt",
|
||||
[filePath]
|
||||
);
|
||||
}
|
||||
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...download!,
|
||||
await downloadsSublevel.put(this.gameKey, {
|
||||
...download,
|
||||
folderName: path.parse(download.folderName!).name,
|
||||
});
|
||||
|
||||
this.setExtractionComplete();
|
||||
},
|
||||
() => {
|
||||
this.clearExtractionState();
|
||||
await this.setExtractionComplete();
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to extract downloaded file: ${filePath}`, err);
|
||||
await this.clearExtractionState();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
82
src/main/services/hosters/buzzheavier.ts
Normal file
82
src/main/services/hosters/buzzheavier.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import axios from "axios";
|
||||
import {
|
||||
HOSTER_USER_AGENT,
|
||||
extractHosterFilename,
|
||||
handleHosterError,
|
||||
} from "./fuckingfast";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
export class BuzzheavierApi {
|
||||
private static readonly BUZZHEAVIER_DOMAINS = [
|
||||
"buzzheavier.com",
|
||||
"bzzhr.co",
|
||||
"fuckingfast.net",
|
||||
];
|
||||
|
||||
private static isSupportedDomain(url: string): boolean {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
return this.BUZZHEAVIER_DOMAINS.some((domain) => lowerUrl.includes(domain));
|
||||
}
|
||||
|
||||
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
|
||||
try {
|
||||
const baseUrl = url.split("#")[0];
|
||||
logger.log(`[Buzzheavier] Starting download link extraction for: ${baseUrl}`);
|
||||
|
||||
await axios.get(baseUrl, {
|
||||
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const downloadUrl = `${baseUrl}/download`;
|
||||
logger.log(`[Buzzheavier] Making HEAD request to: ${downloadUrl}`);
|
||||
const headResponse = await axios.head(downloadUrl, {
|
||||
headers: {
|
||||
"hx-current-url": baseUrl,
|
||||
"hx-request": "true",
|
||||
referer: baseUrl,
|
||||
"User-Agent": HOSTER_USER_AGENT,
|
||||
},
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) =>
|
||||
status === 200 || status === 204 || status === 301 || status === 302,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const hxRedirect = headResponse.headers["hx-redirect"];
|
||||
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
|
||||
if (!hxRedirect) {
|
||||
logger.error(`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`);
|
||||
throw new Error(
|
||||
"Could not extract download link. File may be deleted or is a directory."
|
||||
);
|
||||
}
|
||||
|
||||
const domain = new URL(baseUrl).hostname;
|
||||
const directLink = hxRedirect.startsWith("/dl/")
|
||||
? `https://${domain}${hxRedirect}`
|
||||
: hxRedirect;
|
||||
logger.log(`[Buzzheavier] Extracted direct link`);
|
||||
return directLink;
|
||||
} catch (error) {
|
||||
logger.error(`[Buzzheavier] Error in getBuzzheavierDirectLink:`, error);
|
||||
handleHosterError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getDirectLink(url: string): Promise<string> {
|
||||
if (!this.isSupportedDomain(url)) {
|
||||
throw new Error(
|
||||
`Unsupported domain. Supported domains: ${this.BUZZHEAVIER_DOMAINS.join(", ")}`
|
||||
);
|
||||
}
|
||||
return this.getBuzzheavierDirectLink(url);
|
||||
}
|
||||
|
||||
public static async getFilename(
|
||||
url: string,
|
||||
directUrl?: string
|
||||
): Promise<string> {
|
||||
return extractHosterFilename(url, directUrl);
|
||||
}
|
||||
}
|
||||
129
src/main/services/hosters/fuckingfast.ts
Normal file
129
src/main/services/hosters/fuckingfast.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import axios from "axios";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
export const HOSTER_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0";
|
||||
|
||||
export async function extractHosterFilename(
|
||||
url: string,
|
||||
directUrl?: string
|
||||
): Promise<string> {
|
||||
if (url.includes("#")) {
|
||||
const fragment = url.split("#")[1];
|
||||
if (fragment && !fragment.startsWith("http")) {
|
||||
return fragment;
|
||||
}
|
||||
}
|
||||
|
||||
if (directUrl) {
|
||||
try {
|
||||
const response = await axios.head(directUrl, {
|
||||
timeout: 10000,
|
||||
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||
});
|
||||
|
||||
const contentDisposition = response.headers["content-disposition"];
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
|
||||
contentDisposition
|
||||
);
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
return filenameMatch[1].replace(/['"]/g, "");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
const urlPath = new URL(directUrl).pathname;
|
||||
const filename = urlPath.split("/").pop()?.split("?")[0];
|
||||
if (filename) {
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
return "downloaded_file";
|
||||
}
|
||||
|
||||
export function handleHosterError(error: unknown): never {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 404) {
|
||||
throw new Error("File not found");
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
throw new Error("Rate limit exceeded. Please try again later.");
|
||||
}
|
||||
if (error.response?.status === 403) {
|
||||
throw new Error("Access denied. File may be private or deleted.");
|
||||
}
|
||||
throw new Error(`Network error: ${error.response?.status || "Unknown"}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FuckingFast API Class
|
||||
// ============================================
|
||||
export class FuckingFastApi {
|
||||
private static readonly FUCKINGFAST_DOMAINS = ["fuckingfast.co"];
|
||||
|
||||
private static readonly FUCKINGFAST_REGEX =
|
||||
/window\.open\("(https:\/\/fuckingfast\.co\/dl\/[^"]*)"\)/;
|
||||
|
||||
private static isSupportedDomain(url: string): boolean {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
return this.FUCKINGFAST_DOMAINS.some((domain) => lowerUrl.includes(domain));
|
||||
}
|
||||
|
||||
private static async getFuckingFastDirectLink(url: string): Promise<string> {
|
||||
try {
|
||||
logger.log(`[FuckingFast] Starting download link extraction for: ${url}`);
|
||||
const response = await axios.get(url, {
|
||||
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const html = response.data;
|
||||
|
||||
if (html.toLowerCase().includes("rate limit")) {
|
||||
logger.error(`[FuckingFast] Rate limit detected`);
|
||||
throw new Error(
|
||||
"Rate limit exceeded. Please wait a few minutes and try again."
|
||||
);
|
||||
}
|
||||
|
||||
if (html.includes("File Not Found Or Deleted")) {
|
||||
logger.error(`[FuckingFast] File not found or deleted`);
|
||||
throw new Error("File not found or deleted");
|
||||
}
|
||||
|
||||
const match = this.FUCKINGFAST_REGEX.exec(html);
|
||||
if (!match || !match[1]) {
|
||||
logger.error(`[FuckingFast] Could not extract download link`);
|
||||
throw new Error("Could not extract download link from page");
|
||||
}
|
||||
|
||||
logger.log(`[FuckingFast] Successfully extracted direct link`);
|
||||
return match[1];
|
||||
} catch (error) {
|
||||
logger.error(`[FuckingFast] Error:`, error);
|
||||
handleHosterError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getDirectLink(url: string): Promise<string> {
|
||||
if (!this.isSupportedDomain(url)) {
|
||||
throw new Error(
|
||||
`Unsupported domain. Supported domains: ${this.FUCKINGFAST_DOMAINS.join(", ")}`
|
||||
);
|
||||
}
|
||||
return this.getFuckingFastDirectLink(url);
|
||||
}
|
||||
|
||||
public static async getFilename(
|
||||
url: string,
|
||||
directUrl?: string
|
||||
): Promise<string> {
|
||||
return extractHosterFilename(url, directUrl);
|
||||
}
|
||||
}
|
||||
@@ -36,16 +36,13 @@ export class GofileApi {
|
||||
}
|
||||
|
||||
public static async getDownloadLink(id: string) {
|
||||
const searchParams = new URLSearchParams({
|
||||
wt: WT,
|
||||
});
|
||||
|
||||
const response = await axios.get<{
|
||||
status: string;
|
||||
data: GofileContentsResponse;
|
||||
}>(`https://api.gofile.io/contents/${id}?${searchParams.toString()}`, {
|
||||
}>(`https://api.gofile.io/contents/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"X-Website-Token": WT,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,3 +3,5 @@ export * from "./qiwi";
|
||||
export * from "./datanodes";
|
||||
export * from "./mediafire";
|
||||
export * from "./pixeldrain";
|
||||
export * from "./buzzheavier";
|
||||
export * from "./fuckingfast";
|
||||
|
||||
87
src/main/services/node-7z.d.ts
vendored
Normal file
87
src/main/services/node-7z.d.ts
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
declare module "node-7z" {
|
||||
import { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
export interface CommandLineSwitches {
|
||||
$bin?: string;
|
||||
$progress?: boolean;
|
||||
$spawnOptions?: {
|
||||
cwd?: string;
|
||||
};
|
||||
outputDir?: string;
|
||||
yes?: boolean;
|
||||
password?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProgressInfo {
|
||||
percent: number;
|
||||
fileCount?: number;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
file?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ZipStream extends EventEmitter {
|
||||
on(event: "progress", listener: (progress: ProgressInfo) => void): this;
|
||||
on(event: "data", listener: (data: FileInfo) => void): this;
|
||||
on(event: "end", listener: () => void): this;
|
||||
on(event: "error", listener: (err: Error) => void): this;
|
||||
info: Map<string, unknown>;
|
||||
_childProcess?: ChildProcess;
|
||||
}
|
||||
|
||||
export function extractFull(
|
||||
archive: string,
|
||||
output: string,
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function extract(
|
||||
archive: string,
|
||||
output: string,
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function list(
|
||||
archive: string,
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function add(
|
||||
archive: string,
|
||||
files: string | string[],
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function update(
|
||||
archive: string,
|
||||
files: string | string[],
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function deleteFiles(
|
||||
archive: string,
|
||||
files: string | string[],
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function test(
|
||||
archive: string,
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
const Seven: {
|
||||
extractFull: typeof extractFull;
|
||||
extract: typeof extract;
|
||||
list: typeof list;
|
||||
add: typeof add;
|
||||
update: typeof update;
|
||||
delete: typeof deleteFiles;
|
||||
test: typeof test;
|
||||
};
|
||||
|
||||
export default Seven;
|
||||
}
|
||||
@@ -36,9 +36,9 @@ export class WindowManager {
|
||||
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
|
||||
{
|
||||
width: 1200,
|
||||
height: 720,
|
||||
height: 860,
|
||||
minWidth: 1024,
|
||||
minHeight: 540,
|
||||
minHeight: 860,
|
||||
backgroundColor: "#1c1c1c",
|
||||
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
|
||||
icon,
|
||||
@@ -106,7 +106,7 @@ export class WindowManager {
|
||||
valueEncoding: "json",
|
||||
}
|
||||
);
|
||||
return data ?? { isMaximized: false, height: 720, width: 1200 };
|
||||
return data ?? { isMaximized: false, height: 860, width: 1200 };
|
||||
}
|
||||
|
||||
private static updateInitialConfig(
|
||||
@@ -224,7 +224,7 @@ export class WindowManager {
|
||||
? {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
height: this.initialConfigInitializationMainWindow.height ?? 720,
|
||||
height: this.initialConfigInitializationMainWindow.height ?? 860,
|
||||
width: this.initialConfigInitializationMainWindow.width ?? 1200,
|
||||
isMaximized: true,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user