feat: adding import download source

This commit is contained in:
Chubby Granny Chaser 2024-06-03 02:12:05 +01:00
parent ddd9ea69df
commit 48e07370e4
No known key found for this signature in database
70 changed files with 925 additions and 1261 deletions

View File

@ -8,6 +8,7 @@ import {
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import svgr from "vite-plugin-svgr"; import svgr from "vite-plugin-svgr";
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
loadEnv(mode); loadEnv(mode);

View File

@ -59,6 +59,7 @@
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lottie-react": "^2.4.0", "lottie-react": "^2.4.0",
"parse-torrent": "^11.0.16", "parse-torrent": "^11.0.16",
"piscina": "^4.5.1",
"ps-list": "^8.1.1", "ps-list": "^8.1.1",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0", "react-loading-skeleton": "^3.4.0",
@ -66,7 +67,8 @@
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
"user-agents": "^1.1.193", "user-agents": "^1.1.193",
"yaml": "^2.4.1" "yaml": "^2.4.1",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^19.3.0", "@commitlint/cli": "^19.3.0",
@ -82,9 +84,10 @@
"@types/parse-torrent": "^5.8.7", "@types/parse-torrent": "^5.8.7",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@types/user-agents": "^1.0.4",
"@vanilla-extract/vite-plugin": "^4.0.7", "@vanilla-extract/vite-plugin": "^4.0.7",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"electron": "^28.2.0", "electron": "^30.0.9",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"electron-vite": "^2.0.0", "electron-vite": "^2.0.0",
"eslint": "^8.56.0", "eslint": "^8.56.0",

View File

@ -1,7 +1,6 @@
{ {
"home": { "home": {
"featured": "Featured", "featured": "Featured",
"recently_added": "Recently added",
"trending": "Trending", "trending": "Trending",
"surprise_me": "Surprise me", "surprise_me": "Surprise me",
"no_results": "No results found" "no_results": "No results found"
@ -149,6 +148,7 @@
"launch_with_system": "Launch Hydra on system start-up", "launch_with_system": "Launch Hydra on system start-up",
"general": "General", "general": "General",
"behavior": "Behavior", "behavior": "Behavior",
"download_sources": "Download sources",
"real_debrid_api_token": "API Token", "real_debrid_api_token": "API Token",
"enable_real_debrid": "Enable Real-Debrid", "enable_real_debrid": "Enable Real-Debrid",
"real_debrid_description": "Real-Debrid is an unrestricted downloader that allows you to download files instantly and at the best of your Internet speed.", "real_debrid_description": "Real-Debrid is an unrestricted downloader that allows you to download files instantly and at the best of your Internet speed.",
@ -157,7 +157,16 @@
"real_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to Real-Debrid", "real_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to Real-Debrid",
"real_debrid_linked_message": "Account \"{{username}}\" linked", "real_debrid_linked_message": "Account \"{{username}}\" linked",
"save_changes": "Save changes", "save_changes": "Save changes",
"changes_saved": "Changes successfully saved" "changes_saved": "Changes successfully saved",
"download_sources_description": "Hydra will fetch the download links from these sources. The source URL must be a direct link to a .json file containing the download links.",
"validate_download_source": "Validate",
"remove_download_source": "Remove",
"add_download_source": "Add source",
"download_options_zero": "No download option",
"download_options_one": "{{countFormatted}} download option",
"download_options_other": "{{countFormatted}} download options",
"download_source_url": "Download source URL",
"add_download_source_description": "Insert the URL containing the JSON file"
}, },
"notifications": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",
@ -180,5 +189,8 @@
}, },
"modal": { "modal": {
"close": "Close button" "close": "Close button"
},
"forms": {
"toggle_password_visibility": "Toggle password visibility"
} }
} }

View File

@ -145,6 +145,7 @@
"launch_with_system": "Iniciar o Hydra junto com o sistema", "launch_with_system": "Iniciar o Hydra junto com o sistema",
"general": "Geral", "general": "Geral",
"behavior": "Comportamento", "behavior": "Comportamento",
"download_sources": "Bibliotecas de download",
"real_debrid_api_token": "Token de API", "real_debrid_api_token": "Token de API",
"enable_real_debrid": "Habilitar Real-Debrid", "enable_real_debrid": "Habilitar Real-Debrid",
"real_debrid_api_token_hint": "Você pode obter seu token de API <0>aqui</0>", "real_debrid_api_token_hint": "Você pode obter seu token de API <0>aqui</0>",
@ -153,7 +154,16 @@
"real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine a Real-Debrid", "real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine a Real-Debrid",
"real_debrid_linked_message": "Conta \"{{username}}\" vinculada", "real_debrid_linked_message": "Conta \"{{username}}\" vinculada",
"save_changes": "Salvar mudanças", "save_changes": "Salvar mudanças",
"changes_saved": "Ajustes salvos com sucesso" "changes_saved": "Ajustes salvos com sucesso",
"download_sources_description": "Hydra vai buscar links de download em todas as bibliotecas habilitadas. A URL da biblioteca deve ser um link direto para um arquivo .json contendo uma lista de links.",
"validate_download_source": "Validar",
"remove_download_source": "Remover",
"add_download_source": "Adicionar biblioteca",
"download_options_zero": "Sem opções de download",
"download_options_one": "{{countFormatted}} opcão de download",
"download_options_other": "{{countFormatted}} opções de download",
"download_source_url": "URL da biblioteca",
"add_download_source_description": "Insira a URL contendo o arquivo JSON"
}, },
"notifications": { "notifications": {
"download_complete": "Download concluído", "download_complete": "Download concluído",
@ -180,5 +190,8 @@
}, },
"modal": { "modal": {
"close": "Botão de fechar" "close": "Botão de fechar"
},
"forms": {
"toggle_password_visibility": "Alternar visibilidade da senha"
} }
} }

View File

@ -1,23 +1,6 @@
import { app } from "electron"; import { app } from "electron";
import path from "node:path"; import path from "node:path";
export const repackersOn1337x = [
"DODI",
"FitGirl",
"0xEMPRESS",
"KaOsKrew",
"TinyRepacks",
] as const;
export const repackers = [
...repackersOn1337x,
"Xatab",
"TinyRepacks",
"CPG",
"GOG",
"onlinefix",
] as const;
export const defaultDownloadsPath = app.getPath("downloads"); export const defaultDownloadsPath = app.getPath("downloads");
export const databasePath = path.join( export const databasePath = path.join(

View File

@ -1,5 +1,11 @@
import { DataSource } from "typeorm"; import { DataSource } from "typeorm";
import { Game, GameShopCache, Repack, UserPreferences } from "@main/entity"; import {
DownloadSource,
Game,
GameShopCache,
Repack,
UserPreferences,
} from "@main/entity";
import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions"; import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions";
import { databasePath } from "./constants"; import { databasePath } from "./constants";
@ -10,7 +16,7 @@ export const createDataSource = (
) => ) =>
new DataSource({ new DataSource({
type: "better-sqlite3", type: "better-sqlite3",
entities: [Game, Repack, UserPreferences, GameShopCache], entities: [Game, Repack, UserPreferences, GameShopCache, DownloadSource],
synchronize: true, synchronize: true,
database: databasePath, database: databasePath,
...options, ...options,

View File

@ -0,0 +1,30 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from "typeorm";
import { Repack } from "./repack.entity";
@Entity("download_source")
export class DownloadSource {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { nullable: true, unique: true })
url: string;
@Column("text", { unique: true })
name: string;
@OneToMany(() => Repack, (repack) => repack.downloadSource, { cascade: true })
repacks: Repack[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -63,6 +63,9 @@ export class Game {
@Column("float", { default: 0 }) @Column("float", { default: 0 })
fileSize: number; fileSize: number;
@Column("text", { nullable: true })
uri: string | null;
@OneToOne(() => Repack, { nullable: true }) @OneToOne(() => Repack, { nullable: true })
@JoinColumn() @JoinColumn()
repack: Repack; repack: Repack;

View File

@ -2,3 +2,4 @@ export * from "./game.entity";
export * from "./repack.entity"; export * from "./repack.entity";
export * from "./user-preferences.entity"; export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity"; export * from "./game-shop-cache.entity";
export * from "./download-source.entity";

View File

@ -4,7 +4,9 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
ManyToOne,
} from "typeorm"; } from "typeorm";
import { DownloadSource } from "./download-source.entity";
@Entity("repack") @Entity("repack")
export class Repack { export class Repack {
@ -17,9 +19,6 @@ export class Repack {
@Column("text", { unique: true }) @Column("text", { unique: true })
magnet: string; magnet: string;
@Column("int")
page: number;
@Column("text") @Column("text")
repacker: string; repacker: string;
@ -29,6 +28,9 @@ export class Repack {
@Column("datetime") @Column("datetime")
uploadDate: Date | string; uploadDate: Date | string;
@ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" })
downloadSource: DownloadSource;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;

View File

@ -1,53 +1,24 @@
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers"; import { getSteamAppAsset } from "@main/helpers";
import type { CatalogueCategory, CatalogueEntry, GameShop } from "@types"; import type { CatalogueEntry, GameShop } from "@types";
import { stateManager } from "@main/state-manager";
import { searchGames, searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { requestSteam250 } from "@main/services"; import { SearchEngine, requestSteam250 } from "@main/services";
const repacks = stateManager.getValue("repacks");
const getStringForLookup = (index: number): string => {
const repack = repacks[index];
const formatter =
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
return formatName(formatter(repack.title));
};
const resultSize = 12; const resultSize = 12;
const getCatalogue = async ( const getCatalogue = async (_event: Electron.IpcMainInvokeEvent) => {
_event: Electron.IpcMainInvokeEvent, const trendingGames = await requestSteam250("/90day");
category: CatalogueCategory const results: CatalogueEntry[] = [];
) => {
if (!repacks.length) return [];
if (category === "trending") { for (let i = 0; i < resultSize; i++) {
return getTrendingCatalogue(resultSize); if (!trendingGames[i]) {
i++;
continue;
} }
return getRecentlyAddedCatalogue(resultSize);
};
const getTrendingCatalogue = async (
resultSize: number
): Promise<CatalogueEntry[]> => {
const results: CatalogueEntry[] = [];
const trendingGames = await requestSteam250("/90day");
for (
let i = 0;
i < trendingGames.length && results.length < resultSize;
i++
) {
if (!trendingGames[i]) continue;
const { title, objectID } = trendingGames[i]!; const { title, objectID } = trendingGames[i]!;
const repacks = searchRepacks(title); const repacks = SearchEngine.searchRepacks(title);
if (title && repacks.length) {
const catalogueEntry = { const catalogueEntry = {
objectID, objectID,
title, title,
@ -57,39 +28,8 @@ const getTrendingCatalogue = async (
results.push({ ...catalogueEntry, repacks }); results.push({ ...catalogueEntry, repacks });
} }
}
return results; return results;
}; };
const getRecentlyAddedCatalogue = async (
resultSize: number
): Promise<CatalogueEntry[]> => {
const results: CatalogueEntry[] = [];
for (let i = 0; results.length < resultSize; i++) {
const stringForLookup = getStringForLookup(i);
if (!stringForLookup) {
i++;
continue;
}
const games = searchGames({ query: stringForLookup });
for (const game of games) {
const isAlreadyIncluded = results.some(
(result) => result.objectID === game?.objectID
);
if (!game || !game.repacks.length || isAlreadyIncluded) {
continue;
}
results.push(game);
}
}
return results.slice(0, resultSize);
};
registerEvent("getCatalogue", getCatalogue); registerEvent("getCatalogue", getCatalogue);

View File

@ -4,9 +4,9 @@ import { getSteamAppDetails } from "@main/services";
import type { ShopDetails, GameShop, SteamAppDetails } from "@types"; import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { stateManager } from "@main/state-manager"; import { steamGamesWorker } from "@main/workers";
const getLocalizedSteamAppDetails = ( const getLocalizedSteamAppDetails = async (
objectID: string, objectID: string,
language: string language: string
): Promise<ShopDetails | null> => { ): Promise<ShopDetails | null> => {
@ -14,10 +14,11 @@ const getLocalizedSteamAppDetails = (
return getSteamAppDetails(objectID, language); return getSteamAppDetails(objectID, language);
} }
return getSteamAppDetails(objectID, language).then((localizedAppDetails) => { return getSteamAppDetails(objectID, language).then(
const steamGame = stateManager async (localizedAppDetails) => {
.getValue("steamGames") const steamGame = await steamGamesWorker.run(Number(objectID), {
.find((game) => game.id === Number(objectID)); name: "getById",
});
if (steamGame && localizedAppDetails) { if (steamGame && localizedAppDetails) {
return { return {
@ -27,7 +28,8 @@ const getLocalizedSteamAppDetails = (
} }
return null; return null;
}); }
);
}; };
const getGameShopDetails = async ( const getGameShopDetails = async (

View File

@ -1,39 +1,23 @@
import type { CatalogueEntry, GameShop } from "@types"; import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { searchRepacks } from "../helpers/search-games"; import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { stateManager } from "@main/state-manager"; import { steamGamesWorker } from "@main/workers";
import { getSteamAppAsset } from "@main/helpers";
const steamGames = stateManager.getValue("steamGames");
const getGames = async ( const getGames = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
take = 12, take = 12,
cursor = 0 cursor = 0
): Promise<{ results: CatalogueEntry[]; cursor: number }> => { ): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
const results: CatalogueEntry[] = []; const results = await steamGamesWorker.run(
{ limit: take, offset: cursor },
{ name: "list" }
);
let i = 0 + cursor; return {
results: results.map((result) => convertSteamGameToCatalogueEntry(result)),
while (results.length < take) { cursor: cursor + results.length,
const game = steamGames[i]; };
const repacks = searchRepacks(game.name);
if (repacks.length) {
results.push({
objectID: String(game.id),
title: game.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(game.id)),
repacks,
});
}
i++;
}
return { results, cursor: i };
}; };
registerEvent("getGames", getGames); registerEvent("getGames", getGames);

View File

@ -1,23 +1,36 @@
import { shuffle } from "lodash-es"; import { shuffle, slice } from "lodash-es";
import { getSteam250List } from "@main/services"; import { SearchEngine, getSteam250List } from "@main/services";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { searchGames, searchRepacks } from "../helpers/search-games"; import { searchSteamGames } from "../helpers/search-games";
import type { Steam250Game } from "@types"; import type { Steam250Game } from "@types";
const state = { games: Array<Steam250Game>(), index: 0 }; const state = { games: Array<Steam250Game>(), index: 0 };
const filterGames = async (games: Steam250Game[]) => {
const results: Steam250Game[] = [];
for (const game of games) {
const catalogue = await searchSteamGames({ query: game.title });
if (catalogue.length) {
const repacks = SearchEngine.searchRepacks(catalogue[0].title);
if (repacks.length) {
results.push(game);
}
}
}
return results;
};
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => { const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
if (state.games.length == 0) { if (state.games.length == 0) {
const steam250List = await getSteam250List(); const steam250List = await getSteam250List();
const filteredSteam250List = steam250List.filter((game) => { const filteredSteam250List = await filterGames(steam250List);
const repacks = searchRepacks(game.title);
const catalogue = searchGames({ query: game.title });
return repacks.length && catalogue.length;
});
state.games = shuffle(filteredSteam250List); state.games = shuffle(filteredSteam250List);
} }

View File

@ -1,11 +1,9 @@
import { searchRepacks } from "../helpers/search-games"; import { SearchEngine } from "@main/services";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
const searchGameRepacks = ( const searchGameRepacks = (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
query: string query: string
) => { ) => SearchEngine.searchRepacks(query);
return searchRepacks(query);
};
registerEvent("searchGameRepacks", searchGameRepacks); registerEvent("searchGameRepacks", searchGameRepacks);

View File

@ -1,12 +1,10 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { searchGames } from "../helpers/search-games"; import { searchSteamGames } from "../helpers/search-games";
import { CatalogueEntry } from "@types"; import { CatalogueEntry } from "@types";
const searchGamesEvent = async ( const searchGamesEvent = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
query: string query: string
): Promise<CatalogueEntry[]> => { ): Promise<CatalogueEntry[]> => searchSteamGames({ query, limit: 12 });
return searchGames({ query, take: 12 });
};
registerEvent("searchGames", searchGamesEvent); registerEvent("searchGames", searchGamesEvent);

View File

@ -0,0 +1,57 @@
import { registerEvent } from "../register-event";
import { chunk } from "lodash-es";
import { dataSource } from "@main/data-source";
import { DownloadSource, Repack } from "@main/entity";
import axios from "axios";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { downloadSourceSchema } from "../helpers/validators";
import { SearchEngine } from "@main/services";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const response = await axios.get(url);
const source = downloadSourceSchema.parse(response.data);
const downloadSource = await dataSource.transaction(
async (transactionalEntityManager) => {
const downloadSource = await transactionalEntityManager
.getRepository(DownloadSource)
.save({ url, name: source.name });
const repacks: QueryDeepPartialEntity<Repack>[] = source.downloads.map(
(download) => ({
title: download.title,
magnet: download.uris[0],
fileSize: download.fileSize,
repacker: source.name,
uploadDate: download.uploadDate,
downloadSource: { id: downloadSource.id },
})
);
const downloadsChunks = chunk(repacks, 800);
for (const chunk of downloadsChunks) {
await transactionalEntityManager
.getRepository(Repack)
.createQueryBuilder()
.insert()
.values(chunk)
.updateEntity(false)
.orIgnore()
.execute();
}
return downloadSource;
}
);
await SearchEngine.updateRepacks();
return downloadSource;
};
registerEvent("addDownloadSource", addDownloadSource);

View File

@ -0,0 +1,16 @@
import { downloadSourceRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
return downloadSourceRepository
.createQueryBuilder("downloadSource")
.leftJoin("downloadSource.repacks", "repacks")
.orderBy("downloadSource.createdAt", "DESC")
.loadRelationCountAndMap(
"downloadSource.repackCount",
"downloadSource.repacks"
)
.getMany();
};
registerEvent("getDownloadSources", getDownloadSources);

View File

@ -0,0 +1,13 @@
import { downloadSourceRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { SearchEngine } from "@main/services";
const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => {
await downloadSourceRepository.delete(id);
await SearchEngine.updateRepacks();
};
registerEvent("removeDownloadSource", removeDownloadSource);

View File

@ -0,0 +1,39 @@
import { z } from "zod";
import { registerEvent } from "../register-event";
import axios from "axios";
import { downloadSourceRepository } from "@main/repository";
const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
objectId: z.string().max(255).nullable(),
shop: z.enum(["steam"]).nullable(),
downloaders: z.array(z.enum(["real_debrid", "torrent"])),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
const validateDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const response = await axios.get(url);
const source = downloadSourceSchema.parse(response.data);
const existingSource = await downloadSourceRepository.findOne({
where: [{ url }, { name: source.name }],
});
if (existingSource?.url === url)
throw new Error("Source with the same url already exists");
return { name: source.name, downloadCount: source.downloads.length };
};
registerEvent("validateDownloadSource", validateDownloadSource);

View File

@ -1,40 +1,11 @@
import flexSearch from "flexsearch";
import { orderBy } from "lodash-es"; import { orderBy } from "lodash-es";
import flexSearch from "flexsearch";
import type { GameRepack, GameShop, CatalogueEntry } from "@types"; import type { GameShop, CatalogueEntry, SteamGame } from "@types";
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers"; import { getSteamAppAsset } from "@main/helpers";
import { stateManager } from "@main/state-manager"; import { SearchEngine } from "@main/services";
import { steamGamesWorker } from "@main/workers";
const { Index } = flexSearch;
const repacksIndex = new Index();
const steamGamesIndex = new Index({ tokenize: "forward" });
const repacks = stateManager.getValue("repacks");
const steamGames = stateManager.getValue("steamGames");
for (let i = 0; i < repacks.length; i++) {
const repack = repacks[i];
const formatter =
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
repacksIndex.add(i, formatName(formatter(repack.title)));
}
for (let i = 0; i < steamGames.length; i++) {
const steamGame = steamGames[i];
steamGamesIndex.add(i, formatName(steamGame.name));
}
export const searchRepacks = (title: string): GameRepack[] => {
return orderBy(
repacksIndex
.search(formatName(title))
.map((index) => repacks.at(index as number)!),
["uploadDate"],
"desc"
);
};
export interface SearchGamesArgs { export interface SearchGamesArgs {
query?: string; query?: string;
@ -42,27 +13,25 @@ export interface SearchGamesArgs {
skip?: number; skip?: number;
} }
export const searchGames = ({ export const convertSteamGameToCatalogueEntry = (
query, result: SteamGame
take, ): CatalogueEntry => {
skip,
}: SearchGamesArgs): CatalogueEntry[] => {
const results = steamGamesIndex
.search(formatName(query || ""), { limit: take, offset: skip })
.map((index) => {
const result = steamGames.at(index as number)!;
return { return {
objectID: String(result.id), objectID: String(result.id),
title: result.name, title: result.name,
shop: "steam" as GameShop, shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(result.id)), cover: getSteamAppAsset("library", String(result.id)),
repacks: searchRepacks(result.name), repacks: SearchEngine.searchRepacks(result.name),
}; };
}); };
export const searchSteamGames = async (
options: flexSearch.SearchOptions
): Promise<CatalogueEntry[]> => {
const steamGames = await steamGamesWorker.run(options, { name: "search" });
return orderBy( return orderBy(
results, steamGames.map((result) => convertSteamGameToCatalogueEntry(result)),
[({ repacks }) => repacks.length, "repacks"], [({ repacks }) => repacks.length, "repacks"],
["desc"] ["desc"]
); );

View File

@ -0,0 +1,16 @@
import { z } from "zod";
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
objectId: z.string().max(255).nullable(),
shop: z.enum(["steam"]).nullable(),
downloaders: z.array(z.enum(["real_debrid", "torrent"])),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});

View File

@ -30,6 +30,10 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates"; import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update"; import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid"; import "./user-preferences/authenticate-real-debrid";
import "./download-sources/get-download-sources";
import "./download-sources/validate-download-source";
import "./download-sources/add-download-source";
import "./download-sources/remove-download-source";
ipcMain.handle("ping", () => "pong"); ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion()); ipcMain.handle("getVersion", () => app.getVersion());

View File

@ -11,9 +11,6 @@ const getGameByObjectID = async (
objectID, objectID,
isDeleted: false, isDeleted: false,
}, },
relations: {
repack: true,
},
}); });
registerEvent("getGameByObjectID", getGameByObjectID); registerEvent("getGameByObjectID", getGameByObjectID);

View File

@ -1,30 +1,14 @@
import { gameRepository } from "@main/repository"; import { gameRepository } from "@main/repository";
import { searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { sortBy } from "lodash-es";
const getLibrary = async () => const getLibrary = async () =>
gameRepository gameRepository.find({
.find({
where: { where: {
isDeleted: false, isDeleted: false,
}, },
order: { order: {
createdAt: "desc", createdAt: "desc",
}, },
relations: { });
repack: true,
},
})
.then((games) =>
sortBy(
games.map((game) => ({
...game,
repacks: searchRepacks(game.title),
})),
(game) => (game.status !== "removed" ? 0 : 1)
)
);
registerEvent("getLibrary", getLibrary); registerEvent("getLibrary", getLibrary);

View File

@ -16,7 +16,6 @@ const resumeGameDownload = async (
id: gameId, id: gameId,
isDeleted: false, isDeleted: false,
}, },
relations: { repack: true },
}); });
if (!game) return; if (!game) return;

View File

@ -20,7 +20,6 @@ const startGameDownload = async (
objectID, objectID,
shop, shop,
}, },
relations: { repack: true },
}), }),
repackRepository.findOne({ repackRepository.findOne({
where: { where: {
@ -49,7 +48,7 @@ const startGameDownload = async (
bytesDownloaded: 0, bytesDownloaded: 0,
downloadPath, downloadPath,
downloader, downloader,
repack: { id: repackId }, uri: repack.magnet,
isDeleted: false, isDeleted: false,
} }
); );
@ -71,7 +70,7 @@ const startGameDownload = async (
shop, shop,
status: "active", status: "active",
downloadPath, downloadPath,
repack: { id: repackId }, uri: repack.magnet,
}) })
.then((result) => { .then((result) => {
if (iconUrl) { if (iconUrl) {
@ -88,7 +87,6 @@ const startGameDownload = async (
where: { where: {
objectID, objectID,
}, },
relations: { repack: true },
}); });
await DownloadManager.startDownload(updatedGame!); await DownloadManager.startDownload(updatedGame!);

View File

@ -1,98 +0,0 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import {
dodiFormatter,
empressFormatter,
fitGirlFormatter,
kaosKrewFormatter,
} from "./formatters";
describe("testing formatters", () => {
describe("testing fitgirl formatter", () => {
const fitGirlGames = [
"REVEIL (v1.0.3f4 + 0.5 DLC, MULTi14) [FitGirl Repack]",
"Dune: Spice Wars - The Ixian Edition (v2.0.0.31558 + DLC, MULTi9) [FitGirl Repack]",
"HUMANKIND: Premium Edition (v1.0.22.3819 + 17 DLCs/Bonus Content, MULTi12) [FitGirl Repack, Selective Download - from 7.3 GB]",
"Call to Arms: Gates of Hell - Ostfront: WW2 Bundle (v1.034 Hotfix 3 + 3 DLCs, MULTi9) [FitGirl Repack, Selective Download - from 21.8 GB]",
"SUPER BOMBERMAN R 2 (v1.2.0, MULTi12) [FitGirl Repack]",
"God of Rock (v3110, MULTi11) [FitGirl Repack]",
];
test("should format games correctly", () => {
assert.equal(fitGirlGames.map(fitGirlFormatter), [
"REVEIL",
"Dune: Spice Wars - The Ixian Edition",
"HUMANKIND: Premium Edition",
"Call to Arms: Gates of Hell - Ostfront: WW2 Bundle",
"SUPER BOMBERMAN R 2",
"God of Rock",
]);
});
});
describe("testing kaoskrew formatter", () => {
const kaosKrewGames = [
"Song.Of.Horror.Complete.Edition.v1.25.MULTi4.REPACK-KaOs",
"Remoteness.REPACK-KaOs",
"Persona.5.Royal.v1.0.0.MULTi5.NSW.For.PC.REPACK-KaOs",
"The.Wreck.MULTi5.REPACK-KaOs",
"Nemezis.Mysterious.Journey.III.v1.04.Deluxe.Edition.REPACK-KaOs",
"The.World.Of.Others.v1.05.REPACK-KaOs",
];
test("should format games correctly", () => {
assert.equal(kaosKrewGames.map(kaosKrewFormatter), [
"Song Of Horror Complete Edition",
"Remoteness",
"Persona 5 Royal NSW For PC",
"The Wreck",
"Nemezis Mysterious Journey III Deluxe Edition",
"The World Of Others",
]);
});
});
describe("testing empress formatter", () => {
const empressGames = [
"Resident.Evil.4-EMPRESS",
"Marvels.Guardians.of.the.Galaxy.Crackfix-EMPRESS",
"Life.is.Strange.2.Complete.Edition-EMPRESS",
"Forza.Horizon.4.PROPER-EMPRESS",
"Just.Cause.4.Complete.Edition.READNFO-EMPRESS",
"Immortals.Fenyx.Rising.Crackfix.V2-EMPRESS",
];
test("should format games correctly", () => {
assert.equal(empressGames.map(empressFormatter), [
"Resident Evil 4",
"Marvels Guardians of the Galaxy",
"Life is Strange 2 Complete Edition",
"Forza Horizon 4 PROPER",
"Just Cause 4 Complete Edition",
"Immortals Fenyx Rising",
]);
});
});
describe("testing kodi formatter", () => {
const dodiGames = [
"Tomb Raider I-III Remastered Starring Lara Croft (MULTi20) (From 2.5 GB) [DODI Repack]",
"Trail Out: Complete Edition (v2.9st + All DLCs + MULTi11) [DODI Repack]",
"Call to Arms - Gates of Hell: Ostfront (v1.034.0 + All DLCs + MULTi9) (From 22.4 GB) [DODI Repack]",
"Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition (Digital book + MULTi6) [DODI Repack]",
"DREDGE: Digital Deluxe Edition (v1.2.0.1922 + All DLCs + Bonus Content + MULTi11) (From 413 MB) [DODI Repack]",
"Outliver: Tribulation [DODI Repack]",
];
test("should format games correctly", () => {
assert.equal(dodiGames.map(dodiFormatter), [
"Tomb Raider I-III Remastered Starring Lara Croft",
"Trail Out: Complete Edition",
"Call to Arms - Gates of Hell: Ostfront",
"Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition",
"DREDGE: Digital Deluxe Edition",
"Outliver: Tribulation",
]);
});
});
});

View File

@ -1,56 +0,0 @@
/* String formatting */
export const removeReleaseYearFromName = (name: string) =>
name.replace(/\([0-9]{4}\)/g, "");
export const removeSymbolsFromName = (name: string) =>
name.replace(/[^A-Za-z 0-9]/g, "");
export const removeSpecialEditionFromName = (name: string) =>
name.replace(
/(The |Digital )?(GOTY|Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year|Reloaded|[0-9]{4}) Edition/g,
""
);
export const removeDuplicateSpaces = (name: string) =>
name.replace(/\s{2,}/g, " ");
export const removeTrash = (title: string) =>
title.replace(/\(.*\)|\[.*]/g, "").replace(/:/g, "");
/* Formatters per repacker */
export const fitGirlFormatter = (title: string) =>
title.replace(/\(.*\)/g, "").trim();
export const kaosKrewFormatter = (title: string) =>
title
.replace(/(v\.?[0-9])+([0-9]|\.)+/, "")
.replace(
/(\.Build\.[0-9]*)?(\.MULTi[0-9]{1,2})?(\.REPACK-KaOs|\.UPDATE-KaOs)?/g,
""
)
.replace(/\./g, " ")
.trim();
export const empressFormatter = (title: string) =>
title
.replace(/-EMPRESS/, "")
.replace(/\./g, " ")
.trim();
export const dodiFormatter = (title: string) =>
title.replace(/\(.*?\)/g, "").trim();
export const xatabFormatter = (title: string) =>
title
.replace(/RePack от xatab|RePack от Decepticon|R.G. GOGFAN/, "")
.replace(/[\u0400-\u04FF]/g, "")
.replace(/(v\.?([0-9]| )+)+([0-9]|\.|-|_|\/|[a-zA-Z]| )+/, "");
export const tinyRepacksFormatter = (title: string) => title;
export const onlinefixFormatter = (title: string) =>
title.replace("по сети", "").trim();
export const gogFormatter = (title: string) =>
title.replace(/(v\.[0-9]+|v[0-9]+\.|v[0-9]{4})+.+/, "");

View File

@ -1,48 +1,5 @@
import { import axios from "axios";
removeReleaseYearFromName, import UserAgent from "user-agents";
removeSymbolsFromName,
removeSpecialEditionFromName,
empressFormatter,
kaosKrewFormatter,
fitGirlFormatter,
removeDuplicateSpaces,
dodiFormatter,
removeTrash,
xatabFormatter,
tinyRepacksFormatter,
gogFormatter,
onlinefixFormatter,
} from "./formatters";
import { repackers } from "../constants";
export const pipe =
<T>(...fns: ((arg: T) => any)[]) =>
(arg: T) =>
fns.reduce((prev, fn) => fn(prev), arg);
export const formatName = pipe<string>(
removeTrash,
removeReleaseYearFromName,
removeSymbolsFromName,
removeSpecialEditionFromName,
removeDuplicateSpaces,
(str) => str.trim()
);
export const repackerFormatter: Record<
(typeof repackers)[number],
(title: string) => string
> = {
DODI: dodiFormatter,
"0xEMPRESS": empressFormatter,
KaOsKrew: kaosKrewFormatter,
FitGirl: fitGirlFormatter,
Xatab: xatabFormatter,
CPG: (title: string) => title,
TinyRepacks: tinyRepacksFormatter,
GOG: gogFormatter,
onlinefix: onlinefixFormatter,
};
export const getSteamAppAsset = ( export const getSteamAppAsset = (
category: "library" | "hero" | "logo" | "icon", category: "library" | "hero" | "logo" | "icon",
@ -88,5 +45,16 @@ export const steamUrlBuilder = {
export const sleep = (ms: number) => export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms)); new Promise((resolve) => setTimeout(resolve, ms));
export * from "./formatters"; export const requestWebPage = async (url: string) => {
const userAgent = new UserAgent();
return axios
.get(url, {
headers: {
"User-Agent": userAgent.toString(),
},
})
.then((response) => response.data);
};
export * from "./ps"; export * from "./ps";

View File

@ -3,15 +3,11 @@ import updater from "electron-updater";
import i18n from "i18next"; import i18n from "i18next";
import path from "node:path"; import path from "node:path";
import { electronApp, optimizer } from "@electron-toolkit/utils"; import { electronApp, optimizer } from "@electron-toolkit/utils";
import { import { DownloadManager, logger, WindowManager } from "@main/services";
DownloadManager,
logger,
resolveDatabaseUpdates,
WindowManager,
} from "@main/services";
import { dataSource } from "@main/data-source"; import { dataSource } from "@main/data-source";
import * as resources from "@locales"; import * as resources from "@locales";
import { userPreferencesRepository } from "@main/repository"; import { userPreferencesRepository } from "@main/repository";
const { autoUpdater } = updater; const { autoUpdater } = updater;
autoUpdater.setFeedURL({ autoUpdater.setFeedURL({
@ -51,18 +47,16 @@ if (process.defaultApp) {
// This method will be called when Electron has finished // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // Some APIs can only be used after this event occurs.
app.whenReady().then(() => { app.whenReady().then(async () => {
electronApp.setAppUserModelId("site.hydralauncher.hydra"); electronApp.setAppUserModelId("site.hydralauncher.hydra");
protocol.handle("hydra", (request) => protocol.handle("hydra", (request) =>
net.fetch("file://" + request.url.slice("hydra://".length)) net.fetch("file://" + request.url.slice("hydra://".length))
); );
dataSource.initialize().then(async () => { await dataSource.initialize();
await dataSource.runMigrations(); await dataSource.runMigrations();
await resolveDatabaseUpdates();
await import("./main"); await import("./main");
const userPreferences = await userPreferencesRepository.findOne({ const userPreferences = await userPreferencesRepository.findOne({
@ -72,7 +66,6 @@ app.whenReady().then(() => {
WindowManager.createMainWindow(); WindowManager.createMainWindow();
WindowManager.createSystemTray(userPreferences?.language || "en"); WindowManager.createSystemTray(userPreferences?.language || "en");
}); });
});
app.on("browser-window-created", (_, window) => { app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window); optimizer.watchWindowShortcuts(window);

View File

@ -1,88 +1,12 @@
import { stateManager } from "./state-manager"; import { DownloadManager, SearchEngine, startMainLoop } from "./services";
import { repackersOn1337x, seedsPath } from "./constants"; import { gameRepository, userPreferencesRepository } from "./repository";
import { import { UserPreferences } from "./entity";
getNewGOGGames,
getNewRepacksFromUser,
getNewRepacksFromXatab,
getNewRepacksFromOnlineFix,
DownloadManager,
startMainLoop,
} from "./services";
import {
gameRepository,
repackRepository,
userPreferencesRepository,
} from "./repository";
import { Repack, UserPreferences } from "./entity";
import { Notification } from "electron";
import { t } from "i18next";
import fs from "node:fs";
import path from "node:path";
import { RealDebridClient } from "./services/real-debrid"; import { RealDebridClient } from "./services/real-debrid";
import { orderBy } from "lodash-es";
import { SteamGame } from "@types";
import { Not } from "typeorm"; import { Not } from "typeorm";
startMainLoop(); startMainLoop();
const track1337xUsers = async (existingRepacks: Repack[]) => {
for (const repacker of repackersOn1337x) {
await getNewRepacksFromUser(
repacker,
existingRepacks.filter((repack) => repack.repacker === repacker)
);
}
};
const checkForNewRepacks = async (userPreferences: UserPreferences | null) => {
const existingRepacks = stateManager.getValue("repacks");
Promise.allSettled([
track1337xUsers(existingRepacks),
getNewRepacksFromXatab(
existingRepacks.filter((repack) => repack.repacker === "Xatab")
),
getNewGOGGames(
existingRepacks.filter((repack) => repack.repacker === "GOG")
),
getNewRepacksFromOnlineFix(
existingRepacks.filter((repack) => repack.repacker === "onlinefix")
),
]).then(() => {
repackRepository.count().then((count) => {
const total = count - stateManager.getValue("repacks").length;
if (total > 0 && userPreferences?.repackUpdatesNotificationsEnabled) {
new Notification({
title: t("repack_list_updated", {
ns: "notifications",
lng: userPreferences?.language || "en",
}),
body: t("repack_count", {
ns: "notifications",
lng: userPreferences?.language || "en",
count: total,
}),
}).show();
}
});
});
};
const loadState = async (userPreferences: UserPreferences | null) => { const loadState = async (userPreferences: UserPreferences | null) => {
const repacks = repackRepository.find({
order: {
createdAt: "desc",
},
});
const steamGames = JSON.parse(
fs.readFileSync(path.join(seedsPath, "steam-games.json"), "utf-8")
) as SteamGame[];
stateManager.setValue("repacks", await repacks);
stateManager.setValue("steamGames", orderBy(steamGames, ["name"], "asc"));
import("./events"); import("./events");
if (userPreferences?.realDebridApiToken) if (userPreferences?.realDebridApiToken)
@ -94,10 +18,10 @@ const loadState = async (userPreferences: UserPreferences | null) => {
progress: Not(1), progress: Not(1),
isDeleted: false, isDeleted: false,
}, },
relations: { repack: true },
}); });
if (game) DownloadManager.startDownload(game); if (game) DownloadManager.startDownload(game);
await SearchEngine.updateRepacks();
}; };
userPreferencesRepository userPreferencesRepository
@ -105,5 +29,5 @@ userPreferencesRepository
where: { id: 1 }, where: { id: 1 },
}) })
.then((userPreferences) => { .then((userPreferences) => {
loadState(userPreferences).then(() => checkForNewRepacks(userPreferences)); loadState(userPreferences);
}); });

View File

@ -1,5 +1,11 @@
import { dataSource } from "./data-source"; import { dataSource } from "./data-source";
import { Game, GameShopCache, Repack, UserPreferences } from "@main/entity"; import {
DownloadSource,
Game,
GameShopCache,
Repack,
UserPreferences,
} from "@main/entity";
export const gameRepository = dataSource.getRepository(Game); export const gameRepository = dataSource.getRepository(Game);
@ -9,3 +15,6 @@ export const userPreferencesRepository =
dataSource.getRepository(UserPreferences); dataSource.getRepository(UserPreferences);
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache); export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
export const downloadSourceRepository =
dataSource.getRepository(DownloadSource);

View File

@ -192,7 +192,6 @@ export class DownloadManager {
const game = await gameRepository.findOne({ const game = await gameRepository.findOne({
where: { id: this.game.id, isDeleted: false }, where: { id: this.game.id, isDeleted: false },
relations: { repack: true },
}); });
if (progress === 1 && this.game && !isDownloadingMetadata) { if (progress === 1 && this.game && !isDownloadingMetadata) {
@ -291,10 +290,10 @@ export class DownloadManager {
if (game.downloader === Downloader.RealDebrid) { if (game.downloader === Downloader.RealDebrid) {
this.realDebridTorrentId = await RealDebridClient.getTorrentId( this.realDebridTorrentId = await RealDebridClient.getTorrentId(
game!.repack.magnet game!.uri!
); );
} else { } else {
this.gid = await this.aria2.call("addUri", [game.repack.magnet], options); this.gid = await this.aria2.call("addUri", [game.uri!], options);
this.downloads.set(game.id, this.gid); this.downloads.set(game.id, this.gid);
} }

View File

@ -1,8 +1,8 @@
import { formatName } from "@main/helpers";
import axios from "axios"; import axios from "axios";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { requestWebPage } from "./repack-tracker/helpers"; import { requestWebPage } from "@main/helpers";
import { HowLongToBeatCategory } from "@types"; import { HowLongToBeatCategory } from "@types";
import { formatName } from "@shared";
export interface HowLongToBeatResult { export interface HowLongToBeatResult {
game_id: number; game_id: number;

View File

@ -1,11 +1,10 @@
export * from "./logger"; export * from "./logger";
export * from "./repack-tracker";
export * from "./steam"; export * from "./steam";
export * from "./steam-250"; export * from "./steam-250";
export * from "./steam-grid"; export * from "./steam-grid";
export * from "./update-resolver";
export * from "./window-manager"; export * from "./window-manager";
export * from "./download-manager"; export * from "./download-manager";
export * from "./how-long-to-beat"; export * from "./how-long-to-beat";
export * from "./process-watcher"; export * from "./process-watcher";
export * from "./main-loop"; export * from "./main-loop";
export * from "./search-engine";

View File

@ -1,146 +0,0 @@
import { JSDOM } from "jsdom";
import { Repack } from "@main/entity";
import { requestWebPage, savePage } from "./helpers";
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
export const request1337x = async (path: string) =>
requestWebPage(`https://1337xx.to${path}`);
const formatUploadDate = (str: string) => {
const date = new Date();
const [month, day, year] = str.split(" ");
date.setMonth(months.indexOf(month.replace(".", "")));
date.setDate(Number(day.substring(0, 2)));
date.setFullYear(Number("20" + year.replace("'", "")));
date.setHours(0, 0, 0, 0);
return date;
};
/* TODO: $a will often be null */
const getTorrentDetails = async (path: string) => {
const response = await request1337x(path);
const { window } = new JSDOM(response);
const { document } = window;
const $a = window.document.querySelector(
".torrentdown1"
) as HTMLAnchorElement;
const $ul = Array.from(
document.querySelectorAll(".torrent-detail-page .list")
);
const [$firstColumn, $secondColumn] = $ul;
if (!$firstColumn || !$secondColumn) {
return { magnet: $a?.href };
}
const [_$category, _$type, _$language, $totalSize] = $firstColumn.children;
const [_$downloads, _$lastChecked, $dateUploaded] = $secondColumn.children;
return {
magnet: $a?.href,
fileSize: $totalSize.querySelector("span")!.textContent,
uploadDate: formatUploadDate(
$dateUploaded.querySelector("span")!.textContent!
),
};
};
export const getTorrentListLastPage = async (user: string) => {
const response = await request1337x(`/user/${user}/1`);
const { window } = new JSDOM(response);
const $ul = window.document.querySelector(".pagination > ul");
if ($ul) {
const $li = Array.from($ul.querySelectorAll("li")).at(-1);
const text = $li?.textContent;
if (text === ">>") {
const $previousLi = Array.from($ul.querySelectorAll("li")).at(-2);
return Number($previousLi?.textContent);
}
return Number(text);
}
return -1;
};
export const extractTorrentsFromDocument = async (
page: number,
user: string,
document: Document
) => {
const $trs = Array.from(document.querySelectorAll("tbody tr"));
return Promise.all(
$trs.map(async ($tr) => {
const $td = $tr.querySelector("td");
const [, $name] = Array.from($td!.querySelectorAll("a"));
const url = $name.href;
const title = $name.textContent ?? "";
const details = await getTorrentDetails(url);
return {
title,
magnet: details.magnet,
fileSize: details.fileSize ?? "N/A",
uploadDate: details.uploadDate ?? new Date(),
repacker: user,
page,
};
})
);
};
export const getNewRepacksFromUser = async (
user: string,
existingRepacks: Repack[],
page = 1
) => {
const response = await request1337x(`/user/${user}/${page}`);
const { window } = new JSDOM(response);
const repacks = await extractTorrentsFromDocument(
page,
user,
window.document
);
const newRepacks = repacks.filter(
(repack) =>
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
return getNewRepacksFromUser(user, existingRepacks, page + 1);
};

View File

@ -1,96 +0,0 @@
import { JSDOM, VirtualConsole } from "jsdom";
import { requestWebPage, savePage } from "./helpers";
import { Repack } from "@main/entity";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
const virtualConsole = new VirtualConsole();
const getUploadDate = (document: Document) => {
const $modifiedTime = document.querySelector(
'[property="article:modified_time"]'
) as HTMLMetaElement;
if ($modifiedTime) return $modifiedTime.content;
const $publishedTime = document.querySelector(
'[property="article:published_time"]'
) as HTMLMetaElement;
return $publishedTime.content;
};
const getDownloadLink = (document: Document) => {
const $latestDownloadButton = document.querySelector(
".download-btn:not(.lightweight-accordion *)"
) as HTMLAnchorElement;
if ($latestDownloadButton) return $latestDownloadButton.href;
const $downloadButton = document.querySelector(
".download-btn"
) as HTMLAnchorElement;
if (!$downloadButton) return null;
return $downloadButton.href;
};
const getMagnet = (downloadLink: string) => {
if (downloadLink.startsWith("http")) {
const { searchParams } = new URL(downloadLink);
return Buffer.from(searchParams.get("url")!, "base64").toString("utf-8");
}
return downloadLink;
};
const getGOGGame = async (url: string) => {
const data = await requestWebPage(url);
const { window } = new JSDOM(data, { virtualConsole });
const downloadLink = getDownloadLink(window.document);
if (!downloadLink) return null;
const $em = window.document.querySelector("p em");
if (!$em) return null;
const fileSize = $em.textContent!.split("Size: ").at(1);
return {
fileSize: fileSize ?? "N/A",
uploadDate: new Date(getUploadDate(window.document)),
repacker: "GOG",
magnet: getMagnet(downloadLink),
page: 1,
};
};
export const getNewGOGGames = async (existingRepacks: Repack[] = []) => {
const data = await requestWebPage(
"https://freegogpcgames.com/a-z-games-list/"
);
const { window } = new JSDOM(data, { virtualConsole });
const $uls = Array.from(window.document.querySelectorAll(".az-columns"));
for (const $ul of $uls) {
const repacks: QueryDeepPartialEntity<Repack>[] = [];
const $lis = Array.from($ul.querySelectorAll("li"));
for (const $li of $lis) {
const $a = $li.querySelector("a")!;
const href = $a.href;
const title = $a.textContent!.trim();
const gameExists = existingRepacks.some(
(existingRepack) => existingRepack.title === title
);
if (!gameExists) {
const game = await getGOGGame(href);
if (game) repacks.push({ ...game, title });
}
}
if (repacks.length) await savePage(repacks);
}
};

View File

@ -1,40 +0,0 @@
import axios from "axios";
import UserAgent from "user-agents";
import type { Repack } from "@main/entity";
import { repackRepository } from "@main/repository";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
export const savePage = async (repacks: QueryDeepPartialEntity<Repack>[]) =>
Promise.all(
repacks.map((repack) => repackRepository.insert(repack).catch(() => {}))
);
export const requestWebPage = async (url: string) => {
const userAgent = new UserAgent();
return axios
.get(url, {
headers: {
"User-Agent": userAgent.toString(),
},
})
.then((response) => response.data);
};
export const decodeNonUtf8Response = async (res: Response) => {
const contentType = res.headers.get("content-type");
if (!contentType) return res.text();
const charset = contentType.substring(contentType.indexOf("charset=") + 8);
const text = await res.arrayBuffer().then((ab) => {
const dataView = new DataView(ab);
const decoder = new TextDecoder(charset);
return decoder.decode(dataView);
});
return text;
};

View File

@ -1,4 +0,0 @@
export * from "./1337x";
export * from "./xatab";
export * from "./gog";
export * from "./online-fix";

View File

@ -1,157 +0,0 @@
import { Repack } from "@main/entity";
import { decodeNonUtf8Response, savePage } from "./helpers";
import { logger } from "../logger";
import { JSDOM } from "jsdom";
import createWorker from "@main/workers/torrent-parser.worker?nodeWorker";
import { toMagnetURI } from "parse-torrent";
const worker = createWorker({});
import makeFetchCookie from "fetch-cookie";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { formatBytes } from "@shared";
const ONLINE_FIX_URL = "https://online-fix.me/";
let totalPages = 1;
export const getNewRepacksFromOnlineFix = async (
existingRepacks: Repack[] = [],
page = 1,
cookieJar = new makeFetchCookie.toughCookie.CookieJar()
): Promise<void> => {
const hasCredentials =
import.meta.env.MAIN_VITE_ONLINEFIX_USERNAME &&
import.meta.env.MAIN_VITE_ONLINEFIX_PASSWORD;
if (!hasCredentials) return;
const http = makeFetchCookie(fetch, cookieJar);
if (page === 1) {
await http(ONLINE_FIX_URL);
const preLogin =
((await http("https://online-fix.me/engine/ajax/authtoken.php", {
method: "GET",
headers: {
"X-Requested-With": "XMLHttpRequest",
Referer: ONLINE_FIX_URL,
},
}).then((res) => res.json())) as {
field: string;
value: string;
}) || undefined;
if (!preLogin.field || !preLogin.value) return;
const params = new URLSearchParams({
login_name: import.meta.env.MAIN_VITE_ONLINEFIX_USERNAME,
login_password: import.meta.env.MAIN_VITE_ONLINEFIX_PASSWORD,
login: "submit",
[preLogin.field]: preLogin.value,
});
await http(ONLINE_FIX_URL, {
method: "POST",
headers: {
Referer: ONLINE_FIX_URL,
Origin: ONLINE_FIX_URL,
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
}
const pageParams = page > 1 ? `${`/page/${page}`}` : "";
const home = await http(`https://online-fix.me${pageParams}`).then((res) =>
decodeNonUtf8Response(res)
);
const document = new JSDOM(home).window.document;
const repacks: QueryDeepPartialEntity<Repack>[] = [];
const articles = Array.from(document.querySelectorAll(".news"));
if (page == 1) {
totalPages = Number(
document.querySelector("nav > a:nth-child(13)")?.textContent
);
}
try {
await Promise.all(
articles.map(async (article) => {
const gameLink = article.querySelector("a")?.getAttribute("href");
if (!gameLink) return;
const gamePage = await http(gameLink).then((res) =>
decodeNonUtf8Response(res)
);
const gameDocument = new JSDOM(gamePage).window.document;
const torrentButtons = Array.from(
gameDocument.querySelectorAll("a")
).filter((a) => a.textContent?.includes("Torrent"));
const torrentPrePage = torrentButtons[0]?.getAttribute("href");
if (!torrentPrePage) return;
const torrentPage = await http(torrentPrePage, {
headers: {
Referer: gameLink,
},
}).then((res) => res.text());
const torrentDocument = new JSDOM(torrentPage).window.document;
const torrentLink = torrentDocument
.querySelector("a:nth-child(2)")
?.getAttribute("href");
const torrentFile = Buffer.from(
await http(`${torrentPrePage}${torrentLink}`).then((res) =>
res.arrayBuffer()
)
);
worker.once("message", (torrent) => {
if (!torrent) return;
const { name, created } = torrent;
repacks.push({
fileSize: formatBytes(torrent.length ?? 0),
magnet: toMagnetURI(torrent),
page: 1,
repacker: "onlinefix",
title: name,
uploadDate: created,
});
});
worker.postMessage(torrentFile);
})
);
} catch (err: unknown) {
logger.error((err as Error).message, {
method: "getNewRepacksFromOnlineFix",
});
}
const newRepacks = repacks.filter(
(repack) =>
repack.uploadDate &&
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
if (page === totalPages) return;
return getNewRepacksFromOnlineFix(existingRepacks, page + 1, cookieJar);
};

View File

@ -1,120 +0,0 @@
import { JSDOM } from "jsdom";
import { Repack } from "@main/entity";
import { logger } from "../logger";
import { requestWebPage, savePage } from "./helpers";
import createWorker from "@main/workers/torrent-parser.worker?nodeWorker";
import { toMagnetURI } from "parse-torrent";
import type { Instance } from "parse-torrent";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { formatBytes } from "@shared";
import { getFileBuffer } from "@main/helpers";
const worker = createWorker({});
worker.setMaxListeners(11);
let totalPages = 1;
const formatXatabDate = (str: string) => {
const date = new Date();
const [day, month, year] = str.split(".");
date.setDate(Number(day));
date.setMonth(Number(month) - 1);
date.setFullYear(Number(year));
date.setHours(0, 0, 0, 0);
return date;
};
const getXatabRepack = (
url: string
): Promise<{ fileSize: string; magnet: string; uploadDate: Date } | null> => {
return new Promise((resolve) => {
(async () => {
const data = await requestWebPage(url);
const { window } = new JSDOM(data);
const { document } = window;
const $uploadDate = document.querySelector(".entry__date");
const $downloadButton = document.querySelector(
".download-torrent"
) as HTMLAnchorElement;
if (!$downloadButton) return resolve(null);
worker.once("message", (torrent: Instance | null) => {
if (!torrent) return resolve(null);
resolve({
fileSize: formatBytes(torrent.length ?? 0),
magnet: toMagnetURI(torrent),
uploadDate: formatXatabDate($uploadDate!.textContent!),
});
});
const buffer = await getFileBuffer($downloadButton.href);
worker.postMessage(buffer);
})();
});
};
export const getNewRepacksFromXatab = async (
existingRepacks: Repack[] = [],
page = 1
): Promise<void> => {
const data = await requestWebPage(`https://byxatab.com/page/${page}`);
const { window } = new JSDOM(data);
const repacks: QueryDeepPartialEntity<Repack>[] = [];
if (page === 1) {
totalPages = Number(
window.document.querySelector(
"#bottom-nav > div.pagination > a:nth-child(12)"
)?.textContent
);
}
const repacksFromPage = Array.from(
window.document.querySelectorAll(".entry__title a")
).map(($a) => {
return getXatabRepack(($a as HTMLAnchorElement).href)
.then((repack) => {
if (repack) {
repacks.push({
title: $a.textContent!,
repacker: "Xatab",
...repack,
page,
});
}
})
.catch((err: unknown) => {
logger.error((err as Error).message, {
method: "getNewRepacksFromXatab",
});
});
});
await Promise.all(repacksFromPage);
const newRepacks = repacks.filter(
(repack) =>
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
if (page === totalPages) return;
return getNewRepacksFromXatab(existingRepacks, page + 1);
};

View File

@ -0,0 +1,36 @@
import flexSearch from "flexsearch";
import { repackRepository } from "@main/repository";
import { formatName } from "@shared";
import type { GameRepack } from "@types";
export class SearchEngine {
public static repacks: GameRepack[] = [];
private static repacksIndex = new flexSearch.Index();
public static searchRepacks(query: string): GameRepack[] {
return this.repacksIndex
.search(formatName(query))
.map((index) => this.repacks[index]);
}
public static async updateRepacks() {
this.repacks = [];
const repacks = await repackRepository.find({
order: {
createdAt: "desc",
},
});
for (let i = 0; i < repacks.length; i++) {
const repack = repacks[i];
const formattedTitle = formatName(repack.title);
this.repacks = [...this.repacks, { ...repack, title: formattedTitle }];
this.repacksIndex.add(i, formattedTitle);
}
}
}

View File

@ -1,33 +0,0 @@
import path from "node:path";
import { app } from "electron";
import { chunk } from "lodash-es";
import { createDataSource } from "@main/data-source";
import { Repack } from "@main/entity";
import { repackRepository } from "@main/repository";
export const resolveDatabaseUpdates = async () => {
const updateDataSource = createDataSource({
database: app.isPackaged
? path.join(process.resourcesPath, "hydra.db")
: path.join(__dirname, "..", "..", "hydra.db"),
});
return updateDataSource.initialize().then(async () => {
const updateRepackRepository = updateDataSource.getRepository(Repack);
const updateRepacks = await updateRepackRepository.find();
const updateRepacksChunks = chunk(updateRepacks, 800);
for (const chunk of updateRepacksChunks) {
await repackRepository
.createQueryBuilder()
.insert()
.values(chunk)
.orIgnore()
.execute();
}
});
};

View File

@ -1,4 +1,5 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-comlink/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string; readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;

13
src/main/workers/index.ts Normal file
View File

@ -0,0 +1,13 @@
import path from "node:path";
import steamGamesWorkerPath from "./steam-games.worker?modulePath";
import Piscina from "piscina";
import { seedsPath } from "@main/constants";
export const steamGamesWorker = new Piscina({
filename: steamGamesWorkerPath,
workerData: {
steamGamesPath: path.join(seedsPath, "steam-games.json"),
},
});

View File

@ -0,0 +1,38 @@
import { SteamGame } from "@types";
import { orderBy, slice } from "lodash-es";
import flexSearch from "flexsearch";
import fs from "node:fs";
import { formatName } from "@shared";
import { workerData } from "node:worker_threads";
const steamGamesIndex = new flexSearch.Index({
tokenize: "forward",
});
const { steamGamesPath } = workerData;
const data = fs.readFileSync(steamGamesPath, "utf-8");
const steamGames = JSON.parse(data) as SteamGame[];
for (let i = 0; i < steamGames.length; i++) {
const steamGame = steamGames[i];
const formattedName = formatName(steamGame.name);
steamGamesIndex.add(i, formattedName);
}
export const search = (options: flexSearch.SearchOptions) => {
const results = steamGamesIndex.search(options.query ?? "", options);
const games = results.map((index) => steamGames[index]);
return orderBy(games, ["name"], ["asc"]);
};
export const getById = (id: number) =>
steamGames.find((game) => game.id === id);
export const list = ({ limit, offset }: { limit: number; offset: number }) =>
slice(steamGames, offset, offset + limit);

View File

@ -1,14 +0,0 @@
import { parentPort } from "worker_threads";
import parseTorrent from "parse-torrent";
const port = parentPort;
if (!port) throw new Error("IllegalState");
port.on("message", async (buffer: Buffer) => {
try {
const torrent = await parseTorrent(buffer);
port.postMessage(torrent);
} catch (err) {
port.postMessage(null);
}
});

View File

@ -32,8 +32,7 @@ contextBridge.exposeInMainWorld("electron", {
/* Catalogue */ /* Catalogue */
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query), searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
getCatalogue: (category: CatalogueCategory) => getCatalogue: () => ipcRenderer.invoke("getCatalogue"),
ipcRenderer.invoke("getCatalogue", category),
getGameShopDetails: (objectID: string, shop: GameShop, language: string) => getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language), ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"), getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
@ -52,6 +51,15 @@ contextBridge.exposeInMainWorld("electron", {
authenticateRealDebrid: (apiToken: string) => authenticateRealDebrid: (apiToken: string) =>
ipcRenderer.invoke("authenticateRealDebrid", apiToken), ipcRenderer.invoke("authenticateRealDebrid", apiToken),
/* Download sources */
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
validateDownloadSource: (url: string) =>
ipcRenderer.invoke("validateDownloadSource", url),
addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url),
removeDownloadSource: (id: number) =>
ipcRenderer.invoke("removeDownloadSource", id),
/* Library */ /* Library */
addGameToLibrary: ( addGameToLibrary: (
objectID: string, objectID: string,

View File

@ -23,6 +23,7 @@ export const heroMedia = style({
width: "100%", width: "100%",
height: "100%", height: "100%",
transition: "all ease 0.2s", transition: "all ease 0.2s",
imageRendering: "revert",
selectors: { selectors: {
[`${hero}:hover &`]: { [`${hero}:hover &`]: {
transform: "scale(1.02)", transform: "scale(1.02)",

View File

@ -21,7 +21,7 @@ export const modal = recipe({
animationName: fadeIn, animationName: fadeIn,
animationDuration: "0.3s", animationDuration: "0.3s",
backgroundColor: vars.color.background, backgroundColor: vars.color.background,
borderRadius: "5px", borderRadius: "4px",
maxWidth: "600px", maxWidth: "600px",
color: vars.color.body, color: vars.color.body,
maxHeight: "100%", maxHeight: "100%",

View File

@ -42,7 +42,8 @@ export const textField = recipe({
}, },
}); });
export const textFieldInput = style({ export const textFieldInput = recipe({
base: {
backgroundColor: "transparent", backgroundColor: "transparent",
border: "none", border: "none",
width: "100%", width: "100%",
@ -56,4 +57,18 @@ export const textFieldInput = style({
":focus": { ":focus": {
cursor: "text", cursor: "text",
}, },
},
variants: {
readOnly: {
true: {
textOverflow: "inherit",
},
},
},
});
export const togglePasswordButton = style({
cursor: "pointer",
color: vars.color.muted,
padding: `${SPACING_UNIT}px`,
}); });

View File

@ -1,6 +1,8 @@
import { useId, useState } from "react"; import { useId, useMemo, useState } from "react";
import type { RecipeVariants } from "@vanilla-extract/recipes"; import type { RecipeVariants } from "@vanilla-extract/recipes";
import * as styles from "./text-field.css"; import * as styles from "./text-field.css";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
export interface TextFieldProps export interface TextFieldProps
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
@ -28,9 +30,20 @@ export function TextField({
containerProps, containerProps,
...props ...props
}: TextFieldProps) { }: TextFieldProps) {
const [isFocused, setIsFocused] = useState(false);
const id = useId(); const id = useId();
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const { t } = useTranslation("forms");
const showPasswordToggleButton = props.type === "password";
const inputType = useMemo(() => {
if (props.type === "password" && isPasswordVisible) return "text";
return props.type ?? "text";
}, [props.type, isPasswordVisible]);
return ( return (
<div className={styles.textFieldContainer} {...containerProps}> <div className={styles.textFieldContainer} {...containerProps}>
{label && <label htmlFor={id}>{label}</label>} {label && <label htmlFor={id}>{label}</label>}
@ -41,12 +54,27 @@ export function TextField({
> >
<input <input
id={id} id={id}
type="text" className={styles.textFieldInput({ readOnly: props.readOnly })}
className={styles.textFieldInput}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
{...props} {...props}
type={inputType}
/> />
{showPasswordToggleButton && (
<button
type="button"
className={styles.togglePasswordButton}
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
aria-label={t("toggle_password_visibility")}
>
{isPasswordVisible ? (
<EyeClosedIcon size={16} />
) : (
<EyeIcon size={16} />
)}
</button>
)}
</div> </div>
{hint && <small>{hint}</small>} {hint && <small>{hint}</small>}

View File

@ -12,6 +12,7 @@ import type {
UserPreferences, UserPreferences,
StartGameDownloadPayload, StartGameDownloadPayload,
RealDebridUser, RealDebridUser,
DownloadSource,
} from "@types"; } from "@types";
import type { DiskSpace } from "check-disk-space"; import type { DiskSpace } from "check-disk-space";
@ -33,7 +34,7 @@ declare global {
/* Catalogue */ /* Catalogue */
searchGames: (query: string) => Promise<CatalogueEntry[]>; searchGames: (query: string) => Promise<CatalogueEntry[]>;
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>; getCatalogue: () => Promise<CatalogueEntry[]>;
getGameShopDetails: ( getGameShopDetails: (
objectID: string, objectID: string,
shop: GameShop, shop: GameShop,
@ -58,7 +59,7 @@ declare global {
shop: GameShop, shop: GameShop,
executablePath: string | null executablePath: string | null
) => Promise<void>; ) => Promise<void>;
getLibrary: () => Promise<Game[]>; getLibrary: () => Promise<Omit<Game, "repacks">[]>;
openGameInstaller: (gameId: number) => Promise<boolean>; openGameInstaller: (gameId: number) => Promise<boolean>;
openGame: (gameId: number, executablePath: string) => Promise<void>; openGame: (gameId: number, executablePath: string) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>; closeGame: (gameId: number) => Promise<boolean>;
@ -77,6 +78,14 @@ declare global {
autoLaunch: (enabled: boolean) => Promise<void>; autoLaunch: (enabled: boolean) => Promise<void>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>; authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
/* Download sources */
getDownloadSources: () => Promise<DownloadSource[]>;
validateDownloadSource: (
url: string
) => Promise<{ name: string; downloadCount: number }>;
addDownloadSource: (url: string) => Promise<DownloadSource>;
removeDownloadSource: (id: number) => Promise<void>;
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>; getDiskFreeSpace: (path: string) => Promise<DiskSpace>;

View File

@ -40,3 +40,7 @@ export const buildGameDetailsPath = (
const searchParams = new URLSearchParams({ title: game.title, ...params }); const searchParams = new URLSearchParams({ title: game.title, ...params });
return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`; return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`;
}; };
export const numberFormatter = new Intl.NumberFormat("en-US", {
maximumSignificantDigits: 3,
});

View File

@ -99,12 +99,7 @@ export function Downloads() {
} }
if (game.progress === 1) { if (game.progress === 1) {
return ( return <p>{t("completed")}</p>;
<>
<p>{game.repack?.title}</p>
<p>{t("completed")}</p>
</>
);
} }
if (game.status === "paused") { if (game.status === "paused") {

View File

@ -21,6 +21,7 @@ export interface GameDetailsContext {
game: Game | null; game: Game | null;
shopDetails: ShopDetails | null; shopDetails: ShopDetails | null;
repacks: GameRepack[]; repacks: GameRepack[];
shop: GameShop;
gameTitle: string; gameTitle: string;
isGameRunning: boolean; isGameRunning: boolean;
isLoading: boolean; isLoading: boolean;
@ -35,6 +36,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
game: null, game: null,
shopDetails: null, shopDetails: null,
repacks: [], repacks: [],
shop: "steam",
gameTitle: "", gameTitle: "",
isGameRunning: false, isGameRunning: false,
isLoading: false, isLoading: false,
@ -92,7 +94,7 @@ export function GameDetailsContextProvider({
}, [updateGame, isGameDownloading, lastPacket?.game.status]); }, [updateGame, isGameDownloading, lastPacket?.game.status]);
useEffect(() => { useEffect(() => {
Promise.all([ Promise.allSettled([
window.electron.getGameShopDetails( window.electron.getGameShopDetails(
objectID!, objectID!,
shop as GameShop, shop as GameShop,
@ -100,9 +102,12 @@ export function GameDetailsContextProvider({
), ),
window.electron.searchGameRepacks(gameTitle), window.electron.searchGameRepacks(gameTitle),
]) ])
.then(([appDetails, repacks]) => { .then(([appDetailsResult, repacksResult]) => {
if (appDetails) setGameDetails(appDetails); if (appDetailsResult.status === "fulfilled")
setRepacks(repacks); setGameDetails(appDetailsResult.value);
if (repacksResult.status === "fulfilled")
setRepacks(repacksResult.value);
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
@ -174,6 +179,7 @@ export function GameDetailsContextProvider({
value={{ value={{
game, game,
shopDetails, shopDetails,
shop: shop as GameShop,
repacks, repacks,
gameTitle, gameTitle,
isGameRunning, isGameRunning,

View File

@ -46,7 +46,9 @@ export function DODIInstallationGuide({
flexDirection: "column", flexDirection: "column",
}} }}
> >
<p style={{ fontFamily: "Fira Sans", marginBottom: 8 }}> <p
style={{ fontFamily: "Fira Sans", marginBottom: `${SPACING_UNIT}px` }}
>
<Trans i18nKey="dodi_installation_instruction" ns="game_details"> <Trans i18nKey="dodi_installation_instruction" ns="game_details">
<ArrowUpIcon size={16} /> <ArrowUpIcon size={16} />
</Trans> </Trans>

View File

@ -6,6 +6,7 @@ export const contentSidebar = style({
borderLeft: `solid 1px ${vars.color.border};`, borderLeft: `solid 1px ${vars.color.border};`,
width: "100%", width: "100%",
height: "100%", height: "100%",
position: "relative",
"@media": { "@media": {
"(min-width: 768px)": { "(min-width: 768px)": {
width: "100%", width: "100%",
@ -86,6 +87,14 @@ export const howLongToBeatCategorySkeleton = style({
height: "76px", height: "76px",
}); });
export const technicalDetailsContainer = style({
padding: `0 ${SPACING_UNIT * 2}px`,
color: vars.color.body,
userSelect: "text",
position: "absolute",
bottom: `${SPACING_UNIT}px`,
});
globalStyle(`${requirementsDetails} a`, { globalStyle(`${requirementsDetails} a`, {
display: "flex", display: "flex",
color: vars.color.body, color: vars.color.body,

View File

@ -16,7 +16,8 @@ export function Sidebar() {
const [activeRequirement, setActiveRequirement] = const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum"); useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { gameTitle, shopDetails, objectID } = useContext(gameDetailsContext); const { gameTitle, shopDetails, shop, objectID } =
useContext(gameDetailsContext);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
@ -74,6 +75,15 @@ export function Sidebar() {
}), }),
}} }}
/> />
<div className={styles.technicalDetailsContainer}>
<p>
<small>shop: &quot;{shop}&quot;</small>
</p>
<p>
<small>objectID: &quot;{objectID}&quot;</small>
</p>
</div>
</aside> </aside>
); );
} }

View File

@ -1,15 +1,11 @@
import { style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css"; import { SPACING_UNIT, vars } from "../../theme.css";
export const homeCategories = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const homeHeader = style({ export const homeHeader = style({
display: "flex", display: "flex",
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center",
}); });
export const content = style({ export const content = style({

View File

@ -1,15 +1,11 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Button, GameCard, Hero } from "@renderer/components"; import { Button, GameCard, Hero } from "@renderer/components";
import { import type { Steam250Game, CatalogueEntry } from "@types";
Steam250Game,
type CatalogueCategory,
type CatalogueEntry,
} from "@types";
import starsAnimation from "@renderer/assets/lottie/stars.json"; import starsAnimation from "@renderer/assets/lottie/stars.json";
@ -18,8 +14,6 @@ import { vars } from "../../theme.css";
import Lottie from "lottie-react"; import Lottie from "lottie-react";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
const categories: CatalogueCategory[] = ["trending", "recently_added"];
export function Home() { export function Home() {
const { t } = useTranslation("home"); const { t } = useTranslation("home");
const navigate = useNavigate(); const navigate = useNavigate();
@ -27,22 +21,15 @@ export function Home() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null); const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [searchParams] = useSearchParams(); const [catalogue, setCatalogue] = useState<CatalogueEntry[]>([]);
const [catalogue, setCatalogue] = useState< const getCatalogue = useCallback(() => {
Record<CatalogueCategory, CatalogueEntry[]>
>({
trending: [],
recently_added: [],
});
const getCatalogue = useCallback((category: CatalogueCategory) => {
setIsLoading(true); setIsLoading(true);
window.electron window.electron
.getCatalogue(category) .getCatalogue()
.then((catalogue) => { .then((catalogue) => {
setCatalogue((prev) => ({ ...prev, [category]: catalogue })); setCatalogue(catalogue);
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
@ -50,15 +37,6 @@ export function Home() {
}); });
}, []); }, []);
const currentCategory = searchParams.get("category") || categories[0];
const handleSelectCategory = (category: CatalogueCategory) => {
if (category !== currentCategory) {
getCatalogue(category);
navigate(`/?category=${category}`);
}
};
const getRandomGame = useCallback(() => { const getRandomGame = useCallback(() => {
window.electron.getRandomGame().then((game) => { window.electron.getRandomGame().then((game) => {
if (game) setRandomGame(game); if (game) setRandomGame(game);
@ -80,9 +58,10 @@ export function Home() {
useEffect(() => { useEffect(() => {
setIsLoading(true); setIsLoading(true);
getCatalogue(currentCategory as CatalogueCategory); getCatalogue();
getRandomGame(); getRandomGame();
}, [getCatalogue, currentCategory, getRandomGame]); }, [getCatalogue, getRandomGame]);
return ( return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444"> <SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
@ -92,17 +71,7 @@ export function Home() {
<Hero /> <Hero />
<section className={styles.homeHeader}> <section className={styles.homeHeader}>
<div className={styles.homeCategories}> <h2>{t("trending")}</h2>
{categories.map((category) => (
<Button
key={category}
theme={currentCategory === category ? "primary" : "outline"}
onClick={() => handleSelectCategory(category)}
>
{t(category)}
</Button>
))}
</div>
<Button <Button
onClick={handleRandomizerClick} onClick={handleRandomizerClick}
@ -120,14 +89,12 @@ export function Home() {
</Button> </Button>
</section> </section>
<h2>{t(currentCategory)}</h2>
<section className={styles.cards}> <section className={styles.cards}>
{isLoading {isLoading
? Array.from({ length: 12 }).map((_, index) => ( ? Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} /> <Skeleton key={index} className={styles.cardSkeleton} />
)) ))
: catalogue[currentCategory as CatalogueCategory].map((result) => ( : catalogue.map((result) => (
<GameCard <GameCard
key={result.objectID} key={result.objectID}
game={result} game={result}

View File

@ -0,0 +1,112 @@
import { Button, Modal, TextField } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "./settings-download-sources.css";
import { numberFormatter } from "@renderer/helpers";
interface AddDownloadSourceModalProps {
visible: boolean;
onClose: () => void;
onAddDownloadSource: () => void;
}
export function AddDownloadSourceModal({
visible,
onClose,
onAddDownloadSource,
}: AddDownloadSourceModalProps) {
const [value, setValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [validationResult, setValidationResult] = useState<{
name: string;
downloadCount: number;
} | null>(null);
const { t } = useTranslation("settings");
const handleValidateDownloadSource = async () => {
setIsLoading(true);
try {
const result = await window.electron.validateDownloadSource(value);
setValidationResult(result);
console.log(result);
} finally {
setIsLoading(false);
}
};
const handleAddDownloadSource = async () => {
await window.electron.addDownloadSource(value);
onClose();
onAddDownloadSource();
};
return (
<Modal
visible={visible}
title={t("add_download_source")}
description={t("add_download_source_description")}
onClose={onClose}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
minWidth: "500px",
}}
>
<div className={styles.downloadSourceField}>
<TextField
label={t("download_source_url")}
placeholder="Insert a valid JSON url"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<Button
type="button"
style={{ alignSelf: "flex-end" }}
onClick={handleValidateDownloadSource}
disabled={isLoading || !value}
>
{t("validate_download_source")}
</Button>
</div>
{validationResult && (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: `${SPACING_UNIT * 3}px`,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
}}
>
<h4>{validationResult?.name}</h4>
<small>
Found {numberFormatter.format(validationResult?.downloadCount)}{" "}
download options
</small>
</div>
<Button type="button" onClick={handleAddDownloadSource}>
Import
</Button>
</div>
)}
</div>
</Modal>
);
}

View File

@ -0,0 +1,24 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const downloadSourceField = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const downloadSourceItem = style({
display: "flex",
flexDirection: "column",
backgroundColor: vars.color.darkBackground,
borderRadius: "8px",
padding: `${SPACING_UNIT * 2}px`,
gap: `${SPACING_UNIT}px`,
border: `solid 1px ${vars.color.border}`,
});
export const downloadSourceItemHeader = style({
marginBottom: `${SPACING_UNIT}px`,
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
});

View File

@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { TextField, Button } from "@renderer/components";
import { useTranslation } from "react-i18next";
import * as styles from "./settings-download-sources.css";
import type { DownloadSource } from "@types";
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useToast } from "@renderer/hooks";
import { numberFormatter } from "@renderer/helpers";
export function SettingsDownloadSources() {
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
useState(false);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const { t } = useTranslation("settings");
const { showSuccessToast } = useToast();
const getDownloadSources = async () => {
return window.electron.getDownloadSources().then((sources) => {
setDownloadSources(sources);
});
};
useEffect(() => {
getDownloadSources();
}, []);
const handleRemoveSource = async (id: number) => {
await window.electron.removeDownloadSource(id);
showSuccessToast("Removed download source");
getDownloadSources();
};
const handleAddDownloadSource = async () => {
await getDownloadSources();
showSuccessToast("Download source successfully added");
};
return (
<>
<AddDownloadSourceModal
visible={showAddDownloadSourceModal}
onClose={() => setShowAddDownloadSourceModal(false)}
onAddDownloadSource={handleAddDownloadSource}
/>
<p style={{ fontFamily: '"Fira Sans"' }}>
{t("download_sources_description")}
</p>
{downloadSources.map((downloadSource) => (
<div key={downloadSource.id} className={styles.downloadSourceItem}>
<div className={styles.downloadSourceItemHeader}>
<h3>{downloadSource.name}</h3>
<small>
{t("download_options", {
count: downloadSource.repackCount,
countFormatted: numberFormatter.format(
downloadSource.repackCount
),
})}
</small>
</div>
<div className={styles.downloadSourceField}>
<TextField
label={t("download_source_url")}
value={downloadSource.url}
readOnly
disabled
/>
<Button
type="button"
theme="outline"
style={{ alignSelf: "flex-end" }}
onClick={() => handleRemoveSource(downloadSource.id)}
>
<NoEntryIcon />
{t("remove_download_source")}
</Button>
</div>
</div>
))}
<Button
type="button"
theme="outline"
style={{ alignSelf: "flex-start" }}
onClick={() => setShowAddDownloadSourceModal(true)}
>
<PlusCircleIcon />
{t("add_download_source")}
</Button>
</>
);
}

View File

@ -9,13 +9,19 @@ import { SettingsGeneral } from "./settings-general";
import { SettingsBehavior } from "./settings-behavior"; import { SettingsBehavior } from "./settings-behavior";
import { useAppDispatch } from "@renderer/hooks"; import { useAppDispatch } from "@renderer/hooks";
import { setUserPreferences } from "@renderer/features"; import { setUserPreferences } from "@renderer/features";
import { SettingsDownloadSources } from "./settings-download-sources";
export function Settings() { export function Settings() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const categories = [t("general"), t("behavior"), "Real-Debrid"]; const categories = [
t("general"),
t("behavior"),
t("download_sources"),
"Real-Debrid",
];
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0); const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
@ -41,6 +47,10 @@ export function Settings() {
); );
} }
if (currentCategoryIndex === 2) {
return <SettingsDownloadSources />;
}
return ( return (
<SettingsRealDebrid updateUserPreferences={handleUpdateUserPreferences} /> <SettingsRealDebrid updateUserPreferences={handleUpdateUserPreferences} />
); );

View File

@ -6,10 +6,10 @@ interface BinaryNotFoundModalProps {
onClose: () => void; onClose: () => void;
} }
export const BinaryNotFoundModal = ({ export function BinaryNotFoundModal({
visible, visible,
onClose, onClose,
}: BinaryNotFoundModalProps) => { }: BinaryNotFoundModalProps) {
const { t } = useTranslation("binary_not_found_modal"); const { t } = useTranslation("binary_not_found_modal");
return ( return (
@ -22,4 +22,4 @@ export const BinaryNotFoundModal = ({
{t("instructions")} {t("instructions")}
</Modal> </Modal>
); );
}; }

View File

@ -18,3 +18,31 @@ export const formatBytes = (bytes: number): string => {
return `${Math.trunc(formatedByte * 10) / 10} ${FORMAT[base]}`; return `${Math.trunc(formatedByte * 10) / 10} ${FORMAT[base]}`;
}; };
export const pipe =
<T>(...fns: ((arg: T) => any)[]) =>
(arg: T) =>
fns.reduce((prev, fn) => fn(prev), arg);
export const removeReleaseYearFromName = (name: string) =>
name.replace(/\([0-9]{4}\)/g, "");
export const removeSymbolsFromName = (name: string) =>
name.replace(/[^A-Za-z 0-9]/g, "");
export const removeSpecialEditionFromName = (name: string) =>
name.replace(
/(The |Digital )?(GOTY|Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year|Reloaded|[0-9]{4}) Edition/g,
""
);
export const removeDuplicateSpaces = (name: string) =>
name.replace(/\s{2,}/g, " ");
export const formatName = pipe<string>(
removeReleaseYearFromName,
removeSymbolsFromName,
removeSpecialEditionFromName,
removeDuplicateSpaces,
(str) => str.trim()
);

View File

@ -3,7 +3,6 @@ import type { Downloader } from "@shared";
import { ProgressInfo, UpdateInfo } from "electron-updater"; import { ProgressInfo, UpdateInfo } from "electron-updater";
export type GameShop = "steam" | "epic"; export type GameShop = "steam" | "epic";
export type CatalogueCategory = "recently_added" | "trending";
export interface SteamGenre { export interface SteamGenre {
id: string; id: string;
@ -61,7 +60,6 @@ export interface GameRepack {
id: number; id: number;
title: string; title: string;
magnet: string; magnet: string;
page: number;
repacker: string; repacker: string;
fileSize: string | null; fileSize: string | null;
uploadDate: Date | string | null; uploadDate: Date | string | null;
@ -97,7 +95,6 @@ export interface Game extends Omit<CatalogueEntry, "cover"> {
folderName: string; folderName: string;
downloadPath: string | null; downloadPath: string | null;
repacks: GameRepack[]; repacks: GameRepack[];
repack: GameRepack | null;
progress: number; progress: number;
bytesDownloaded: number; bytesDownloaded: number;
playTimeInMilliseconds: number; playTimeInMilliseconds: number;
@ -233,3 +230,12 @@ export interface RealDebridUser {
export type AppUpdaterEvents = export type AppUpdaterEvents =
| { type: "update-available"; info: Partial<UpdateInfo> } | { type: "update-available"; info: Partial<UpdateInfo> }
| { type: "update-downloaded" }; | { type: "update-downloaded" };
export interface DownloadSource {
id: number;
name: string;
url: string;
repackCount: number;
createdAt: Date;
updatedAt: Date;
}

View File

@ -1309,10 +1309,10 @@
dependencies: dependencies:
undici-types "~5.26.4" undici-types "~5.26.4"
"@types/node@^18.11.18": "@types/node@^20.9.0":
version "18.19.33" version "20.13.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.33.tgz#98cd286a1b8a5e11aa06623210240bcc28e95c48" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.13.0.tgz#011a76bc1e71ae9a026dddcfd7039084f752c4b6"
integrity sha512-NR9+KrpSajr2qBVp/Yt5TU/rp+b5Mayi3+OlMlcg2cVCfRmcG5PWZ7S4+MG9PZ5gWBoc9Pd0BKSRViuBCRPu0A== integrity sha512-FM6AOb3khNkNIXPnHFDYaHerSv8uN22C91z098AnGccVu+Pcdhi+pNUFDi0iLmPIsVE0JBD0KVS7mzUYt4nRzQ==
dependencies: dependencies:
undici-types "~5.26.4" undici-types "~5.26.4"
@ -1382,6 +1382,11 @@
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
"@types/user-agents@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@types/user-agents/-/user-agents-1.0.4.tgz#49bf6760d9ca3858d91e6258835bf2c329b87cf0"
integrity sha512-AjeFc4oX5WPPflgKfRWWJfkEk7Wu82fnj1rROPsiqFt6yElpdGFg8Srtm/4PU4rA9UiDUZlruGPgcwTMQlwq4w==
"@types/verror@^1.10.3": "@types/verror@^1.10.3":
version "1.10.10" version "1.10.10"
resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.10.tgz#d5a4b56abac169bfbc8b23d291363a682e6fa087" resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.10.tgz#d5a4b56abac169bfbc8b23d291363a682e6fa087"
@ -2719,13 +2724,13 @@ electron-vite@^2.0.0:
magic-string "^0.30.5" magic-string "^0.30.5"
picocolors "^1.0.0" picocolors "^1.0.0"
electron@^28.2.0: electron@^30.0.9:
version "28.3.1" version "30.0.9"
resolved "https://registry.yarnpkg.com/electron/-/electron-28.3.1.tgz#babb3ff8e246336e9cd1c1966f16a55ba723ea06" resolved "https://registry.yarnpkg.com/electron/-/electron-30.0.9.tgz#b11400e4642a4b635e79244ba365f1d401ee60b5"
integrity sha512-aF9fONuhVDJlctJS7YOw76ynxVAQdfIWmlhRMKits24tDcdSL0eMHUS0wWYiRfGWbQnUKB6V49Rf17o32f4/fg== integrity sha512-ArxgdGHVu3o5uaP+Tqj8cJDvU03R6vrGrOqiMs7JXLnvQHMqXJIIxmFKQAIdJW8VoT3ac3hD21tA7cPO10RLow==
dependencies: dependencies:
"@electron/get" "^2.0.0" "@electron/get" "^2.0.0"
"@types/node" "^18.11.18" "@types/node" "^20.9.0"
extract-zip "^2.0.1" extract-zip "^2.0.1"
emoji-regex@^8.0.0: emoji-regex@^8.0.0:
@ -4567,6 +4572,14 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
nice-napi@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/nice-napi/-/nice-napi-1.0.2.tgz#dc0ab5a1eac20ce548802fc5686eaa6bc654927b"
integrity sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA==
dependencies:
node-addon-api "^3.0.0"
node-gyp-build "^4.2.2"
no-case@^3.0.4: no-case@^3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d"
@ -4587,6 +4600,11 @@ node-addon-api@^1.6.3:
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.2.tgz#3df30b95720b53c24e59948b49532b662444f54d" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.2.tgz#3df30b95720b53c24e59948b49532b662444f54d"
integrity sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg== integrity sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==
node-addon-api@^3.0.0:
version "3.2.1"
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161"
integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==
node-domexception@^1.0.0: node-domexception@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
@ -4608,6 +4626,11 @@ node-fetch@^3.3.0:
fetch-blob "^3.1.4" fetch-blob "^3.1.4"
formdata-polyfill "^4.0.10" formdata-polyfill "^4.0.10"
node-gyp-build@^4.2.2:
version "4.8.1"
resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.1.tgz#976d3ad905e71b76086f4f0b0d3637fe79b6cda5"
integrity sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==
node-releases@^2.0.14: node-releases@^2.0.14:
version "2.0.14" version "2.0.14"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b"
@ -4867,6 +4890,13 @@ picomatch@^2.3.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
piscina@^4.5.1:
version "4.5.1"
resolved "https://registry.yarnpkg.com/piscina/-/piscina-4.5.1.tgz#103f4237ce88c3f9e8b63c278673bcbddc10a9a2"
integrity sha512-DVhySLPfqAW+uRH9dF0bjA2xEWr5ANLAzkYXx5adSLMFnwssSIVJYhg0FlvgYsnT/khILQJ3WkjqbAlBvt+maw==
optionalDependencies:
nice-napi "^1.0.2"
pkg-types@^1.1.0: pkg-types@^1.1.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.1.1.tgz#07b626880749beb607b0c817af63aac1845a73f2" resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.1.1.tgz#07b626880749beb607b0c817af63aac1845a73f2"
@ -6269,3 +6299,8 @@ yocto-queue@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
zod@^3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==