Merge pull request #562 from hydralauncher/feature/add-download-sources

Feature/add download sources
This commit is contained in:
Chubby Granny Chaser 2024-06-08 20:36:10 +01:00 committed by GitHub
commit d2aef7ca98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 2889 additions and 2218 deletions

View file

@ -44,6 +44,7 @@ jobs:
name: Build-${{ matrix.os }}
path: |
dist/win-unpacked/**
dist/*-portable.exe
dist/*.zip
dist/*.dmg
dist/*.deb

View file

@ -5,7 +5,6 @@ directories:
extraResources:
- aria2
- seeds
- hydra.db
- fastlist.exe
files:
- "!**/.vscode/*"
@ -19,6 +18,9 @@ asarUnpack:
win:
executableName: Hydra
requestedExecutionLevel: requireAdministrator
target:
- nsis
- portable
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
@ -26,6 +28,9 @@ nsis:
createDesktopShortcut: always
oneClick: false
allowToChangeInstallationDirectory: true
portable:
artifactName: ${name}-${version}-portable.${ext}
requestExecutionLevel: admin
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:

View file

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

BIN
hydra.db

Binary file not shown.

View file

@ -40,7 +40,6 @@
"@reduxjs/toolkit": "^2.2.3",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/recipes": "^0.5.2",
"iso-639-1": "3.1.2",
"aria2": "^4.1.2",
"auto-launch": "^5.0.6",
"axios": "^1.6.8",
@ -49,6 +48,7 @@
"classnames": "^2.5.1",
"color": "^4.2.3",
"color.js": "^1.2.0",
"create-desktop-shortcuts": "^1.11.0",
"date-fns": "^3.6.0",
"electron-log": "^5.1.4",
"electron-updater": "^6.1.8",
@ -56,10 +56,13 @@
"flexsearch": "^0.7.43",
"i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1",
"icojs": "^0.19.3",
"iso-639-1": "3.1.2",
"jsdom": "^24.0.0",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.0",
"parse-torrent": "^11.0.16",
"piscina": "^4.5.1",
"ps-list": "^8.1.1",
"react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0",
@ -67,7 +70,8 @@
"react-router-dom": "^6.22.3",
"typeorm": "^0.3.20",
"user-agents": "^1.1.193",
"yaml": "^2.4.1"
"yaml": "^2.4.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@commitlint/cli": "^19.3.0",
@ -83,9 +87,10 @@
"@types/parse-torrent": "^5.8.7",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/user-agents": "^1.0.4",
"@vanilla-extract/vite-plugin": "^4.0.7",
"@vitejs/plugin-react": "^4.2.1",
"electron": "^28.2.0",
"electron": "^30.0.9",
"electron-builder": "^24.9.1",
"electron-vite": "^2.0.0",
"eslint": "^8.56.0",

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,6 @@
{
"home": {
"featured": "Featured",
"recently_added": "Recently added",
"trending": "Trending",
"surprise_me": "Surprise me",
"no_results": "No results found"
@ -15,12 +14,9 @@
"paused": "{{title}} (Paused)",
"downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filter library",
"follow_us": "Follow us",
"home": "Home",
"discord": "Join our Discord",
"telegram": "Join our Telegram",
"x": "Follow on X",
"github": "Contribute on GitHub"
"queued": "{{title}} (Queued)",
"game_has_no_executable": "Game has no executable selected"
},
"header": {
"search": "Search games",
@ -105,7 +101,25 @@
"screenshot": "Screenshot {{number}}",
"open_screenshot": "Open screenshot {{number}}",
"download_settings": "Download settings",
"downloader": "Downloader"
"downloader": "Downloader",
"select_executable": "Select",
"no_executable_selected": "No executable selected",
"open_folder": "Open folder",
"open_download_location": "See downloaded files",
"create_shortcut": "Create desktop shortcut",
"remove_files": "Remove files",
"remove_from_library_title": "Are you sure?",
"remove_from_library_description": "This will remove {{game}} from your library",
"options": "Options",
"executable_section_title": "Executable",
"executable_section_description": "Path of the file that will be executed when \"Play\" is clicked",
"downloads_secion_title": "Downloads",
"downloads_section_description": "Check out updates or other versions of this game",
"danger_zone_section_title": "Danger zone",
"danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra",
"download_in_progress": "Download in progress",
"download_paused": "Download paused",
"last_downloaded_option": "Last downloaded option"
},
"activation": {
"title": "Activate Hydra",
@ -135,7 +149,13 @@
"remove_from_list": "Remove",
"delete_modal_title": "Are you sure?",
"delete_modal_description": "This will remove all the installation files from your computer",
"install": "Install"
"install": "Install",
"download_in_progress": "In progress",
"queued_downloads": "Queued downloads",
"downloads_completed": "Completed",
"queued": "Queued",
"no_downloads_title": "Such empty",
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start."
},
"settings": {
"downloads_path": "Downloads path",
@ -150,6 +170,7 @@
"launch_with_system": "Launch Hydra on system start-up",
"general": "General",
"behavior": "Behavior",
"download_sources": "Download sources",
"language": "Language",
"real_debrid_api_token": "API Token",
"enable_real_debrid": "Enable Real-Debrid",
@ -159,7 +180,25 @@
"real_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to Real-Debrid",
"real_debrid_linked_message": "Account \"{{username}}\" linked",
"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_count_zero": "No downloads in list",
"download_count_one": "{{countFormatted}} download in list",
"download_count_other": "{{countFormatted}} downloads in list",
"download_options_zero": "No download available",
"download_options_one": "{{countFormatted}} download available",
"download_options_other": "{{countFormatted}} downloads available",
"download_source_url": "Download source URL",
"add_download_source_description": "Insert the URL containing the .json file",
"download_source_up_to_date": "Up-to-date",
"download_source_errored": "Errored",
"sync_download_sources": "Sync sources",
"removed_download_source": "Download source removed",
"added_download_source": "Added download source",
"download_sources_synced": "All download sources are synced"
},
"notifications": {
"download_complete": "Download complete",
@ -182,5 +221,8 @@
},
"modal": {
"close": "Close button"
},
"forms": {
"toggle_password_visibility": "Toggle password visibility"
}
}

View file

@ -1,7 +1,6 @@
{
"home": {
"featured": "Destaque",
"recently_added": "Recém adicionados",
"trending": "Populares",
"surprise_me": "Surpreenda-me",
"no_results": "Nenhum resultado encontrado"
@ -17,10 +16,8 @@
"filter": "Filtrar biblioteca",
"home": "Início",
"follow_us": "Acompanhe-nos",
"discord": "Entre no nosso Discord",
"telegram": "Entre no nosso Telegram",
"x": "Siga-nos no X",
"github": "Contribua no GitHub"
"queued": "{{title}} (Na fila)",
"game_has_no_executable": "Jogo não possui executável selecionado"
},
"header": {
"search": "Buscar jogos",
@ -101,7 +98,25 @@
"screenshot": "Captura de tela {{number}}",
"open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download",
"downloader": "Downloader"
"downloader": "Downloader",
"select_executable": "Selecionar",
"no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta",
"open_download_location": "Ver arquivos baixados",
"create_shortcut": "Criar atalho na área de trabalho",
"remove_files": "Remover arquivos",
"options": "Opções",
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
"remove_from_library_title": "Tem certeza?",
"executable_section_title": "Executável",
"executable_section_description": "O caminho do arquivo que será executado ao clicar em \"Jogar\"",
"downloads_secion_title": "Downloads",
"downloads_section_description": "Confira atualizações ou versões diferentes para este mesmo título",
"danger_zone_section_title": "Zona de perigo",
"danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra",
"download_in_progress": "Download em andamento",
"download_paused": "Download pausado",
"last_downloaded_option": "Última opção baixada"
},
"activation": {
"title": "Ativação",
@ -119,7 +134,7 @@
"verifying": "Verificando…",
"completed_at": "Concluído em {{date}}",
"completed": "Concluído",
"removed": "Não baixado",
"removed": "Cancelado",
"download_again": "Baixar novamente",
"cancel": "Cancelar",
"filter": "Filtrar jogos baixados",
@ -131,7 +146,13 @@
"delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador",
"delete_modal_title": "Tem certeza?",
"deleting": "Excluindo instalador…",
"install": "Instalar"
"install": "Instalar",
"download_in_progress": "Baixando agora",
"queued_downloads": "Na fila",
"downloads_completed": "Completo",
"queued": "Na fila",
"no_downloads_title": "Nada por aqui…",
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar."
},
"settings": {
"downloads_path": "Diretório dos downloads",
@ -146,6 +167,7 @@
"launch_with_system": "Iniciar o Hydra junto com o sistema",
"general": "Geral",
"behavior": "Comportamento",
"download_sources": "Fontes de download",
"language": "Idioma",
"real_debrid_api_token": "Token de API",
"enable_real_debrid": "Habilitar Real-Debrid",
@ -155,7 +177,25 @@
"real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine a Real-Debrid",
"real_debrid_linked_message": "Conta \"{{username}}\" vinculada",
"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 fonte habilitadas. A URL da fonte 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 fonte",
"download_count_zero": "Sem downloads na lista",
"download_count_one": "{{countFormatted}} download na lista",
"download_count_other": "{{countFormatted}} downloads na lista",
"download_options_zero": "Sem downloads disponíveis",
"download_options_one": "{{countFormatted}} download disponível",
"download_options_other": "{{countFormatted}} downloads disponíveis",
"download_source_url": "URL da fonte",
"add_download_source_description": "Insira a URL contendo o arquivo .json",
"download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou",
"sync_download_sources": "Sincronizar",
"removed_download_source": "Fonte removida",
"added_download_source": "Fonte adicionada",
"download_sources_synced": "As fontes foram sincronizadas"
},
"notifications": {
"download_complete": "Download concluído",
@ -182,5 +222,8 @@
},
"modal": {
"close": "Botão de fechar"
},
"forms": {
"toggle_password_visibility": "Alternar visibilidade da senha"
}
}

View file

@ -1,23 +1,6 @@
import { app } from "electron";
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 databasePath = path.join(

View file

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

View file

@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from "typeorm";
import type { Game } from "./game.entity";
@Entity("download_queue")
export class DownloadQueue {
@PrimaryGeneratedColumn()
id: number;
@OneToOne("Game", "downloadQueue")
@JoinColumn()
game: Game;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View file

@ -0,0 +1,41 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from "typeorm";
import type { Repack } from "./repack.entity";
import { DownloadSourceStatus } from "@shared";
@Entity("download_source")
export class DownloadSource {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { nullable: true, unique: true })
url: string;
@Column("text")
name: string;
@Column("text", { nullable: true })
etag: string | null;
@Column("int", { default: 0 })
downloadCount: number;
@Column("text", { default: DownloadSourceStatus.UpToDate })
status: DownloadSourceStatus;
@OneToMany("Repack", "downloadSource", { cascade: true })
repacks: Repack[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View file

@ -12,6 +12,7 @@ import { Repack } from "./repack.entity";
import type { GameShop } from "@types";
import { Downloader } from "@shared";
import type { Aria2Status } from "aria2";
import type { DownloadQueue } from "./download-queue.entity";
@Entity("game")
export class Game {
@ -63,10 +64,19 @@ export class Game {
@Column("float", { default: 0 })
fileSize: number;
@OneToOne(() => Repack, { nullable: true })
@Column("text", { nullable: true })
uri: string | null;
/**
* @deprecated
*/
@OneToOne("Repack", "game", { nullable: true })
@JoinColumn()
repack: Repack;
@OneToOne("DownloadQueue", "game")
downloadQueue: DownloadQueue;
@Column("boolean", { default: false })
isDeleted: boolean;

View file

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

View file

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

View file

@ -1,95 +1,36 @@
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
import type { CatalogueCategory, CatalogueEntry, GameShop } from "@types";
import { getSteamAppAsset } from "@main/helpers";
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 { 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));
};
import { RepacksManager, requestSteam250 } from "@main/services";
import { formatName } from "@shared";
const resultSize = 12;
const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent,
category: CatalogueCategory
) => {
if (!repacks.length) return [];
if (category === "trending") {
return getTrendingCatalogue(resultSize);
}
return getRecentlyAddedCatalogue(resultSize);
};
const getTrendingCatalogue = async (
resultSize: number
): Promise<CatalogueEntry[]> => {
const results: CatalogueEntry[] = [];
const getCatalogue = async (_event: Electron.IpcMainInvokeEvent) => {
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 repacks = searchRepacks(title);
if (title && repacks.length) {
const catalogueEntry = {
objectID,
title,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", objectID),
};
results.push({ ...catalogueEntry, repacks });
}
}
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) {
for (let i = 0; i < resultSize; i++) {
if (!trendingGames[i]) {
i++;
continue;
}
const games = searchGames({ query: stringForLookup });
const { title, objectID } = trendingGames[i]!;
const repacks = RepacksManager.search({ query: formatName(title) });
for (const game of games) {
const isAlreadyIncluded = results.some(
(result) => result.objectID === game?.objectID
);
const catalogueEntry = {
objectID,
title,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", objectID),
};
if (!game || !game.repacks.length || isAlreadyIncluded) {
continue;
}
results.push(game);
}
results.push({ ...catalogueEntry, repacks });
}
return results.slice(0, resultSize);
return results;
};
registerEvent("getCatalogue", getCatalogue);

View file

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

View file

@ -1,39 +1,28 @@
import type { CatalogueEntry, GameShop } from "@types";
import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event";
import { searchRepacks } from "../helpers/search-games";
import { stateManager } from "@main/state-manager";
import { getSteamAppAsset } from "@main/helpers";
const steamGames = stateManager.getValue("steamGames");
import { steamGamesWorker } from "@main/workers";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { RepacksManager } from "@main/services";
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
take = 12,
cursor = 0
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
const results: CatalogueEntry[] = [];
const steamGames = await steamGamesWorker.run(
{ limit: take, offset: cursor },
{ name: "list" }
);
let i = 0 + cursor;
const entries = RepacksManager.findRepacksForCatalogueEntries(
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
);
while (results.length < take) {
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 };
return {
results: entries,
cursor: cursor + entries.length,
};
};
registerEvent("getGames", getGames);

View file

@ -3,21 +3,34 @@ import { shuffle } from "lodash-es";
import { getSteam250List } from "@main/services";
import { registerEvent } from "../register-event";
import { searchGames, searchRepacks } from "../helpers/search-games";
import { searchSteamGames } from "../helpers/search-games";
import type { Steam250Game } from "@types";
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 [steamGame] = catalogue;
if (steamGame.repacks.length) {
results.push(game);
}
}
}
return results;
};
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
if (state.games.length == 0) {
const steam250List = await getSteam250List();
const filteredSteam250List = steam250List.filter((game) => {
const repacks = searchRepacks(game.title);
const catalogue = searchGames({ query: game.title });
return repacks.length && catalogue.length;
});
const filteredSteam250List = await filterGames(steam250List);
state.games = shuffle(filteredSteam250List);
}

View file

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

View file

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

View file

@ -0,0 +1,42 @@
import { registerEvent } from "../register-event";
import { dataSource } from "@main/data-source";
import { DownloadSource } from "@main/entity";
import axios from "axios";
import { downloadSourceSchema } from "../helpers/validators";
import { insertDownloadsFromSource } from "@main/helpers";
import { RepacksManager } 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,
downloadCount: source.downloads.length,
});
await insertDownloadsFromSource(
transactionalEntityManager,
downloadSource,
source.downloads
);
return downloadSource;
}
);
await RepacksManager.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 { RepacksManager } from "@main/services";
const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => {
await downloadSourceRepository.delete(id);
await RepacksManager.updateRepacks();
};
registerEvent("removeDownloadSource", removeDownloadSource);

View file

@ -0,0 +1,7 @@
import { registerEvent } from "../register-event";
import { fetchDownloadSourcesAndUpdate } from "@main/helpers";
const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
fetchDownloadSourcesAndUpdate();
registerEvent("syncDownloadSources", syncDownloadSources);

View file

@ -0,0 +1,34 @@
import { registerEvent } from "../register-event";
import axios from "axios";
import { downloadSourceRepository } from "@main/repository";
import { downloadSourceSchema } from "../helpers/validators";
import { RepacksManager } from "@main/services";
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 },
});
if (existingSource)
throw new Error("Source with the same url already exists");
const repacks = RepacksManager.repacks;
const existingUris = source.downloads
.flatMap((download) => download.uris)
.filter((uri) => repacks.some((repack) => repack.magnet === uri));
return {
name: source.name,
downloadCount: source.downloads.length - existingUris.length,
};
};
registerEvent("validateDownloadSource", validateDownloadSource);

View file

@ -1,40 +1,11 @@
import flexSearch from "flexsearch";
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 { stateManager } from "@main/state-manager";
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"
);
};
import { getSteamAppAsset } from "@main/helpers";
import { steamGamesWorker } from "@main/workers";
import { RepacksManager } from "@main/services";
export interface SearchGamesArgs {
query?: string;
@ -42,27 +13,29 @@ export interface SearchGamesArgs {
skip?: number;
}
export const searchGames = ({
query,
take,
skip,
}: SearchGamesArgs): CatalogueEntry[] => {
const results = steamGamesIndex
.search(formatName(query || ""), { limit: take, offset: skip })
.map((index) => {
const result = steamGames.at(index as number)!;
export const convertSteamGameToCatalogueEntry = (
game: SteamGame
): CatalogueEntry => ({
objectID: String(game.id),
title: game.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(game.id)),
repacks: [],
});
return {
objectID: String(result.id),
title: result.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(result.id)),
repacks: searchRepacks(result.name),
};
});
export const searchSteamGames = async (
options: flexSearch.SearchOptions
): Promise<CatalogueEntry[]> => {
const steamGames = (await steamGamesWorker.run(options, {
name: "search",
})) as SteamGame[];
const result = RepacksManager.findRepacksForCatalogueEntries(
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
);
return orderBy(
results,
result,
[({ repacks }) => repacks.length, "repacks"],
["desc"]
);

View file

@ -0,0 +1,14 @@
import { z } from "zod";
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
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

@ -10,12 +10,16 @@ import "./catalogue/search-games";
import "./catalogue/search-game-repacks";
import "./hardware/get-disk-free-space";
import "./library/add-game-to-library";
import "./library/create-game-shortcut";
import "./library/close-game";
import "./library/delete-game-folder";
import "./library/get-game-by-object-id";
import "./library/get-library";
import "./library/open-game";
import "./library/open-game-executable-path";
import "./library/open-game-installer";
import "./library/open-game-installer-path";
import "./library/update-executable-path";
import "./library/remove-game";
import "./library/remove-game-from-library";
import "./misc/open-external";
@ -30,6 +34,11 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
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";
import "./download-sources/sync-download-sources";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());

View file

@ -4,14 +4,14 @@ import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { stateManager } from "@main/state-manager";
import { steamGamesWorker } from "@main/workers";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
title: string,
shop: GameShop,
executablePath: string | null
shop: GameShop
) => {
return gameRepository
.update(
@ -21,15 +21,14 @@ const addGameToLibrary = async (
{
shop,
status: null,
executablePath,
isDeleted: false,
}
)
.then(async ({ affected }) => {
if (!affected) {
const steamGame = stateManager
.getValue("steamGames")
.find((game) => game.id === Number(objectID));
const steamGame = await steamGamesWorker.run(Number(objectID), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
@ -41,7 +40,6 @@ const addGameToLibrary = async (
iconUrl,
objectID,
shop,
executablePath,
})
.then(() => {
if (iconUrl) {

View file

@ -0,0 +1,29 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { IsNull, Not } from "typeorm";
import createDesktopShortcut from "create-desktop-shortcuts";
const createGameShortcut = async (
_event: Electron.IpcMainInvokeEvent,
id: number
): Promise<boolean> => {
const game = await gameRepository.findOne({
where: { id, executablePath: Not(IsNull()) },
});
if (game) {
const filePath = game.executablePath;
const options = { filePath, name: game.title };
return createDesktopShortcut({
windows: options,
linux: options,
osx: options,
});
}
return false;
};
registerEvent("createGameShortcut", createGameShortcut);

View file

@ -1,8 +1,6 @@
import path from "node:path";
import fs from "node:fs";
import { In } from "typeorm";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
@ -14,11 +12,18 @@ const deleteGameFolder = async (
gameId: number
): Promise<void> => {
const game = await gameRepository.findOne({
where: {
id: gameId,
status: In(["removed", "complete"]),
isDeleted: false,
},
where: [
{
id: gameId,
isDeleted: false,
status: "removed",
},
{
id: gameId,
progress: 1,
isDeleted: false,
},
],
});
if (!game) return;
@ -30,7 +35,7 @@ const deleteGameFolder = async (
);
if (fs.existsSync(folderPath)) {
return new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
fs.rm(
folderPath,
{ recursive: true, force: true, maxRetries: 5, retryDelay: 200 },
@ -40,12 +45,21 @@ const deleteGameFolder = async (
reject();
}
const aria2ControlFilePath = `${folderPath}.aria2`;
if (fs.existsSync(aria2ControlFilePath))
fs.rmSync(aria2ControlFilePath);
resolve();
}
);
});
}
}
await gameRepository.update(
{ id: gameId },
{ downloadPath: null, folderName: null, status: null, progress: 0 }
);
};
registerEvent("deleteGameFolder", deleteGameFolder);

View file

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

View file

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

View file

@ -0,0 +1,18 @@
import { shell } from "electron";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const openGameExecutablePath = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!game || !game.executablePath) return;
shell.showItemInFolder(game.executablePath);
};
registerEvent("openGameExecutablePath", openGameExecutablePath);

View file

@ -0,0 +1,27 @@
import { shell } from "electron";
import path from "node:path";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { registerEvent } from "../register-event";
const openGameInstallerPath = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!game || !game.folderName || !game.downloadPath) return true;
const gamePath = path.join(
game.downloadPath ?? (await getDownloadsPath()),
game.folderName!
);
shell.showItemInFolder(gamePath);
return true;
};
registerEvent("openGameInstallerPath", openGameInstallerPath);

View file

@ -5,7 +5,10 @@ const removeGameFromLibrary = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
gameRepository.update({ id: gameId }, { isDeleted: true });
gameRepository.update(
{ id: gameId },
{ isDeleted: true, executablePath: null }
);
};
registerEvent("removeGameFromLibrary", removeGameFromLibrary);

View file

@ -0,0 +1,20 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const updateExecutablePath = async (
_event: Electron.IpcMainInvokeEvent,
id: number,
executablePath: string
) => {
return gameRepository.update(
{
id,
},
{
executablePath,
}
);
};
registerEvent("updateExecutablePath", updateExecutablePath);

View file

@ -1,25 +1,31 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
await DownloadManager.cancelDownload(gameId);
await dataSource.transaction(async (transactionalEntityManager) => {
await DownloadManager.cancelDownload(gameId);
await gameRepository.update(
{
id: gameId,
},
{
status: "removed",
bytesDownloaded: 0,
progress: 0,
}
);
await transactionalEntityManager.getRepository(DownloadQueue).delete({
game: { id: gameId },
});
await transactionalEntityManager.getRepository(Game).update(
{
id: gameId,
},
{
status: "removed",
bytesDownloaded: 0,
progress: 0,
}
);
});
};
registerEvent("cancelGameDownload", cancelGameDownload);

View file

@ -1,13 +1,24 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
await DownloadManager.pauseDownload();
await gameRepository.update({ id: gameId }, { status: "paused" });
await dataSource.transaction(async (transactionalEntityManager) => {
await DownloadManager.pauseDownload();
await transactionalEntityManager.getRepository(DownloadQueue).delete({
game: { id: gameId },
});
await transactionalEntityManager
.getRepository(Game)
.update({ id: gameId }, { status: "paused" });
});
};
registerEvent("pauseGameDownload", pauseGameDownload);

View file

@ -5,7 +5,7 @@ import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { Game } from "@main/entity";
import { DownloadQueue, Game } from "@main/entity";
const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -16,7 +16,6 @@ const resumeGameDownload = async (
id: gameId,
isDeleted: false,
},
relations: { repack: true },
});
if (!game) return;
@ -31,6 +30,14 @@ const resumeGameDownload = async (
await DownloadManager.resumeDownload(game);
await transactionalEntityManager
.getRepository(DownloadQueue)
.delete({ game: { id: gameId } });
await transactionalEntityManager
.getRepository(DownloadQueue)
.insert({ game: { id: gameId } });
await transactionalEntityManager
.getRepository(Game)
.update({ id: gameId }, { status: "active" });

View file

@ -1,12 +1,17 @@
import { gameRepository, repackRepository } from "@main/repository";
import {
downloadQueueRepository,
gameRepository,
repackRepository,
} from "@main/repository";
import { registerEvent } from "../register-event";
import type { StartGameDownloadPayload } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { DownloadManager } from "@main/services";
import { stateManager } from "@main/state-manager";
import { Not } from "typeorm";
import { steamGamesWorker } from "@main/workers";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -20,7 +25,6 @@ const startGameDownload = async (
objectID,
shop,
},
relations: { repack: true },
}),
repackRepository.findOne({
where: {
@ -49,14 +53,14 @@ const startGameDownload = async (
bytesDownloaded: 0,
downloadPath,
downloader,
repack: { id: repackId },
uri: repack.magnet,
isDeleted: false,
}
);
} else {
const steamGame = stateManager
.getValue("steamGames")
.find((game) => game.id === Number(objectID));
const steamGame = await steamGamesWorker.run(Number(objectID), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
@ -71,7 +75,7 @@ const startGameDownload = async (
shop,
status: "active",
downloadPath,
repack: { id: repackId },
uri: repack.magnet,
})
.then((result) => {
if (iconUrl) {
@ -88,9 +92,11 @@ const startGameDownload = async (
where: {
objectID,
},
relations: { repack: true },
});
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
await DownloadManager.startDownload(updatedGame!);
};

View file

@ -0,0 +1,75 @@
import { dataSource } from "@main/data-source";
import { DownloadSource, Repack } from "@main/entity";
import { downloadSourceSchema } from "@main/events/helpers/validators";
import { downloadSourceRepository } from "@main/repository";
import { RepacksManager } from "@main/services";
import { downloadSourceWorker } from "@main/workers";
import { chunk } from "lodash-es";
import type { EntityManager } from "typeorm";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { z } from "zod";
export const insertDownloadsFromSource = async (
trx: EntityManager,
downloadSource: DownloadSource,
downloads: z.infer<typeof downloadSourceSchema>["downloads"]
) => {
const repacks: QueryDeepPartialEntity<Repack>[] = downloads.map(
(download) => ({
title: download.title,
magnet: download.uris[0],
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSource: { id: downloadSource.id },
})
);
const downloadsChunks = chunk(repacks, 800);
for (const chunk of downloadsChunks) {
await trx
.getRepository(Repack)
.createQueryBuilder()
.insert()
.values(chunk)
.updateEntity(false)
.orIgnore()
.execute();
}
};
export const fetchDownloadSourcesAndUpdate = async () => {
const downloadSources = await downloadSourceRepository.find({
order: {
id: "desc",
},
});
const results = await downloadSourceWorker.run(downloadSources, {
name: "getUpdatedRepacks",
});
await dataSource.transaction(async (transactionalEntityManager) => {
for (const result of results) {
if (result.etag !== null) {
await transactionalEntityManager.getRepository(DownloadSource).update(
{ id: result.id },
{
etag: result.etag,
status: result.status,
downloadCount: result.downloads.length,
}
);
await insertDownloadsFromSource(
transactionalEntityManager,
result,
result.downloads
);
}
}
await RepacksManager.updateRepacks();
});
};

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 {
removeReleaseYearFromName,
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,
};
import axios from "axios";
import UserAgent from "user-agents";
export const getSteamAppAsset = (
category: "library" | "hero" | "logo" | "icon",
@ -88,5 +45,17 @@ export const steamUrlBuilder = {
export const sleep = (ms: number) =>
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 "./download-source";

View file

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

View file

@ -1,103 +1,48 @@
import { stateManager } from "./state-manager";
import { repackersOn1337x, seedsPath } from "./constants";
import { DownloadManager, RepacksManager, startMainLoop } from "./services";
import {
getNewGOGGames,
getNewRepacksFromUser,
getNewRepacksFromXatab,
getNewRepacksFromOnlineFix,
DownloadManager,
startMainLoop,
} from "./services";
import {
gameRepository,
downloadQueueRepository,
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 { UserPreferences } from "./entity";
import { RealDebridClient } from "./services/real-debrid";
import { orderBy } from "lodash-es";
import { SteamGame } from "@types";
import { Not } from "typeorm";
import { fetchDownloadSourcesAndUpdate } from "./helpers";
import { publishNewRepacksNotifications } from "./services/notifications";
import { MoreThan } from "typeorm";
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 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"));
await RepacksManager.updateRepacks();
import("./events");
if (userPreferences?.realDebridApiToken)
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
const game = await gameRepository.findOne({
where: {
status: "active",
progress: Not(1),
isDeleted: false,
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
relations: { repack: true },
});
if (game) DownloadManager.startDownload(game);
if (nextQueueItem?.game.status === "active")
DownloadManager.startDownload(nextQueueItem.game);
const now = new Date();
fetchDownloadSourcesAndUpdate().then(async () => {
const newRepacksCount = await repackRepository.count({
where: {
createdAt: MoreThan(now),
},
});
if (newRepacksCount > 0) publishNewRepacksNotifications(newRepacksCount);
});
};
userPreferencesRepository
@ -105,5 +50,5 @@ userPreferencesRepository
where: { id: 1 },
})
.then((userPreferences) => {
loadState(userPreferences).then(() => checkForNewRepacks(userPreferences));
loadState(userPreferences);
});

View file

@ -1,75 +1,8 @@
import { createDataSource } from "@main/data-source";
import { Repack } from "@main/entity";
import { app } from "electron";
import { chunk } from "lodash-es";
import path from "path";
import { In, MigrationInterface, QueryRunner, Table } from "typeorm";
import { MigrationInterface, QueryRunner } from "typeorm";
export class FixRepackUploadDate1715900413313 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: "repack_temp",
columns: [
{ name: "title", type: "varchar" },
{ name: "old_id", type: "int" },
],
}),
true
);
await queryRunner.query(
`INSERT INTO repack_temp (title, old_id) SELECT title, id FROM repack WHERE repacker IN ('onlinefix', 'Xatab');`
);
await queryRunner.query(
`DELETE FROM repack WHERE repacker IN ('onlinefix', 'Xatab');`
);
const updateDataSource = createDataSource({
database: app.isPackaged
? path.join(process.resourcesPath, "hydra.db")
: path.join(__dirname, "..", "..", "hydra.db"),
});
await updateDataSource.initialize();
const updateRepackRepository = updateDataSource.getRepository(Repack);
const updatedRepacks = await updateRepackRepository.find({
where: {
repacker: In(["onlinefix", "Xatab"]),
},
});
const chunks = chunk(
updatedRepacks.map((repack) => {
const { id: _, ...rest } = repack;
return rest;
}),
500
);
for (const chunk of chunks) {
await queryRunner.manager
.createQueryBuilder(Repack, "repack")
.insert()
.values(chunk)
.orIgnore()
.execute();
}
await queryRunner.query(
`UPDATE game
SET repackId = (
SELECT id
from repack LEFT JOIN repack_temp ON repack_temp.title = repack.title
WHERE repack_temp.old_id = game.repackId
)
WHERE EXISTS (select old_id from repack_temp WHERE old_id = game.repackId)`
);
await queryRunner.dropTable("repack_temp");
public async up(_: QueryRunner): Promise<void> {
return;
}
public async down(_: QueryRunner): Promise<void> {

View file

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

View file

@ -1,11 +1,12 @@
import Aria2, { StatusResponse } from "aria2";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import path from "node:path";
import { downloadQueueRepository, gameRepository } from "@main/repository";
import { WindowManager } from "./window-manager";
import { RealDebridClient } from "./real-debrid";
import { Notification } from "electron";
import { t } from "i18next";
import { Downloader } from "@shared";
import { DownloadProgress } from "@types";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
@ -14,6 +15,7 @@ import { startAria2 } from "./aria2c";
import { sleep } from "@main/helpers";
import { logger } from "./logger";
import type { ChildProcess } from "node:child_process";
import { publishDownloadCompleteNotification } from "./notifications";
export class DownloadManager {
private static downloads = new Map<number, string>();
@ -65,29 +67,13 @@ export class DownloadManager {
return -1;
}
static async publishNotification() {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.downloadNotificationsEnabled && this.game) {
new Notification({
title: t("download_complete", {
ns: "notifications",
lng: userPreferences.language,
}),
body: t("game_ready_to_install", {
ns: "notifications",
lng: userPreferences.language,
title: this.game.title,
}),
}).show();
}
}
private static getFolderName(status: StatusResponse) {
if (status.bittorrent?.info) return status.bittorrent.info.name;
return "";
const [file] = status.files;
if (file) return path.win32.basename(file.path);
return null;
}
private static async getRealDebridDownloadUrl() {
@ -192,22 +178,8 @@ export class DownloadManager {
const game = await gameRepository.findOne({
where: { id: this.game.id, isDeleted: false },
relations: { repack: true },
});
if (progress === 1 && this.game && !isDownloadingMetadata) {
await this.publishNotification();
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
}
if (WindowManager.mainWindow && game) {
if (!isNaN(progress))
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
@ -230,6 +202,34 @@ export class DownloadManager {
JSON.parse(JSON.stringify(payload))
);
}
if (progress === 1 && this.game && !isDownloadingMetadata) {
publishDownloadCompleteNotification(this.game);
await downloadQueueRepository.delete({ game: this.game });
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
if (nextQueueItem) {
this.resumeDownload(nextQueueItem.game);
}
}
}
private static clearCurrentDownload() {
@ -245,7 +245,7 @@ export class DownloadManager {
const gid = this.downloads.get(gameId);
if (gid) {
await this.aria2.call("remove", gid);
await this.aria2.call("forceRemove", gid);
if (this.gid === gid) {
this.clearCurrentDownload();
@ -291,10 +291,10 @@ export class DownloadManager {
if (game.downloader === Downloader.RealDebrid) {
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
game!.repack.magnet
game!.uri!
);
} 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);
}

View file

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

View file

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

View file

@ -0,0 +1,72 @@
import { Notification, nativeImage } from "electron";
import { t } from "i18next";
import { parseICO } from "icojs";
import { Game } from "@main/entity";
import { gameRepository, userPreferencesRepository } from "@main/repository";
const getGameIconNativeImage = async (gameId: number) => {
try {
const game = await gameRepository.findOne({
where: {
id: gameId,
},
});
if (!game?.iconUrl) return undefined;
const images = await parseICO(
Buffer.from(game.iconUrl.split("base64,")[1], "base64")
);
const highResIcon = images.find((image) => image.width >= 128);
if (!highResIcon) return undefined;
return nativeImage.createFromBuffer(Buffer.from(highResIcon.buffer));
} catch (err) {
return undefined;
}
};
export const publishDownloadCompleteNotification = async (game: Game) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const icon = await getGameIconNativeImage(game.id);
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({
title: t("download_complete", {
ns: "notifications",
lng: userPreferences.language,
}),
body: t("game_ready_to_install", {
ns: "notifications",
lng: userPreferences.language,
title: game.title,
}),
icon,
}).show();
}
};
export const publishNewRepacksNotifications = async (count: number) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (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: count,
}),
}).show();
}
};

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,44 @@
import { repackRepository } from "@main/repository";
import { formatName } from "@shared";
import { CatalogueEntry, GameRepack } from "@types";
import flexSearch from "flexsearch";
export class RepacksManager {
public static repacks: GameRepack[] = [];
private static repacksIndex = new flexSearch.Index();
public static async updateRepacks() {
this.repacks = await repackRepository.find({
order: {
createdAt: "DESC",
},
});
for (let i = 0; i < this.repacks.length; i++) {
this.repacksIndex.remove(i);
}
this.repacksIndex = new flexSearch.Index();
for (let i = 0; i < this.repacks.length; i++) {
const repack = this.repacks[i];
const formattedTitle = formatName(repack.title);
this.repacksIndex.add(i, formattedTitle);
}
}
public static search(options: flexSearch.SearchOptions) {
return this.repacksIndex
.search({ ...options, query: formatName(options.query ?? "") })
.map((index) => this.repacks[index]);
}
public static findRepacksForCatalogueEntries(entries: CatalogueEntry[]) {
return entries.map((entry) => {
const repacks = this.search({ query: formatName(entry.title) });
return { ...entry, repacks };
});
}
}

View file

@ -30,6 +30,6 @@ export const getSteamAppDetails = async (
})
.catch((err) => {
logger.error(err, { method: "getSteamAppDetails" });
throw new Error(err);
return null;
});
};

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,30 +0,0 @@
import type { Repack } from "@main/entity";
import type { SteamGame } from "@types";
interface State {
repacks: Repack[];
steamGames: SteamGame[];
}
const initialState: State = {
repacks: [],
steamGames: [],
};
export class StateManager {
private state = initialState;
public setValue<T extends keyof State>(key: T, value: State[T]) {
this.state = { ...this.state, [key]: value };
}
public getValue<T extends keyof State>(key: T) {
return this.state[key];
}
public clearValue<T extends keyof State>(key: T) {
this.state = { ...this.state, [key]: initialState[key] };
}
}
export const stateManager = new StateManager();

View file

@ -0,0 +1,50 @@
import { downloadSourceSchema } from "@main/events/helpers/validators";
import { DownloadSourceStatus } from "@shared";
import type { DownloadSource } from "@types";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { z } from "zod";
export type DownloadSourceResponse = z.infer<typeof downloadSourceSchema> & {
etag: string | null;
status: DownloadSourceStatus;
};
export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
const results: DownloadSourceResponse[] = [];
for (const downloadSource of downloadSources) {
const headers = new AxiosHeaders();
if (downloadSource.etag) {
headers.set("If-None-Match", downloadSource.etag);
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
results.push({
...downloadSource,
downloads: source.downloads,
etag: response.headers["etag"],
status: DownloadSourceStatus.UpToDate,
});
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
results.push({
...downloadSource,
downloads: [],
etag: null,
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
return results;
};

18
src/main/workers/index.ts Normal file
View file

@ -0,0 +1,18 @@
import path from "node:path";
import steamGamesWorkerPath from "./steam-games.worker?modulePath";
import downloadSourceWorkerPath from "./download-source.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"),
},
});
export const downloadSourceWorker = new Piscina({
filename: downloadSourceWorkerPath,
});

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: "reverse",
});
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);
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

@ -3,7 +3,6 @@
import { contextBridge, ipcRenderer } from "electron";
import type {
CatalogueCategory,
GameShop,
DownloadProgress,
UserPreferences,
@ -32,8 +31,7 @@ contextBridge.exposeInMainWorld("electron", {
/* Catalogue */
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
getCatalogue: () => ipcRenderer.invoke("getCatalogue"),
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
@ -52,23 +50,30 @@ contextBridge.exposeInMainWorld("electron", {
authenticateRealDebrid: (apiToken: string) =>
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),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
/* Library */
addGameToLibrary: (
objectID: string,
title: string,
shop: GameShop,
executablePath: string
) =>
ipcRenderer.invoke(
"addGameToLibrary",
objectID,
title,
shop,
executablePath
),
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
createGameShortcut: (id: number) =>
ipcRenderer.invoke("createGameShortcut", id),
updateExecutablePath: (id: number, executablePath: string) =>
ipcRenderer.invoke("updateExecutablePath", id, executablePath),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
openGameInstaller: (gameId: number) =>
ipcRenderer.invoke("openGameInstaller", gameId),
openGameInstallerPath: (gameId: number) =>
ipcRenderer.invoke("openGameInstallerPath", gameId),
openGameExecutablePath: (gameId: number) =>
ipcRenderer.invoke("openGameExecutablePath", gameId),
openGame: (gameId: number, executablePath: string) =>
ipcRenderer.invoke("openGame", gameId, executablePath),
closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),

View file

@ -0,0 +1,12 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../theme.css";
export const badge = style({
color: "#c0c1c7",
fontSize: "10px",
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
border: "solid 1px #c0c1c7",
borderRadius: "4px",
display: "flex",
alignItems: "center",
});

View file

@ -0,0 +1,14 @@
import React from "react";
import * as styles from "./badge.css";
export interface BadgeProps {
children: React.ReactNode;
}
export function Badge({ children }: BadgeProps) {
return (
<div className={styles.badge}>
<span>{children}</span>
</div>
);
}

View file

@ -55,4 +55,15 @@ export const button = styleVariants({
color: "#c0c1c7",
},
],
danger: [
base,
{
border: `solid 1px #a31533`,
backgroundColor: "transparent",
color: "white",
":hover": {
backgroundColor: "#a31533",
},
},
],
});

View file

@ -69,16 +69,7 @@ export const downloadOptions = style({
padding: "0",
gap: `${SPACING_UNIT}px`,
flexWrap: "wrap",
});
export const downloadOption = style({
color: "#c0c1c7",
fontSize: "10px",
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
border: "solid 1px #c0c1c7",
borderRadius: "4px",
display: "flex",
alignItems: "center",
listStyle: "none",
});
export const specifics = style({

View file

@ -5,6 +5,7 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
export interface GameCardProps
extends React.DetailedHTMLProps<
@ -39,8 +40,8 @@ export function GameCard({ game, ...props }: GameCardProps) {
{uniqueRepackers.length > 0 ? (
<ul className={styles.downloadOptions}>
{uniqueRepackers.map((repacker) => (
<li key={repacker} className={styles.downloadOption}>
<span>{repacker}</span>
<li key={repacker}>
<Badge>{repacker}</Badge>
</li>
))}
</ul>

View file

@ -71,7 +71,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
isWindows: window.electron.platform === "win32",
})}
>
<div className={styles.section}>
<section className={styles.section}>
<button
type="button"
className={styles.backButton({
@ -90,7 +90,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
>
{title}
</h3>
</div>
</section>
<section className={styles.section}>
<div className={styles.search({ focused: isFocused })}>

View file

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

View file

@ -54,7 +54,7 @@ export function Hero() {
>
<div className={styles.backdrop}>
<img
src="https://cdn2.steamgriddb.com/hero/4ef10445b952a8b3c93a9379d581146a.jpg"
src={steamUrlBuilder.libraryHero(FEATURED_GAME_ID)}
alt={FEATURED_GAME_TITLE}
className={styles.heroMedia}
/>

View file

@ -8,5 +8,6 @@ export * from "./sidebar/sidebar";
export * from "./text-field/text-field";
export * from "./checkbox-field/checkbox-field";
export * from "./link/link";
export * from "./select/select";
export * from "./select-field/select-field";
export * from "./toast/toast";
export * from "./badge/badge";

View file

@ -2,26 +2,27 @@ import { keyframes, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const fadeIn = keyframes({
"0%": { opacity: 0 },
export const scaleFadeIn = keyframes({
"0%": { opacity: "0", scale: "0.5" },
"100%": {
opacity: 1,
opacity: "1",
scale: "1",
},
});
export const fadeOut = keyframes({
"0%": { opacity: 1 },
export const scaleFadeOut = keyframes({
"0%": { opacity: "1", scale: "1" },
"100%": {
opacity: 0,
opacity: "0",
scale: "0.5",
},
});
export const modal = recipe({
base: {
animationName: fadeIn,
animationDuration: "0.3s",
animation: `${scaleFadeIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
backgroundColor: vars.color.background,
borderRadius: "5px",
borderRadius: "4px",
maxWidth: "600px",
color: vars.color.body,
maxHeight: "100%",
@ -33,8 +34,14 @@ export const modal = recipe({
variants: {
closing: {
true: {
animationName: fadeOut,
opacity: 0,
animationName: scaleFadeOut,
opacity: "0",
},
},
large: {
true: {
width: "800px",
maxWidth: "800px",
},
},
},

View file

@ -12,6 +12,7 @@ export interface ModalProps {
title: string;
description?: string;
onClose: () => void;
large?: boolean;
children: React.ReactNode;
}
@ -20,6 +21,7 @@ export function Modal({
title,
description,
onClose,
large,
children,
}: ModalProps) {
const [isClosing, setIsClosing] = useState(false);
@ -88,7 +90,7 @@ export function Modal({
return createPortal(
<Backdrop isClosing={isClosing}>
<div
className={styles.modal({ closing: isClosing })}
className={styles.modal({ closing: isClosing, large })}
role="dialog"
aria-labelledby={title}
aria-describedby={description}

View file

@ -49,9 +49,6 @@ export const option = style({
fontSize: vars.size.body,
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
":focus": {
cursor: "text",
},
});
export const label = style({

View file

@ -1,6 +1,6 @@
import { useId, useState } from "react";
import type { RecipeVariants } from "@vanilla-extract/recipes";
import * as styles from "./select.css";
import * as styles from "./select-field.css";
export interface SelectProps
extends React.DetailedHTMLProps<
@ -12,7 +12,7 @@ export interface SelectProps
options?: { key: string; value: string; label: string }[];
}
export function Select({
export function SelectField({
value,
label,
options = [{ key: "-", value: value?.toString() || "-", label: "-" }],

View file

@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import type { Game } from "@types";
import type { LibraryGame } from "@types";
import { TextField } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import { routes } from "./routes";
@ -25,7 +25,7 @@ export function Sidebar() {
const { library, updateLibrary } = useLibrary();
const navigate = useNavigate();
const [filteredLibrary, setFilteredLibrary] = useState<Game[]>([]);
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
const [isResizing, setIsResizing] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(
@ -36,6 +36,8 @@ export function Sidebar() {
const { lastPacket, progress } = useDownload();
const { showWarningToast } = useToast();
useEffect(() => {
updateLibrary();
}, [lastPacket?.game.id, updateLibrary]);
@ -99,9 +101,7 @@ export function Sidebar() {
};
}, [isResizing]);
const getGameTitle = (game: Game) => {
if (game.status === "paused") return t("paused", { title: game.title });
const getGameTitle = (game: LibraryGame) => {
if (lastPacket?.game.id === game.id) {
return t("downloading", {
title: game.title,
@ -109,6 +109,12 @@ export function Sidebar() {
});
}
if (game.downloadQueue !== null) {
return t("queued", { title: game.title });
}
if (game.status === "paused") return t("paused", { title: game.title });
return game.title;
};
@ -118,6 +124,24 @@ export function Sidebar() {
}
};
const handleSidebarGameClick = (
event: React.MouseEvent,
game: LibraryGame
) => {
const path = buildGameDetailsPath(game);
if (path !== location.pathname) {
navigate(path);
}
if (event.detail == 2) {
if (game.executablePath) {
window.electron.openGame(game.id, game.executablePath);
} else {
showWarningToast(t("game_has_no_executable"));
}
}
};
return (
<aside
ref={sidebarRef}
@ -179,9 +203,7 @@ export function Sidebar() {
<button
type="button"
className={styles.menuItemButton}
onClick={() =>
handleSidebarItemClick(buildGameDetailsPath(game))
}
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
<img

View file

@ -42,18 +42,33 @@ export const textField = recipe({
},
});
export const textFieldInput = style({
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
":focus": {
cursor: "text",
export const textFieldInput = recipe({
base: {
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
":focus": {
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 * as styles from "./text-field.css";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
export interface TextFieldProps
extends React.DetailedHTMLProps<
@ -28,9 +30,20 @@ export function TextField({
containerProps,
...props
}: TextFieldProps) {
const [isFocused, setIsFocused] = useState(false);
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 (
<div className={styles.textFieldContainer} {...containerProps}>
{label && <label htmlFor={id}>{label}</label>}
@ -41,12 +54,27 @@ export function TextField({
>
<input
id={id}
type="text"
className={styles.textFieldInput}
className={styles.textFieldInput({ readOnly: props.readOnly })}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...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>
{hint && <small>{hint}</small>}

View file

@ -81,3 +81,7 @@ export const successIcon = style({
export const errorIcon = style({
color: vars.color.danger,
});
export const warningIcon = style({
color: vars.color.warning,
});

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
AlertIcon,
CheckCircleFillIcon,
XCircleFillIcon,
XIcon,
@ -11,7 +12,7 @@ import { SPACING_UNIT } from "@renderer/theme.css";
export interface ToastProps {
visible: boolean;
message: string;
type: "success" | "error";
type: "success" | "error" | "warning";
onClose: () => void;
}
@ -84,6 +85,8 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
)}
{type === "error" && <XCircleFillIcon className={styles.errorIcon} />}
{type === "warning" && <AlertIcon className={styles.warningIcon} />}
<span style={{ fontWeight: "bold" }}>{message}</span>
</div>

View file

@ -1,8 +1,8 @@
import type {
AppUpdaterEvent,
CatalogueCategory,
CatalogueEntry,
Game,
LibraryGame,
GameRepack,
GameShop,
HowLongToBeatCategory,
@ -12,6 +12,7 @@ import type {
UserPreferences,
StartGameDownloadPayload,
RealDebridUser,
DownloadSource,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -33,7 +34,7 @@ declare global {
/* Catalogue */
searchGames: (query: string) => Promise<CatalogueEntry[]>;
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
getCatalogue: () => Promise<CatalogueEntry[]>;
getGameShopDetails: (
objectID: string,
shop: GameShop,
@ -55,11 +56,14 @@ declare global {
addGameToLibrary: (
objectID: string,
title: string,
shop: GameShop,
executablePath: string | null
shop: GameShop
) => Promise<void>;
getLibrary: () => Promise<Game[]>;
createGameShortcut: (id: number) => Promise<boolean>;
updateExecutablePath: (id: number, executablePath: string) => Promise<void>;
getLibrary: () => Promise<LibraryGame[]>;
openGameInstaller: (gameId: number) => Promise<boolean>;
openGameInstallerPath: (gameId: number) => Promise<boolean>;
openGameExecutablePath: (gameId: number) => Promise<void>;
openGame: (gameId: number, executablePath: string) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>;
removeGameFromLibrary: (gameId: number) => Promise<void>;
@ -77,6 +81,15 @@ declare global {
autoLaunch: (enabled: boolean) => Promise<void>;
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>;
syncDownloadSources: () => Promise<void>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;

View file

@ -1,10 +1,10 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { Game } from "@types";
import type { LibraryGame } from "@types";
export interface LibraryState {
value: Game[];
value: LibraryGame[];
}
const initialState: LibraryState = {

View file

@ -1,4 +1,4 @@
import type { CatalogueEntry } from "@types";
import type { GameShop } from "@types";
export const steamUrlBuilder = {
library: (objectID: string) =>
@ -34,7 +34,7 @@ export const getSteamLanguage = (language: string) => {
};
export const buildGameDetailsPath = (
game: Pick<CatalogueEntry, "title" | "shop" | "objectID">,
game: { shop: GameShop; objectID: string; title: string },
params: Record<string, string> = {}
) => {
const searchParams = new URLSearchParams({ title: game.title, ...params });

View file

@ -41,24 +41,25 @@ export function useDownload() {
return updateLibrary();
};
const cancelDownload = async (gameId: number) => {
await window.electron.cancelGameDownload(gameId);
dispatch(clearDownload());
updateLibrary();
};
const removeGameInstaller = async (gameId: number) => {
dispatch(setGameDeleting(gameId));
try {
await window.electron.deleteGameFolder(gameId);
await window.electron.removeGame(gameId);
updateLibrary();
} finally {
dispatch(removeGameFromDeleting(gameId));
}
};
const cancelDownload = async (gameId: number) => {
await window.electron.cancelGameDownload(gameId);
dispatch(clearDownload());
updateLibrary();
removeGameInstaller(gameId);
};
const removeGameFromLibrary = (gameId: number) =>
window.electron.removeGameFromLibrary(gameId).then(() => {
updateLibrary();

View file

@ -29,5 +29,17 @@ export function useToast() {
[dispatch]
);
return { showSuccessToast, showErrorToast };
const showWarningToast = useCallback(
(message: string) => {
dispatch(
showToast({
message,
type: "warning",
})
);
},
[dispatch]
);
return { showSuccessToast, showErrorToast, showWarningToast };
}

View file

@ -0,0 +1,107 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const downloadTitleWrapper = style({
display: "flex",
alignItems: "center",
marginBottom: `${SPACING_UNIT}px`,
gap: `${SPACING_UNIT}px`,
});
export const downloadTitle = style({
fontWeight: "bold",
cursor: "pointer",
color: vars.color.body,
textAlign: "left",
fontSize: "16px",
display: "block",
":hover": {
textDecoration: "underline",
},
});
export const downloads = style({
width: "100%",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
margin: "0",
padding: "0",
marginTop: `${SPACING_UNIT}px`,
});
export const downloadCover = style({
width: "280px",
minWidth: "280px",
height: "auto",
borderRight: `solid 1px ${vars.color.border}`,
position: "relative",
zIndex: "1",
});
export const downloadCoverContent = style({
width: "100%",
height: "100%",
padding: `${SPACING_UNIT}px`,
display: "flex",
alignItems: "flex-end",
justifyContent: "flex-end",
});
export const downloadCoverBackdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, transparent 100%)",
display: "flex",
overflow: "hidden",
zIndex: "1",
});
export const downloadCoverImage = style({
width: "100%",
height: "100%",
position: "absolute",
zIndex: "-1",
});
export const download = style({
width: "100%",
backgroundColor: vars.color.background,
display: "flex",
borderRadius: "8px",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
transition: "all ease 0.2s",
height: "140px",
minHeight: "140px",
maxHeight: "140px",
});
export const downloadDetails = style({
display: "flex",
flexDirection: "column",
flex: "1",
justifyContent: "center",
gap: `${SPACING_UNIT / 2}px`,
fontSize: "14px",
});
export const downloadRightContent = style({
display: "flex",
padding: `${SPACING_UNIT * 2}px`,
flex: "1",
gap: `${SPACING_UNIT}px`,
});
export const downloadActions = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});
export const downloadGroup = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});

View file

@ -0,0 +1,237 @@
import { useNavigate } from "react-router-dom";
import type { LibraryGame } from "@types";
import { Badge, Button } from "@renderer/components";
import {
buildGameDetailsPath,
formatDownloadProgress,
steamUrlBuilder,
} from "@renderer/helpers";
import { Downloader, formatBytes } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload } from "@renderer/hooks";
import * as styles from "./download-group.css";
import { useTranslation } from "react-i18next";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
export interface DownloadGroupProps {
library: LibraryGame[];
title: string;
openDeleteGameModal: (gameId: number) => void;
openGameInstaller: (gameId: number) => void;
}
export function DownloadGroup({
library,
title,
openDeleteGameModal,
openGameInstaller,
}: DownloadGroupProps) {
const navigate = useNavigate();
const { t } = useTranslation("downloads");
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const {
lastPacket,
progress,
pauseDownload,
resumeDownload,
cancelDownload,
isGameDeleting,
} = useDownload();
const getFinalDownloadSize = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
if (game.fileSize) return formatBytes(game.fileSize);
if (lastPacket?.game.fileSize && isGameDownloading)
return formatBytes(lastPacket?.game.fileSize);
return "N/A";
};
const getGameInfo = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
}
if (isGameDownloading) {
return (
<>
<p>{progress}</p>
<p>
{formatBytes(lastPacket?.game.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
{game.downloader === Downloader.Torrent && (
<small>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
</small>
)}
</>
);
}
if (game.progress === 1) {
return <p>{t("completed")}</p>;
}
if (game.status === "paused") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>{t(game.downloadQueue && lastPacket ? "queued" : "paused")}</p>
</>
);
}
if (game.status === "active") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
</p>
</>
);
}
return <p>{t(game.status)}</p>;
};
const getGameActions = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const deleting = isGameDeleting(game.id);
if (game.progress === 1) {
return (
<>
<Button
onClick={() => openGameInstaller(game.id)}
theme="outline"
disabled={deleting}
>
{t("install")}
</Button>
<Button onClick={() => openDeleteGameModal(game.id)} theme="outline">
{t("delete")}
</Button>
</>
);
}
if (isGameDownloading || game.status === "active") {
return (
<>
<Button onClick={() => pauseDownload(game.id)} theme="outline">
{t("pause")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
return (
<>
<Button
onClick={() => resumeDownload(game.id)}
theme="outline"
disabled={
game.downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
>
{t("resume")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
};
if (!library.length) return null;
return (
<div className={styles.downloadGroup}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{title}</h2>
<div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>{library.length}</h3>
</div>
<ul className={styles.downloads}>
{library.map((game) => {
return (
<li key={game.id} className={styles.download}>
<div className={styles.downloadCover}>
<div className={styles.downloadCoverBackdrop}>
<img
src={steamUrlBuilder.library(game.objectID)}
className={styles.downloadCoverImage}
alt={game.title}
/>
<div className={styles.downloadCoverContent}>
<Badge>{DOWNLOADER_NAME[game.downloader]}</Badge>
</div>
</div>
</div>
<div className={styles.downloadRightContent}>
<div className={styles.downloadDetails}>
<div className={styles.downloadTitleWrapper}>
<button
type="button"
className={styles.downloadTitle}
onClick={() => navigate(buildGameDetailsPath(game))}
>
{game.title}
</button>
</div>
{getGameInfo(game)}
</div>
<div className={styles.downloadActions}>
{getGameActions(game)}
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View file

@ -1,134 +1,5 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { SPACING_UNIT } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
export const downloadTitleWrapper = style({
display: "flex",
alignItems: "center",
marginBottom: `${SPACING_UNIT}px`,
gap: `${SPACING_UNIT}px`,
});
export const downloadTitle = style({
fontWeight: "bold",
cursor: "pointer",
color: vars.color.body,
textAlign: "left",
fontSize: "16px",
display: "block",
":hover": {
textDecoration: "underline",
},
});
export const repackTitle = style({
maxHeight: "40px",
overflow: "hidden",
});
export const downloaderName = style({
color: "#c0c1c7",
fontSize: "10px",
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
border: "solid 1px #c0c1c7",
borderRadius: "4px",
display: "flex",
alignItems: "center",
});
export const downloads = style({
width: "100%",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
margin: "0",
padding: "0",
marginTop: `${SPACING_UNIT * 3}px`,
});
export const downloadCover = style({
width: "280px",
minWidth: "280px",
height: "auto",
borderRight: `solid 1px ${vars.color.border}`,
position: "relative",
zIndex: "1",
});
export const downloadCoverContent = style({
width: "100%",
height: "100%",
padding: `${SPACING_UNIT}px`,
display: "flex",
alignItems: "flex-end",
justifyContent: "flex-end",
});
export const downloadCoverBackdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, transparent 100%)",
display: "flex",
overflow: "hidden",
zIndex: "1",
});
export const downloadCoverImage = style({
width: "100%",
height: "100%",
position: "absolute",
zIndex: "-1",
});
export const download = recipe({
base: {
width: "100%",
backgroundColor: vars.color.background,
display: "flex",
borderRadius: "8px",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
transition: "all ease 0.2s",
height: "140px",
minHeight: "140px",
maxHeight: "140px",
},
variants: {
cancelled: {
true: {
opacity: vars.opacity.disabled,
":hover": {
opacity: "1",
},
},
},
},
});
export const downloadDetails = style({
display: "flex",
flexDirection: "column",
flex: "1",
justifyContent: "center",
gap: `${SPACING_UNIT / 2}px`,
fontSize: "14px",
});
export const downloadRightContent = style({
display: "flex",
alignItems: "center",
padding: `${SPACING_UNIT * 2}px`,
flex: "1",
gap: `${SPACING_UNIT}px`,
});
export const downloadActions = style({
height: "fit-content",
display: "flex",
alignItems: "stretch",
gap: `${SPACING_UNIT}px`,
});
export const downloadsContainer = style({
display: "flex",
@ -136,3 +7,30 @@ export const downloadsContainer = style({
flexDirection: "column",
width: "100%",
});
export const downloadGroups = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
flexDirection: "column",
});
export const arrowIcon = style({
width: "60px",
height: "60px",
borderRadius: "50%",
backgroundColor: "rgba(255, 255, 255, 0.06)",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: `${SPACING_UNIT * 2}px`,
});
export const noDownloads = style({
display: "flex",
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});

View file

@ -1,235 +1,106 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button, TextField } from "@renderer/components";
import {
buildGameDetailsPath,
formatDownloadProgress,
steamUrlBuilder,
} from "@renderer/helpers";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import type { Game } from "@types";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
import * as styles from "./downloads.css";
import { DeleteGameModal } from "./delete-game-modal";
import { Downloader, formatBytes } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { DownloadGroup } from "./download-group";
import { LibraryGame } from "@types";
import { orderBy } from "lodash-es";
import { ArrowDownIcon } from "@primer/octicons-react";
export function Downloads() {
const { library, updateLibrary } = useLibrary();
const { t } = useTranslation("downloads");
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const navigate = useNavigate();
const gameToBeDeleted = useRef<number | null>(null);
const [filteredLibrary, setFilteredLibrary] = useState<Game[]>([]);
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const {
lastPacket,
progress,
pauseDownload,
resumeDownload,
removeGameFromLibrary,
cancelDownload,
removeGameInstaller,
isGameDeleting,
} = useDownload();
const libraryWithDownloadedGamesOnly = useMemo(() => {
return library.filter((game) => game.status);
}, [library]);
useEffect(() => {
setFilteredLibrary(libraryWithDownloadedGamesOnly);
}, [libraryWithDownloadedGamesOnly]);
const openGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
const getFinalDownloadSize = (game: Game) => {
const isGameDownloading = lastPacket?.game.id === game.id;
if (!game) return "N/A";
if (game.fileSize) return formatBytes(game.fileSize);
if (lastPacket?.game.fileSize && isGameDownloading)
return formatBytes(lastPacket?.game.fileSize);
return game.repack?.fileSize ?? "N/A";
};
const getGameInfo = (game: Game) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
}
if (isGameDownloading) {
return (
<>
<p>{progress}</p>
<p>
{formatBytes(lastPacket?.game.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
{game.downloader === Downloader.Torrent && (
<small>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
</small>
)}
</>
);
}
if (game.progress === 1) {
return (
<>
<p className={styles.repackTitle}>{game.repack?.title}</p>
<p>{t("completed")}</p>
</>
);
}
if (game.status === "paused") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>{t("paused")}</p>
</>
);
}
if (game.status === "active") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
</p>
</>
);
}
return <p>{t(game.status)}</p>;
};
const openDeleteModal = (gameId: number) => {
gameToBeDeleted.current = gameId;
setShowDeleteModal(true);
};
const getGameActions = (game: Game) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const deleting = isGameDeleting(game.id);
if (game.progress === 1) {
return (
<>
<Button
onClick={() => openGameInstaller(game.id)}
theme="outline"
disabled={deleting}
>
{t("install")}
</Button>
<Button onClick={() => openDeleteModal(game.id)} theme="outline">
{t("delete")}
</Button>
</>
);
}
if (isGameDownloading || game.status === "active") {
return (
<>
<Button onClick={() => pauseDownload(game.id)} theme="outline">
{t("pause")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
if (game.status === "paused") {
return (
<>
<Button
onClick={() => resumeDownload(game.id)}
theme="outline"
disabled={
game.downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
>
{t("resume")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
return (
<>
<Button
onClick={() => navigate(buildGameDetailsPath(game))}
theme="outline"
disabled={deleting}
>
{t("download_again")}
</Button>
<Button
onClick={() => removeGameFromLibrary(game.id)}
theme="outline"
disabled={deleting}
>
{t("remove_from_list")}
</Button>
</>
);
};
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setFilteredLibrary(
libraryWithDownloadedGamesOnly.filter((game) =>
game.title
.toLowerCase()
.includes(event.target.value.toLocaleLowerCase())
)
);
};
const { removeGameInstaller } = useDownload();
const handleDeleteGame = async () => {
if (gameToBeDeleted.current)
await removeGameInstaller(gameToBeDeleted.current);
};
const { lastPacket } = useDownload();
const handleOpenGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
const handleOpenDeleteGameModal = (gameId: number) => {
gameToBeDeleted.current = gameId;
setShowDeleteModal(true);
};
const libraryGroup: Record<string, LibraryGame[]> = useMemo(() => {
const initialValue: Record<string, LibraryGame[]> = {
downloading: [],
queued: [],
complete: [],
};
const result = library.reduce((prev, next) => {
/* Game has been manually added to the library or has been canceled */
if (!next.status || next.status === "removed") return prev;
/* Is downloading */
if (lastPacket?.game.id === next.id)
return { ...prev, downloading: [...prev.downloading, next] };
/* Is either queued or paused */
if (next.downloadQueue || next.status === "paused")
return { ...prev, queued: [...prev.queued, next] };
return { ...prev, complete: [...prev.complete, next] };
}, initialValue);
const queued = orderBy(
result.queued,
(game) => game.downloadQueue?.id ?? -1,
["desc"]
);
const complete = orderBy(result.complete, (game) =>
game.progress === 1 ? 0 : 1
);
return {
...result,
queued,
complete,
};
}, [library, lastPacket?.game.id]);
const downloadGroups = [
{
title: t("download_in_progress"),
library: libraryGroup.downloading,
},
{
title: t("queued_downloads"),
library: libraryGroup.queued,
},
{
title: t("downloads_completed"),
library: libraryGroup.complete,
},
];
const hasItemsInLibrary = useMemo(() => {
return Object.values(libraryGroup).some((group) => group.length > 0);
}, [libraryGroup]);
return (
<section className={styles.downloadsContainer}>
<>
<BinaryNotFoundModal
visible={showBinaryNotFoundModal}
onClose={() => setShowBinaryNotFoundModal(false)}
@ -241,55 +112,31 @@ export function Downloads() {
deleteGame={handleDeleteGame}
/>
<TextField placeholder={t("filter")} onChange={handleFilter} />
<ul className={styles.downloads}>
{filteredLibrary.map((game) => {
return (
<li
key={game.id}
className={styles.download({
cancelled: game.status === "removed",
})}
>
<div className={styles.downloadCover}>
<div className={styles.downloadCoverBackdrop}>
<img
src={steamUrlBuilder.library(game.objectID)}
className={styles.downloadCoverImage}
alt={game.title}
/>
<div className={styles.downloadCoverContent}>
<small className={styles.downloaderName}>
{DOWNLOADER_NAME[game.downloader]}
</small>
</div>
</div>
</div>
<div className={styles.downloadRightContent}>
<div className={styles.downloadDetails}>
<div className={styles.downloadTitleWrapper}>
<button
type="button"
className={styles.downloadTitle}
onClick={() => navigate(buildGameDetailsPath(game))}
>
{game.title}
</button>
</div>
{getGameInfo(game)}
</div>
<div className={styles.downloadActions}>
{getGameActions(game)}
</div>
</div>
</li>
);
})}
</ul>
</section>
{hasItemsInLibrary ? (
<section className={styles.downloadsContainer}>
<div className={styles.downloadGroups}>
{downloadGroups.map((group) => (
<DownloadGroup
key={group.title}
title={group.title}
library={group.library}
openDeleteGameModal={handleOpenDeleteGameModal}
openGameInstaller={handleOpenGameInstaller}
/>
))}
</div>
</section>
) : (
<div className={styles.noDownloads}>
<div className={styles.arrowIcon}>
<ArrowDownIcon size={24} />
</div>
<h2>{t("no_downloads_title")}</h2>
<p style={{ fontFamily: "Fira Sans" }}>
{t("no_downloads_description")}
</p>
</div>
)}
</>
);
}

View file

@ -13,7 +13,7 @@ export function GallerySlider() {
const { t } = useTranslation("game_details");
const hasScreenshots = shopDetails && shopDetails.screenshots.length;
const hasScreenshots = shopDetails && shopDetails.screenshots?.length;
const hasMovies = shopDetails && shopDetails.movies?.length;
const mediaCount = useMemo(() => {
@ -77,7 +77,7 @@ export function GallerySlider() {
const previews = useMemo(() => {
const screenshotPreviews =
shopDetails?.screenshots.map(({ id, path_thumbnail }) => ({
shopDetails?.screenshots?.map(({ id, path_thumbnail }) => ({
id,
thumbnail: path_thumbnail,
})) ?? [];
@ -121,7 +121,7 @@ export function GallerySlider() {
))}
{hasScreenshots &&
shopDetails.screenshots.map((image, i) => (
shopDetails.screenshots?.map((image, i) => (
<img
key={image.id}
className={styles.gallerySliderMedia}

View file

@ -3,7 +3,7 @@ import { useParams, useSearchParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers";
import { useAppDispatch, useDownload } from "@renderer/hooks";
import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
@ -16,11 +16,13 @@ import {
RepacksModal,
} from "./modals";
import { Downloader } from "@shared";
import { GameOptionsModal } from "./modals/game-options-modal";
export interface GameDetailsContext {
game: Game | null;
shopDetails: ShopDetails | null;
repacks: GameRepack[];
shop: GameShop;
gameTitle: string;
isGameRunning: boolean;
isLoading: boolean;
@ -28,6 +30,8 @@ export interface GameDetailsContext {
gameColor: string;
setGameColor: React.Dispatch<React.SetStateAction<string>>;
openRepacksModal: () => void;
openGameOptionsModal: () => void;
selectGameExecutable: () => Promise<string | null>;
updateGame: () => Promise<void>;
}
@ -35,6 +39,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
game: null,
shopDetails: null,
repacks: [],
shop: "steam",
gameTitle: "",
isGameRunning: false,
isLoading: false,
@ -42,6 +47,8 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
gameColor: "",
setGameColor: () => {},
openRepacksModal: () => {},
openGameOptionsModal: () => {},
selectGameExecutable: async () => null,
updateGame: async () => {},
});
@ -68,6 +75,7 @@ export function GameDetailsContextProvider({
>(null);
const [isGameRunning, setisGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
const [searchParams] = useSearchParams();
@ -79,6 +87,10 @@ export function GameDetailsContextProvider({
const { startDownload, lastPacket } = useDownload();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const updateGame = useCallback(async () => {
return window.electron
.getGameByObjectID(objectID!)
@ -92,7 +104,7 @@ export function GameDetailsContextProvider({
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
useEffect(() => {
Promise.all([
Promise.allSettled([
window.electron.getGameShopDetails(
objectID!,
shop as GameShop,
@ -100,9 +112,12 @@ export function GameDetailsContextProvider({
),
window.electron.searchGameRepacks(gameTitle),
])
.then(([appDetails, repacks]) => {
if (appDetails) setGameDetails(appDetails);
setRepacks(repacks);
.then(([appDetailsResult, repacksResult]) => {
if (appDetailsResult.status === "fulfilled")
setGameDetails(appDetailsResult.value);
if (repacksResult.status === "fulfilled")
setRepacks(repacksResult.value);
})
.finally(() => {
setIsLoading(false);
@ -153,6 +168,7 @@ export function GameDetailsContextProvider({
await updateGame();
setShowRepacksModal(false);
setShowGameOptionsModal(false);
if (
repack.repacker === "onlinefix" &&
@ -167,13 +183,43 @@ export function GameDetailsContextProvider({
}
};
const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
};
const selectGameExecutable = async () => {
const downloadsPath = await getDownloadsPath();
return window.electron
.showOpenDialog({
properties: ["openFile"],
defaultPath: downloadsPath,
filters: [
{
name: "Game executable",
extensions: ["exe"],
},
],
})
.then(({ filePaths }) => {
if (filePaths && filePaths.length > 0) {
return filePaths[0];
}
return null;
});
};
const openRepacksModal = () => setShowRepacksModal(true);
const openGameOptionsModal = () => setShowGameOptionsModal(true);
return (
<Provider
value={{
game,
shopDetails,
shop: shop as GameShop,
repacks,
gameTitle,
isGameRunning,
@ -182,6 +228,8 @@ export function GameDetailsContextProvider({
gameColor,
setGameColor,
openRepacksModal,
openGameOptionsModal,
selectGameExecutable,
updateGame,
}}
>
@ -202,6 +250,16 @@ export function GameDetailsContextProvider({
onClose={() => setShowInstructionsModal(null)}
/>
{game && (
<GameOptionsModal
visible={showGameOptionsModal}
game={game}
onClose={() => {
setShowGameOptionsModal(false);
}}
/>
)}
{children}
</>
</Provider>

View file

@ -1,7 +1,18 @@
import { style } from "@vanilla-extract/css";
import { vars } from "../../../theme.css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const heroPanelAction = style({
border: `solid 1px ${vars.color.muted}`,
});
export const actions = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
});
export const separator = style({
width: "1px",
backgroundColor: vars.color.muted,
opacity: "0.2",
});

View file

@ -1,28 +1,16 @@
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { BinaryNotFoundModal } from "../../shared-modals/binary-not-found-modal";
import { GearIcon, PlayIcon, PlusCircleIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel-actions.css";
import { gameDetailsContext } from "../game-details.context";
import { Downloader } from "@shared";
export function HeroPanelActions() {
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
useState(false);
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const {
resumeDownload,
pauseDownload,
cancelDownload,
removeGameFromLibrary,
isGameDeleting,
} = useDownload();
const { isGameDeleting } = useDownload();
const {
game,
@ -31,61 +19,20 @@ export function HeroPanelActions() {
objectID,
gameTitle,
openRepacksModal,
openGameOptionsModal,
updateGame,
selectGameExecutable,
} = useContext(gameDetailsContext);
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const { updateLibrary } = useLibrary();
const { t } = useTranslation("game_details");
const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
};
const selectGameExecutable = async () => {
const downloadsPath = await getDownloadsPath();
return window.electron
.showOpenDialog({
properties: ["openFile"],
defaultPath: downloadsPath,
filters: [
{
name: "Game executable",
extensions: ["exe"],
},
],
})
.then(({ filePaths }) => {
if (filePaths && filePaths.length > 0) {
return filePaths[0];
}
return null;
});
};
const toggleGameOnLibrary = async () => {
const addGameToLibrary = async () => {
setToggleLibraryGameDisabled(true);
try {
if (game) {
await removeGameFromLibrary(game.id);
} else {
const gameExecutablePath = await selectGameExecutable();
await window.electron.addGameToLibrary(
objectID!,
gameTitle,
"steam",
gameExecutablePath
);
}
await window.electron.addGameToLibrary(objectID!, gameTitle, "steam");
updateLibrary();
updateGame();
@ -94,15 +41,6 @@ export function HeroPanelActions() {
}
};
const openGameInstaller = () => {
if (game) {
window.electron.openGameInstaller(game.id).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
}
};
const openGame = async () => {
if (game) {
if (game.executablePath) {
@ -110,11 +48,6 @@ export function HeroPanelActions() {
return;
}
if (game?.executablePath) {
window.electron.openGame(game.id, game.executablePath);
return;
}
const gameExecutablePath = await selectGameExecutable();
if (gameExecutablePath)
window.electron.openGame(game.id, gameExecutablePath);
@ -127,144 +60,76 @@ export function HeroPanelActions() {
const deleting = game ? isGameDeleting(game?.id) : false;
const toggleGameOnLibraryButton = (
const addGameToLibraryButton = (
<Button
theme="outline"
disabled={toggleLibraryGameDisabled}
onClick={toggleGameOnLibrary}
onClick={addGameToLibrary}
className={styles.heroPanelAction}
>
{game ? <NoEntryIcon /> : <PlusCircleIcon />}
{game ? t("remove_from_library") : t("add_to_library")}
<PlusCircleIcon />
{t("add_to_library")}
</Button>
);
if (game?.status === "active" && game?.progress !== 1) {
return (
<>
<Button
onClick={() => pauseDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("pause")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "paused") {
return (
<>
<Button
onClick={() => resumeDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
disabled={
game.downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
>
{t("resume")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "removed") {
return (
<>
<Button
onClick={openRepacksModal}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
>
{t("open_download_options")}
</Button>
<Button
onClick={() => removeGameFromLibrary(game.id).then(updateGame)}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
>
{t("remove_from_list")}
</Button>
</>
);
}
const showDownloadOptionsButton = (
<Button
onClick={openRepacksModal}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
>
{t("open_download_options")}
</Button>
);
if (repacks.length && !game) {
return (
<>
{toggleGameOnLibraryButton}
<Button
onClick={openRepacksModal}
theme="outline"
className={styles.heroPanelAction}
>
{t("open_download_options")}
</Button>
{addGameToLibraryButton}
{showDownloadOptionsButton}
</>
);
}
return (
<>
{game?.progress === 1 ? (
<>
<BinaryNotFoundModal
visible={showBinaryNotFoundModal}
onClose={() => setShowBinaryNotFoundModal(false)}
/>
if (game) {
return (
<div className={styles.actions}>
{isGameRunning ? (
<Button
onClick={openGameInstaller}
onClick={closeGame}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
>
{t("close")}
</Button>
) : (
<Button
onClick={openGame}
theme="outline"
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}
>
{t("install")}
<PlayIcon />
{t("play")}
</Button>
</>
) : (
toggleGameOnLibraryButton
)}
)}
<div className={styles.separator} />
{isGameRunning ? (
<Button
onClick={closeGame}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
>
{t("close")}
</Button>
) : (
<Button
onClick={openGame}
onClick={openGameOptionsModal}
theme="outline"
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}
>
{t("play")}
<GearIcon />
{t("options")}
</Button>
)}
</>
);
</div>
);
}
return addGameToLibraryButton;
}

Some files were not shown because too many files have changed in this diff Show more