From c2273dbf712842ef7f6241d8a68b154816bbb64a Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Sat, 18 Oct 2025 14:07:44 +0100
Subject: [PATCH 01/81] feat: moving sources to worker
---
src/main/events/download-sources/helpers.ts | 85 ++--------
.../download-sources/sync-download-sources.ts | 20 +--
src/main/main.ts | 4 +
.../services/game-matcher-worker-manager.ts | 145 ++++++++++++++++
src/main/services/index.ts | 1 +
src/main/workers/game-matcher-worker.ts | 158 ++++++++++++++++++
6 files changed, 329 insertions(+), 84 deletions(-)
create mode 100644 src/main/services/game-matcher-worker-manager.ts
create mode 100644 src/main/workers/game-matcher-worker.ts
diff --git a/src/main/events/download-sources/helpers.ts b/src/main/events/download-sources/helpers.ts
index 2e7489fd..edd3878e 100644
--- a/src/main/events/download-sources/helpers.ts
+++ b/src/main/events/download-sources/helpers.ts
@@ -192,83 +192,30 @@ export const addNewDownloads = async (
const batch = repacksSublevel.batch();
+ // Get title hash mapping and perform matching in worker thread
const titleHashMapping = await getTitleHashMapping();
- let hashMatchCount = 0;
- let fuzzyMatchCount = 0;
- let noMatchCount = 0;
- for (const download of downloads) {
- let objectIds: string[] = [];
- let usedHashMatch = false;
+ const { GameMatcherWorkerManager } = await import("@main/services");
+ const matchResult = await GameMatcherWorkerManager.matchDownloads(
+ downloads,
+ steamGames,
+ titleHashMapping
+ );
- const titleHash = hashTitle(download.title);
- const steamIdsFromHash = titleHashMapping[titleHash];
-
- if (steamIdsFromHash && steamIdsFromHash.length > 0) {
- hashMatchCount++;
- usedHashMatch = true;
-
- objectIds = steamIdsFromHash.map(String);
- }
-
- if (!usedHashMatch) {
- let gamesInSteam: FormattedSteamGame[] = [];
- const formattedTitle = formatRepackName(download.title);
-
- if (formattedTitle && formattedTitle.length > 0) {
- const [firstLetter] = formattedTitle;
- const games = steamGames[firstLetter] || [];
-
- gamesInSteam = games.filter((game) =>
- formattedTitle.startsWith(game.formattedName)
- );
-
- if (gamesInSteam.length === 0) {
- gamesInSteam = games.filter(
- (game) =>
- formattedTitle.includes(game.formattedName) ||
- game.formattedName.includes(formattedTitle)
- );
- }
-
- if (gamesInSteam.length === 0) {
- for (const letter of Object.keys(steamGames)) {
- const letterGames = steamGames[letter] || [];
- const matches = letterGames.filter(
- (game) =>
- formattedTitle.includes(game.formattedName) ||
- game.formattedName.includes(formattedTitle)
- );
- if (matches.length > 0) {
- gamesInSteam = matches;
- break;
- }
- }
- }
-
- if (gamesInSteam.length > 0) {
- fuzzyMatchCount++;
- objectIds = gamesInSteam.map((game) => String(game.id));
- } else {
- noMatchCount++;
- }
- } else {
- noMatchCount++;
- }
- }
-
- for (const id of objectIds) {
+ // Process matched results and write to database
+ for (const matchedDownload of matchResult.matchedDownloads) {
+ for (const id of matchedDownload.objectIds) {
objectIdsOnSource.add(id);
}
const repack = {
id: nextRepackId++,
- objectIds: objectIds,
- title: download.title,
- uris: download.uris,
- fileSize: download.fileSize,
+ objectIds: matchedDownload.objectIds,
+ title: matchedDownload.title,
+ uris: matchedDownload.uris,
+ fileSize: matchedDownload.fileSize,
repacker: downloadSource.name,
- uploadDate: download.uploadDate,
+ uploadDate: matchedDownload.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
@@ -280,7 +227,7 @@ export const addNewDownloads = async (
await batch.write();
logger.info(
- `Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}`
+ `Matching stats for ${downloadSource.name}: Hash=${matchResult.stats.hashMatchCount}, Fuzzy=${matchResult.stats.fuzzyMatchCount}, None=${matchResult.stats.noMatchCount}`
);
const existingSource = await downloadSourcesSublevel.get(
diff --git a/src/main/events/download-sources/sync-download-sources.ts b/src/main/events/download-sources/sync-download-sources.ts
index 88861074..3bb78f22 100644
--- a/src/main/events/download-sources/sync-download-sources.ts
+++ b/src/main/events/download-sources/sync-download-sources.ts
@@ -31,20 +31,10 @@ const syncDownloadSources = async (
downloadSources.push(source);
}
- const existingRepacks: Array<{
- id: number;
- title: string;
- uris: string[];
- repacker: string;
- fileSize: string | null;
- objectIds: string[];
- uploadDate: Date | string | null;
- downloadSourceId: number;
- createdAt: Date;
- updatedAt: Date;
- }> = [];
+ // Use a Set for O(1) lookups instead of O(n) with array.some()
+ const existingRepackTitles = new Set();
for await (const [, repack] of repacksSublevel.iterator()) {
- existingRepacks.push(repack);
+ existingRepackTitles.add(repack.title);
}
// Handle sources with missing fingerprints individually, don't delete all sources
@@ -77,9 +67,9 @@ const syncDownloadSources = async (
const source = downloadSourceSchema.parse(response.data);
const steamGames = await getSteamGames();
+ // O(1) lookup instead of O(n) - massive performance improvement
const repacks = source.downloads.filter(
- (download) =>
- !existingRepacks.some((repack) => repack.title === download.title)
+ (download) => !existingRepackTitles.has(download.title)
);
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
diff --git a/src/main/main.ts b/src/main/main.ts
index 5eecb101..e9b6187c 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -17,6 +17,7 @@ import {
Lock,
DeckyPlugin,
ResourceCache,
+ GameMatcherWorkerManager,
} from "@main/services";
export const loadState = async () => {
@@ -25,6 +26,9 @@ export const loadState = async () => {
ResourceCache.initialize();
await ResourceCache.updateResourcesOnStartup();
+ // Initialize game matcher worker thread
+ GameMatcherWorkerManager.initialize();
+
const userPreferences = await db.get(
levelKeys.userPreferences,
{
diff --git a/src/main/services/game-matcher-worker-manager.ts b/src/main/services/game-matcher-worker-manager.ts
new file mode 100644
index 00000000..b5d306c7
--- /dev/null
+++ b/src/main/services/game-matcher-worker-manager.ts
@@ -0,0 +1,145 @@
+import { Worker } from "worker_threads";
+import workerPath from "../workers/game-matcher-worker?modulePath";
+
+interface WorkerMessage {
+ id: string;
+ data: unknown;
+}
+
+interface WorkerResponse {
+ id: string;
+ success: boolean;
+ result?: unknown;
+ error?: string;
+}
+
+export type TitleHashMapping = Record;
+
+export type FormattedSteamGame = {
+ id: string;
+ name: string;
+ formattedName: string;
+};
+export type FormattedSteamGamesByLetter = Record;
+
+interface DownloadToMatch {
+ title: string;
+ uris: string[];
+ uploadDate: string;
+ fileSize: string;
+}
+
+interface MatchedDownload {
+ title: string;
+ uris: string[];
+ uploadDate: string;
+ fileSize: string;
+ objectIds: string[];
+ usedHashMatch: boolean;
+}
+
+interface MatchResponse {
+ matchedDownloads: MatchedDownload[];
+ stats: {
+ hashMatchCount: number;
+ fuzzyMatchCount: number;
+ noMatchCount: number;
+ };
+}
+
+export class GameMatcherWorkerManager {
+ private static worker: Worker | null = null;
+ private static messageId = 0;
+ private static pendingMessages = new Map<
+ string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ { resolve: (value: any) => void; reject: (error: Error) => void }
+ >();
+
+ public static initialize() {
+ if (this.worker) {
+ return;
+ }
+
+ try {
+ console.log(
+ "[GameMatcherWorker] Initializing worker with path:",
+ workerPath
+ );
+
+ this.worker = new Worker(workerPath);
+
+ this.worker.on("message", (response: WorkerResponse) => {
+ const pending = this.pendingMessages.get(response.id);
+ if (pending) {
+ if (response.success) {
+ pending.resolve(response.result);
+ } else {
+ pending.reject(new Error(response.error || "Unknown error"));
+ }
+ this.pendingMessages.delete(response.id);
+ }
+ });
+
+ this.worker.on("error", (error) => {
+ console.error("[GameMatcherWorker] Worker error:", error);
+ for (const [id, pending] of this.pendingMessages.entries()) {
+ pending.reject(error);
+ this.pendingMessages.delete(id);
+ }
+ });
+
+ this.worker.on("exit", (code) => {
+ if (code !== 0) {
+ console.error(
+ `[GameMatcherWorker] Worker stopped with exit code ${code}`
+ );
+ }
+ this.worker = null;
+ for (const [id, pending] of this.pendingMessages.entries()) {
+ pending.reject(new Error("Worker exited unexpectedly"));
+ this.pendingMessages.delete(id);
+ }
+ });
+
+ console.log("[GameMatcherWorker] Worker initialized successfully");
+ } catch (error) {
+ console.error("[GameMatcherWorker] Failed to initialize worker:", error);
+ throw error;
+ }
+ }
+
+ private static sendMessage(data: unknown): Promise {
+ if (!this.worker) {
+ return Promise.reject(new Error("Worker not initialized"));
+ }
+
+ const id = `msg_${++this.messageId}`;
+ const message: WorkerMessage = { id, data };
+
+ return new Promise((resolve, reject) => {
+ this.pendingMessages.set(id, { resolve, reject });
+ this.worker!.postMessage(message);
+ });
+ }
+
+ public static async matchDownloads(
+ downloads: DownloadToMatch[],
+ steamGames: FormattedSteamGamesByLetter,
+ titleHashMapping: TitleHashMapping
+ ): Promise {
+ return this.sendMessage({
+ downloads,
+ steamGames,
+ titleHashMapping,
+ });
+ }
+
+ public static terminate() {
+ if (this.worker) {
+ this.worker.terminate();
+ this.worker = null;
+ this.pendingMessages.clear();
+ }
+ }
+}
diff --git a/src/main/services/index.ts b/src/main/services/index.ts
index c98f09e1..0853859f 100644
--- a/src/main/services/index.ts
+++ b/src/main/services/index.ts
@@ -19,3 +19,4 @@ export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";
export * from "./resource-cache";
+export * from "./game-matcher-worker-manager";
diff --git a/src/main/workers/game-matcher-worker.ts b/src/main/workers/game-matcher-worker.ts
new file mode 100644
index 00000000..4930ada0
--- /dev/null
+++ b/src/main/workers/game-matcher-worker.ts
@@ -0,0 +1,158 @@
+import { parentPort } from "worker_threads";
+import crypto from "node:crypto";
+
+export type TitleHashMapping = Record;
+
+export type FormattedSteamGame = {
+ id: string;
+ name: string;
+ formattedName: string;
+};
+export type FormattedSteamGamesByLetter = Record;
+
+interface DownloadToMatch {
+ title: string;
+ uris: string[];
+ uploadDate: string;
+ fileSize: string;
+}
+
+interface MatchedDownload {
+ title: string;
+ uris: string[];
+ uploadDate: string;
+ fileSize: string;
+ objectIds: string[];
+ usedHashMatch: boolean;
+}
+
+interface MatchRequest {
+ downloads: DownloadToMatch[];
+ steamGames: FormattedSteamGamesByLetter;
+ titleHashMapping: TitleHashMapping;
+}
+
+interface MatchResponse {
+ matchedDownloads: MatchedDownload[];
+ stats: {
+ hashMatchCount: number;
+ fuzzyMatchCount: number;
+ noMatchCount: number;
+ };
+}
+
+const hashTitle = (title: string): string => {
+ return crypto.createHash("sha256").update(title).digest("hex");
+};
+
+const formatName = (name: string) => {
+ return name
+ .normalize("NFD")
+ .replaceAll(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replaceAll(/[^a-z0-9]/g, "");
+};
+
+const formatRepackName = (name: string) => {
+ return formatName(name.replace("[DL]", ""));
+};
+
+const matchDownloads = (request: MatchRequest): MatchResponse => {
+ const { downloads, steamGames, titleHashMapping } = request;
+ const matchedDownloads: MatchedDownload[] = [];
+
+ let hashMatchCount = 0;
+ let fuzzyMatchCount = 0;
+ let noMatchCount = 0;
+
+ for (const download of downloads) {
+ let objectIds: string[] = [];
+ let usedHashMatch = false;
+
+ const titleHash = hashTitle(download.title);
+ const steamIdsFromHash = titleHashMapping[titleHash];
+
+ if (steamIdsFromHash && steamIdsFromHash.length > 0) {
+ hashMatchCount++;
+ usedHashMatch = true;
+ objectIds = steamIdsFromHash.map(String);
+ }
+
+ if (!usedHashMatch) {
+ let gamesInSteam: FormattedSteamGame[] = [];
+ const formattedTitle = formatRepackName(download.title);
+
+ if (formattedTitle && formattedTitle.length > 0) {
+ const [firstLetter] = formattedTitle;
+ const games = steamGames[firstLetter] || [];
+
+ gamesInSteam = games.filter((game) =>
+ formattedTitle.startsWith(game.formattedName)
+ );
+
+ if (gamesInSteam.length === 0) {
+ gamesInSteam = games.filter(
+ (game) =>
+ formattedTitle.includes(game.formattedName) ||
+ game.formattedName.includes(formattedTitle)
+ );
+ }
+
+ if (gamesInSteam.length === 0) {
+ for (const letter of Object.keys(steamGames)) {
+ const letterGames = steamGames[letter] || [];
+ const matches = letterGames.filter(
+ (game) =>
+ formattedTitle.includes(game.formattedName) ||
+ game.formattedName.includes(formattedTitle)
+ );
+ if (matches.length > 0) {
+ gamesInSteam = matches;
+ break;
+ }
+ }
+ }
+
+ if (gamesInSteam.length > 0) {
+ fuzzyMatchCount++;
+ objectIds = gamesInSteam.map((game) => String(game.id));
+ } else {
+ noMatchCount++;
+ }
+ } else {
+ noMatchCount++;
+ }
+ }
+
+ matchedDownloads.push({
+ ...download,
+ objectIds,
+ usedHashMatch,
+ });
+ }
+
+ return {
+ matchedDownloads,
+ stats: {
+ hashMatchCount,
+ fuzzyMatchCount,
+ noMatchCount,
+ },
+ };
+};
+
+// Message handler
+if (parentPort) {
+ parentPort.on("message", (message: { id: string; data: MatchRequest }) => {
+ try {
+ const result = matchDownloads(message.data);
+ parentPort!.postMessage({ id: message.id, success: true, result });
+ } catch (error) {
+ parentPort!.postMessage({
+ id: message.id,
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ });
+}
From 48ce9a247640347f6667546ad8f60dcc75feaa08 Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Tue, 21 Oct 2025 04:18:11 +0100
Subject: [PATCH 02/81] feat: using api download sources
---
.../download-sources/add-download-source.ts | 99 ++----
.../check-download-source-exists.ts | 17 -
.../delete-all-download-sources.ts | 13 -
.../delete-download-source.ts | 28 --
.../get-download-sources-list.ts | 19 --
.../download-sources/get-download-sources.ts | 4 +-
src/main/events/download-sources/helpers.ts | 314 ------------------
.../remove-download-source.ts | 17 +-
.../sync-download-sources-from-api.ts | 19 --
.../download-sources/sync-download-sources.ts | 113 +------
.../update-missing-fingerprints.ts | 67 ----
.../validate-download-source.ts | 32 --
src/main/events/index.ts | 7 -
src/main/events/repacks/get-all-repacks.ts | 16 -
src/main/level/sublevels/download-sources.ts | 14 +-
src/main/level/sublevels/index.ts | 1 -
src/main/level/sublevels/keys.ts | 1 -
src/main/level/sublevels/repacks.ts | 22 --
src/main/main.ts | 8 -
.../services/game-matcher-worker-manager.ts | 145 --------
src/main/services/hydra-api.ts | 5 -
src/main/services/index.ts | 2 -
src/main/services/resource-cache.ts | 157 ---------
src/main/workers/game-matcher-worker.ts | 158 ---------
src/preload/index.ts | 12 -
src/renderer/src/app.tsx | 34 --
.../src/components/game-card/game-card.tsx | 40 +--
.../game-details/game-details.context.tsx | 61 ++--
src/renderer/src/declaration.d.ts | 22 +-
.../src/features/download-sources-slice.ts | 21 --
src/renderer/src/features/index.ts | 2 -
src/renderer/src/hooks/index.ts | 1 -
src/renderer/src/hooks/use-catalogue.ts | 12 +-
src/renderer/src/hooks/use-repacks.ts | 26 --
.../src/pages/catalogue/catalogue.tsx | 73 ++--
.../src/pages/catalogue/game-item.tsx | 14 +-
.../src/pages/game-details/game-reviews.tsx | 4 -
.../game-details/modals/repacks-modal.tsx | 27 +-
src/renderer/src/pages/home/home.tsx | 18 +-
.../settings/add-download-source-modal.scss | 7 +
.../settings/add-download-source-modal.tsx | 128 ++-----
.../settings/settings-download-sources.tsx | 102 +++---
src/renderer/src/store.ts | 4 -
src/shared/constants.ts | 6 +-
src/types/index.ts | 28 +-
45 files changed, 295 insertions(+), 1625 deletions(-)
delete mode 100644 src/main/events/download-sources/check-download-source-exists.ts
delete mode 100644 src/main/events/download-sources/delete-all-download-sources.ts
delete mode 100644 src/main/events/download-sources/delete-download-source.ts
delete mode 100644 src/main/events/download-sources/get-download-sources-list.ts
delete mode 100644 src/main/events/download-sources/helpers.ts
delete mode 100644 src/main/events/download-sources/sync-download-sources-from-api.ts
delete mode 100644 src/main/events/download-sources/update-missing-fingerprints.ts
delete mode 100644 src/main/events/download-sources/validate-download-source.ts
delete mode 100644 src/main/events/repacks/get-all-repacks.ts
delete mode 100644 src/main/level/sublevels/repacks.ts
delete mode 100644 src/main/services/game-matcher-worker-manager.ts
delete mode 100644 src/main/services/resource-cache.ts
delete mode 100644 src/main/workers/game-matcher-worker.ts
delete mode 100644 src/renderer/src/features/download-sources-slice.ts
delete mode 100644 src/renderer/src/hooks/use-repacks.ts
diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts
index e51cae3e..45bcd27c 100644
--- a/src/main/events/download-sources/add-download-source.ts
+++ b/src/main/events/download-sources/add-download-source.ts
@@ -1,76 +1,45 @@
import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
-import { HydraApi, logger } from "@main/services";
-import { importDownloadSourceToLocal } from "./helpers";
+import { HydraApi } from "@main/services/hydra-api";
+import { downloadSourcesSublevel } from "@main/level";
+import type { DownloadSource } from "@types";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
- const result = await importDownloadSourceToLocal(url, true);
- if (!result) {
- throw new Error("Failed to import download source");
- }
-
- // Verify that repacks were actually written to the database (read-after-write)
- // This ensures all async operations are complete before proceeding
- let repackCount = 0;
- for await (const [, repack] of repacksSublevel.iterator()) {
- if (repack.downloadSourceId === result.id) {
- repackCount++;
- }
- }
-
- await HydraApi.post("/profile/download-sources", {
- urls: [url],
- });
-
- const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
- "/download-sources",
- {
- objectIds: result.objectIds,
- },
- { needsAuth: false }
- );
-
- // Update the source with fingerprint
- const updatedSource = await downloadSourcesSublevel.get(`${result.id}`);
- if (updatedSource) {
- await downloadSourcesSublevel.put(`${result.id}`, {
- ...updatedSource,
- fingerprint,
- updatedAt: new Date(),
- });
- }
-
- // Final verification: ensure the source with fingerprint is persisted
- const finalSource = await downloadSourcesSublevel.get(`${result.id}`);
- if (!finalSource || !finalSource.fingerprint) {
- throw new Error("Failed to persist download source with fingerprint");
- }
-
- // Verify repacks still exist after fingerprint update
- let finalRepackCount = 0;
- for await (const [, repack] of repacksSublevel.iterator()) {
- if (repack.downloadSourceId === result.id) {
- finalRepackCount++;
- }
- }
-
- if (finalRepackCount !== repackCount) {
- logger.warn(
- `Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}`
+ try {
+ const downloadSource = await HydraApi.post(
+ "/download-sources",
+ {
+ url,
+ },
+ { needsAuth: false }
);
- } else {
- logger.info(
- `Final verification passed: ${finalRepackCount} repacks confirmed`
- );
- }
- return {
- ...result,
- fingerprint,
- };
+ if (HydraApi.isLoggedIn()) {
+ try {
+ await HydraApi.post("/profile/download-sources", {
+ urls: [url],
+ });
+ } catch (error) {
+ console.error("Failed to add download source to profile:", error);
+ }
+ }
+
+ const downloadSourceForStorage = {
+ ...downloadSource,
+ fingerprint: downloadSource.fingerprint || "",
+ };
+ await downloadSourcesSublevel.put(
+ downloadSource.id,
+ downloadSourceForStorage
+ );
+
+ return downloadSource;
+ } catch (error) {
+ console.error("Failed to add download source:", error);
+ throw error;
+ }
};
registerEvent("addDownloadSource", addDownloadSource);
diff --git a/src/main/events/download-sources/check-download-source-exists.ts b/src/main/events/download-sources/check-download-source-exists.ts
deleted file mode 100644
index 36dd88ce..00000000
--- a/src/main/events/download-sources/check-download-source-exists.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel } from "@main/level";
-
-const checkDownloadSourceExists = async (
- _event: Electron.IpcMainInvokeEvent,
- url: string
-): Promise => {
- for await (const [, source] of downloadSourcesSublevel.iterator()) {
- if (source.url === url) {
- return true;
- }
- }
-
- return false;
-};
-
-registerEvent("checkDownloadSourceExists", checkDownloadSourceExists);
diff --git a/src/main/events/download-sources/delete-all-download-sources.ts b/src/main/events/download-sources/delete-all-download-sources.ts
deleted file mode 100644
index cbf3958f..00000000
--- a/src/main/events/download-sources/delete-all-download-sources.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
-import { invalidateIdCaches } from "./helpers";
-
-const deleteAllDownloadSources = async (
- _event: Electron.IpcMainInvokeEvent
-) => {
- await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]);
-
- invalidateIdCaches();
-};
-
-registerEvent("deleteAllDownloadSources", deleteAllDownloadSources);
diff --git a/src/main/events/download-sources/delete-download-source.ts b/src/main/events/download-sources/delete-download-source.ts
deleted file mode 100644
index 5322b96c..00000000
--- a/src/main/events/download-sources/delete-download-source.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
-import { invalidateIdCaches } from "./helpers";
-
-const deleteDownloadSource = async (
- _event: Electron.IpcMainInvokeEvent,
- id: number
-) => {
- const repacksToDelete: string[] = [];
-
- for await (const [key, repack] of repacksSublevel.iterator()) {
- if (repack.downloadSourceId === id) {
- repacksToDelete.push(key);
- }
- }
-
- const batch = repacksSublevel.batch();
- for (const key of repacksToDelete) {
- batch.del(key);
- }
- await batch.write();
-
- await downloadSourcesSublevel.del(`${id}`);
-
- invalidateIdCaches();
-};
-
-registerEvent("deleteDownloadSource", deleteDownloadSource);
diff --git a/src/main/events/download-sources/get-download-sources-list.ts b/src/main/events/download-sources/get-download-sources-list.ts
deleted file mode 100644
index db26ad01..00000000
--- a/src/main/events/download-sources/get-download-sources-list.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel, DownloadSource } from "@main/level";
-
-const getDownloadSourcesList = async (_event: Electron.IpcMainInvokeEvent) => {
- const sources: DownloadSource[] = [];
-
- for await (const [, source] of downloadSourcesSublevel.iterator()) {
- sources.push(source);
- }
-
- // Sort by createdAt descending
- sources.sort(
- (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
- );
-
- return sources;
-};
-
-registerEvent("getDownloadSourcesList", getDownloadSourcesList);
diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts
index bbebd06c..cf7cd4d7 100644
--- a/src/main/events/download-sources/get-download-sources.ts
+++ b/src/main/events/download-sources/get-download-sources.ts
@@ -1,8 +1,8 @@
-import { HydraApi } from "@main/services";
+import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
- return HydraApi.get("/profile/download-sources");
+ return downloadSourcesSublevel.values().all();
};
registerEvent("getDownloadSources", getDownloadSources);
diff --git a/src/main/events/download-sources/helpers.ts b/src/main/events/download-sources/helpers.ts
deleted file mode 100644
index edd3878e..00000000
--- a/src/main/events/download-sources/helpers.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-import axios from "axios";
-import { z } from "zod";
-import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
-import { DownloadSourceStatus } from "@shared";
-import crypto from "node:crypto";
-import { logger, ResourceCache } from "@main/services";
-
-export const downloadSourceSchema = z.object({
- name: z.string().max(255),
- downloads: z.array(
- z.object({
- title: z.string().max(255),
- uris: z.array(z.string()),
- uploadDate: z.string().max(255),
- fileSize: z.string().max(255),
- })
- ),
-});
-
-export type TitleHashMapping = Record;
-
-let titleHashMappingCache: TitleHashMapping | null = null;
-
-export const getTitleHashMapping = async (): Promise => {
- if (titleHashMappingCache) {
- return titleHashMappingCache;
- }
-
- try {
- const cached =
- ResourceCache.getCachedData("sources-manifest");
- if (cached) {
- titleHashMappingCache = cached;
- return cached;
- }
-
- const fetched = await ResourceCache.fetchAndCache(
- "sources-manifest",
- "https://cdn.losbroxas.org/sources-manifest.json",
- 10000
- );
- titleHashMappingCache = fetched;
- return fetched;
- } catch (error) {
- logger.error("Failed to fetch title hash mapping:", error);
- return {} as TitleHashMapping;
- }
-};
-
-export const hashTitle = (title: string): string => {
- return crypto.createHash("sha256").update(title).digest("hex");
-};
-
-export type SteamGamesByLetter = Record;
-export type FormattedSteamGame = {
- id: string;
- name: string;
- formattedName: string;
-};
-export type FormattedSteamGamesByLetter = Record;
-
-export const formatName = (name: string) => {
- return name
- .normalize("NFD")
- .replaceAll(/[\u0300-\u036f]/g, "")
- .toLowerCase()
- .replaceAll(/[^a-z0-9]/g, "");
-};
-
-export const formatRepackName = (name: string) => {
- return formatName(name.replace("[DL]", ""));
-};
-
-interface DownloadSource {
- id: number;
- url: string;
- name: string;
- etag: string | null;
- status: number;
- downloadCount: number;
- objectIds: string[];
- fingerprint?: string;
- createdAt: Date;
- updatedAt: Date;
-}
-
-const getDownloadSourcesMap = async (): Promise<
- Map
-> => {
- const map = new Map();
- for await (const [key, source] of downloadSourcesSublevel.iterator()) {
- map.set(key, source);
- }
-
- return map;
-};
-
-export const checkUrlExists = async (url: string): Promise => {
- const sources = await getDownloadSourcesMap();
- for (const source of sources.values()) {
- if (source.url === url) {
- return true;
- }
- }
- return false;
-};
-
-let steamGamesFormattedCache: FormattedSteamGamesByLetter | null = null;
-
-export const getSteamGames = async (): Promise => {
- if (steamGamesFormattedCache) {
- return steamGamesFormattedCache;
- }
-
- let steamGames: SteamGamesByLetter;
-
- const cached = ResourceCache.getCachedData(
- "steam-games-by-letter"
- );
- if (cached) {
- steamGames = cached;
- } else {
- steamGames = await ResourceCache.fetchAndCache(
- "steam-games-by-letter",
- `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
- );
- }
-
- const formattedData: FormattedSteamGamesByLetter = {};
- for (const [letter, games] of Object.entries(steamGames)) {
- formattedData[letter] = games.map((game) => ({
- ...game,
- formattedName: formatName(game.name),
- }));
- }
-
- steamGamesFormattedCache = formattedData;
- return formattedData;
-};
-
-export type SublevelIterator = AsyncIterable<[string, { id: number }]>;
-
-export interface SublevelWithId {
- iterator: () => SublevelIterator;
-}
-
-let maxRepackId: number | null = null;
-let maxDownloadSourceId: number | null = null;
-
-export const getNextId = async (sublevel: SublevelWithId): Promise => {
- const isRepackSublevel = sublevel === repacksSublevel;
- const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel;
-
- if (isRepackSublevel && maxRepackId !== null) {
- return ++maxRepackId;
- }
-
- if (isDownloadSourceSublevel && maxDownloadSourceId !== null) {
- return ++maxDownloadSourceId;
- }
-
- let maxId = 0;
- for await (const [, value] of sublevel.iterator()) {
- if (value.id > maxId) {
- maxId = value.id;
- }
- }
-
- if (isRepackSublevel) {
- maxRepackId = maxId;
- } else if (isDownloadSourceSublevel) {
- maxDownloadSourceId = maxId;
- }
-
- return maxId + 1;
-};
-
-export const invalidateIdCaches = () => {
- maxRepackId = null;
- maxDownloadSourceId = null;
-};
-
-export const addNewDownloads = async (
- downloadSource: { id: number; name: string },
- downloads: z.infer["downloads"],
- steamGames: FormattedSteamGamesByLetter
-) => {
- const now = new Date();
- const objectIdsOnSource = new Set();
-
- let nextRepackId = await getNextId(repacksSublevel);
-
- const batch = repacksSublevel.batch();
-
- // Get title hash mapping and perform matching in worker thread
- const titleHashMapping = await getTitleHashMapping();
-
- const { GameMatcherWorkerManager } = await import("@main/services");
- const matchResult = await GameMatcherWorkerManager.matchDownloads(
- downloads,
- steamGames,
- titleHashMapping
- );
-
- // Process matched results and write to database
- for (const matchedDownload of matchResult.matchedDownloads) {
- for (const id of matchedDownload.objectIds) {
- objectIdsOnSource.add(id);
- }
-
- const repack = {
- id: nextRepackId++,
- objectIds: matchedDownload.objectIds,
- title: matchedDownload.title,
- uris: matchedDownload.uris,
- fileSize: matchedDownload.fileSize,
- repacker: downloadSource.name,
- uploadDate: matchedDownload.uploadDate,
- downloadSourceId: downloadSource.id,
- createdAt: now,
- updatedAt: now,
- };
-
- batch.put(`${repack.id}`, repack);
- }
-
- await batch.write();
-
- logger.info(
- `Matching stats for ${downloadSource.name}: Hash=${matchResult.stats.hashMatchCount}, Fuzzy=${matchResult.stats.fuzzyMatchCount}, None=${matchResult.stats.noMatchCount}`
- );
-
- const existingSource = await downloadSourcesSublevel.get(
- `${downloadSource.id}`
- );
- if (existingSource) {
- await downloadSourcesSublevel.put(`${downloadSource.id}`, {
- ...existingSource,
- objectIds: Array.from(objectIdsOnSource),
- });
- }
-
- return Array.from(objectIdsOnSource);
-};
-
-export const importDownloadSourceToLocal = async (
- url: string,
- throwOnDuplicate = false
-) => {
- const urlExists = await checkUrlExists(url);
- if (urlExists) {
- if (throwOnDuplicate) {
- throw new Error("Download source with this URL already exists");
- }
- return null;
- }
-
- const response = await axios.get>(url);
-
- const steamGames = await getSteamGames();
-
- const now = new Date();
-
- const nextId = await getNextId(downloadSourcesSublevel);
-
- const downloadSource = {
- id: nextId,
- url,
- name: response.data.name,
- etag: response.headers["etag"] || null,
- status: DownloadSourceStatus.UpToDate,
- downloadCount: response.data.downloads.length,
- objectIds: [],
- createdAt: now,
- updatedAt: now,
- };
-
- await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource);
-
- const objectIds = await addNewDownloads(
- downloadSource,
- response.data.downloads,
- steamGames
- );
-
- // Invalidate ID caches after creating new repacks to prevent ID collisions
- invalidateIdCaches();
-
- return {
- ...downloadSource,
- objectIds,
- };
-};
-
-export const updateDownloadSourcePreservingTimestamp = async (
- existingSource: DownloadSource,
- url: string
-) => {
- const response = await axios.get>(url);
-
- const updatedSource = {
- ...existingSource,
- name: response.data.name,
- etag: response.headers["etag"] || null,
- status: DownloadSourceStatus.UpToDate,
- downloadCount: response.data.downloads.length,
- updatedAt: new Date(),
- // Preserve the original createdAt timestamp
- };
-
- await downloadSourcesSublevel.put(`${existingSource.id}`, updatedSource);
-
- return updatedSource;
-};
diff --git a/src/main/events/download-sources/remove-download-source.ts b/src/main/events/download-sources/remove-download-source.ts
index bcc66998..8efe0072 100644
--- a/src/main/events/download-sources/remove-download-source.ts
+++ b/src/main/events/download-sources/remove-download-source.ts
@@ -1,18 +1,27 @@
import { HydraApi } from "@main/services";
+import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
- url?: string,
- removeAll = false
+ removeAll = false,
+ downloadSourceId?: string
) => {
const params = new URLSearchParams({
all: removeAll.toString(),
});
- if (url) params.set("url", url);
+ if (downloadSourceId) params.set("downloadSourceId", downloadSourceId);
- return HydraApi.delete(`/profile/download-sources?${params.toString()}`);
+ if (HydraApi.isLoggedIn()) {
+ void HydraApi.delete(`/profile/download-sources?${params.toString()}`);
+ }
+
+ if (removeAll) {
+ await downloadSourcesSublevel.clear();
+ } else if (downloadSourceId) {
+ await downloadSourcesSublevel.del(downloadSourceId);
+ }
};
registerEvent("removeDownloadSource", removeDownloadSource);
diff --git a/src/main/events/download-sources/sync-download-sources-from-api.ts b/src/main/events/download-sources/sync-download-sources-from-api.ts
deleted file mode 100644
index 3cac8819..00000000
--- a/src/main/events/download-sources/sync-download-sources-from-api.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { HydraApi, logger } from "@main/services";
-import { importDownloadSourceToLocal, checkUrlExists } from "./helpers";
-
-export const syncDownloadSourcesFromApi = async () => {
- try {
- const apiSources = await HydraApi.get<
- { url: string; createdAt: string; updatedAt: string }[]
- >("/profile/download-sources");
-
- for (const apiSource of apiSources) {
- const exists = await checkUrlExists(apiSource.url);
- if (!exists) {
- await importDownloadSourceToLocal(apiSource.url, false);
- }
- }
- } catch (error) {
- logger.error("Failed to sync download sources from API:", error);
- }
-};
diff --git a/src/main/events/download-sources/sync-download-sources.ts b/src/main/events/download-sources/sync-download-sources.ts
index 3bb78f22..987ad1c1 100644
--- a/src/main/events/download-sources/sync-download-sources.ts
+++ b/src/main/events/download-sources/sync-download-sources.ts
@@ -1,105 +1,24 @@
+import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
-import axios, { AxiosError } from "axios";
-import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
-import { DownloadSourceStatus } from "@shared";
-import {
- invalidateIdCaches,
- downloadSourceSchema,
- getSteamGames,
- addNewDownloads,
-} from "./helpers";
+import { downloadSourcesSublevel } from "@main/level";
+import type { DownloadSource } from "@types";
-const syncDownloadSources = async (
- _event: Electron.IpcMainInvokeEvent
-): Promise => {
- let newRepacksCount = 0;
+const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
+ const downloadSources = await downloadSourcesSublevel.values().all();
- try {
- const downloadSources: Array<{
- id: number;
- url: string;
- name: string;
- etag: string | null;
- status: number;
- downloadCount: number;
- objectIds: string[];
- fingerprint?: string;
- createdAt: Date;
- updatedAt: Date;
- }> = [];
- for await (const [, source] of downloadSourcesSublevel.iterator()) {
- downloadSources.push(source);
- }
+ const response = await HydraApi.post(
+ "/download-sources/sync",
+ {
+ ids: downloadSources.map((downloadSource) => downloadSource.id),
+ },
+ { needsAuth: false }
+ );
- // Use a Set for O(1) lookups instead of O(n) with array.some()
- const existingRepackTitles = new Set();
- for await (const [, repack] of repacksSublevel.iterator()) {
- existingRepackTitles.add(repack.title);
- }
-
- // Handle sources with missing fingerprints individually, don't delete all sources
- const sourcesWithFingerprints = downloadSources.filter(
- (source) => source.fingerprint
- );
- const sourcesWithoutFingerprints = downloadSources.filter(
- (source) => !source.fingerprint
- );
-
- // For sources without fingerprints, just continue with normal sync
- // They will get fingerprints updated later by updateMissingFingerprints
- const allSourcesToSync = [
- ...sourcesWithFingerprints,
- ...sourcesWithoutFingerprints,
- ];
-
- for (const downloadSource of allSourcesToSync) {
- const headers: Record = {};
-
- if (downloadSource.etag) {
- headers["If-None-Match"] = downloadSource.etag;
- }
-
- try {
- const response = await axios.get(downloadSource.url, {
- headers,
- });
-
- const source = downloadSourceSchema.parse(response.data);
- const steamGames = await getSteamGames();
-
- // O(1) lookup instead of O(n) - massive performance improvement
- const repacks = source.downloads.filter(
- (download) => !existingRepackTitles.has(download.title)
- );
-
- await downloadSourcesSublevel.put(`${downloadSource.id}`, {
- ...downloadSource,
- etag: response.headers["etag"] || null,
- downloadCount: source.downloads.length,
- status: DownloadSourceStatus.UpToDate,
- });
-
- await addNewDownloads(downloadSource, repacks, steamGames);
-
- newRepacksCount += repacks.length;
- } catch (err: unknown) {
- const isNotModified = (err as AxiosError).response?.status === 304;
-
- await downloadSourcesSublevel.put(`${downloadSource.id}`, {
- ...downloadSource,
- status: isNotModified
- ? DownloadSourceStatus.UpToDate
- : DownloadSourceStatus.Errored,
- });
- }
- }
-
- invalidateIdCaches();
-
- return newRepacksCount;
- } catch (err) {
- return -1;
+ for (const downloadSource of response) {
+ await downloadSourcesSublevel.put(downloadSource.id, downloadSource);
}
+
+ return response;
};
registerEvent("syncDownloadSources", syncDownloadSources);
diff --git a/src/main/events/download-sources/update-missing-fingerprints.ts b/src/main/events/download-sources/update-missing-fingerprints.ts
deleted file mode 100644
index 7fd43c63..00000000
--- a/src/main/events/download-sources/update-missing-fingerprints.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel } from "@main/level";
-import { HydraApi, logger } from "@main/services";
-
-const updateMissingFingerprints = async (
- _event: Electron.IpcMainInvokeEvent
-): Promise => {
- const sourcesNeedingFingerprints: Array<{
- id: number;
- objectIds: string[];
- }> = [];
-
- for await (const [, source] of downloadSourcesSublevel.iterator()) {
- if (
- !source.fingerprint &&
- source.objectIds &&
- source.objectIds.length > 0
- ) {
- sourcesNeedingFingerprints.push({
- id: source.id,
- objectIds: source.objectIds,
- });
- }
- }
-
- if (sourcesNeedingFingerprints.length === 0) {
- return 0;
- }
-
- logger.info(
- `Updating fingerprints for ${sourcesNeedingFingerprints.length} sources`
- );
-
- await Promise.all(
- sourcesNeedingFingerprints.map(async (source) => {
- try {
- const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
- "/download-sources",
- {
- objectIds: source.objectIds,
- },
- { needsAuth: false }
- );
-
- const existingSource = await downloadSourcesSublevel.get(
- `${source.id}`
- );
- if (existingSource) {
- await downloadSourcesSublevel.put(`${source.id}`, {
- ...existingSource,
- fingerprint,
- updatedAt: new Date(),
- });
- }
- } catch (error) {
- logger.error(
- `Failed to update fingerprint for source ${source.id}:`,
- error
- );
- }
- })
- );
-
- return sourcesNeedingFingerprints.length;
-};
-
-registerEvent("updateMissingFingerprints", updateMissingFingerprints);
diff --git a/src/main/events/download-sources/validate-download-source.ts b/src/main/events/download-sources/validate-download-source.ts
deleted file mode 100644
index 2bc86df7..00000000
--- a/src/main/events/download-sources/validate-download-source.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { registerEvent } from "../register-event";
-import axios from "axios";
-import { z } from "zod";
-
-const downloadSourceSchema = z.object({
- name: z.string().max(255),
- downloads: z.array(
- z.object({
- title: z.string().max(255),
- uris: z.array(z.string()),
- uploadDate: z.string().max(255),
- fileSize: z.string().max(255),
- })
- ),
-});
-
-const validateDownloadSource = async (
- _event: Electron.IpcMainInvokeEvent,
- url: string
-) => {
- const response = await axios.get>(url);
-
- const { name } = downloadSourceSchema.parse(response.data);
-
- return {
- name,
- etag: response.headers["etag"] || null,
- downloadCount: response.data.downloads.length,
- };
-};
-
-registerEvent("validateDownloadSource", validateDownloadSource);
diff --git a/src/main/events/index.ts b/src/main/events/index.ts
index 8d21aa11..0ab5499a 100644
--- a/src/main/events/index.ts
+++ b/src/main/events/index.ts
@@ -63,14 +63,7 @@ import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-torbox";
import "./download-sources/add-download-source";
-import "./download-sources/update-missing-fingerprints";
-import "./download-sources/delete-download-source";
-import "./download-sources/delete-all-download-sources";
-import "./download-sources/validate-download-source";
import "./download-sources/sync-download-sources";
-import "./download-sources/get-download-sources-list";
-import "./download-sources/check-download-source-exists";
-import "./repacks/get-all-repacks";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
diff --git a/src/main/events/repacks/get-all-repacks.ts b/src/main/events/repacks/get-all-repacks.ts
deleted file mode 100644
index 6eb83a39..00000000
--- a/src/main/events/repacks/get-all-repacks.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { registerEvent } from "../register-event";
-import { repacksSublevel, GameRepack } from "@main/level";
-
-const getAllRepacks = async (_event: Electron.IpcMainInvokeEvent) => {
- const repacks: GameRepack[] = [];
-
- for await (const [, repack] of repacksSublevel.iterator()) {
- if (Array.isArray(repack.objectIds)) {
- repacks.push(repack);
- }
- }
-
- return repacks;
-};
-
-registerEvent("getAllRepacks", getAllRepacks);
diff --git a/src/main/level/sublevels/download-sources.ts b/src/main/level/sublevels/download-sources.ts
index 59104e3c..b6cdad0b 100644
--- a/src/main/level/sublevels/download-sources.ts
+++ b/src/main/level/sublevels/download-sources.ts
@@ -1,18 +1,6 @@
import { db } from "../level";
import { levelKeys } from "./keys";
-
-export interface DownloadSource {
- id: number;
- name: string;
- url: string;
- status: number;
- objectIds: string[];
- downloadCount: number;
- fingerprint?: string;
- etag: string | null;
- createdAt: Date;
- updatedAt: Date;
-}
+import type { DownloadSource } from "@types";
export const downloadSourcesSublevel = db.sublevel(
levelKeys.downloadSources,
diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts
index 7224fc64..3619ae26 100644
--- a/src/main/level/sublevels/index.ts
+++ b/src/main/level/sublevels/index.ts
@@ -7,4 +7,3 @@ export * from "./game-achievements";
export * from "./keys";
export * from "./themes";
export * from "./download-sources";
-export * from "./repacks";
diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts
index 6faacd52..a28690b2 100644
--- a/src/main/level/sublevels/keys.ts
+++ b/src/main/level/sublevels/keys.ts
@@ -18,5 +18,4 @@ export const levelKeys = {
screenState: "screenState",
rpcPassword: "rpcPassword",
downloadSources: "downloadSources",
- repacks: "repacks",
};
diff --git a/src/main/level/sublevels/repacks.ts b/src/main/level/sublevels/repacks.ts
deleted file mode 100644
index 6257665b..00000000
--- a/src/main/level/sublevels/repacks.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { db } from "../level";
-import { levelKeys } from "./keys";
-
-export interface GameRepack {
- id: number;
- title: string;
- uris: string[];
- repacker: string;
- fileSize: string | null;
- objectIds: string[];
- uploadDate: Date | string | null;
- downloadSourceId: number;
- createdAt: Date;
- updatedAt: Date;
-}
-
-export const repacksSublevel = db.sublevel(
- levelKeys.repacks,
- {
- valueEncoding: "json",
- }
-);
diff --git a/src/main/main.ts b/src/main/main.ts
index e9b6187c..617dd135 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -16,19 +16,11 @@ import {
Ludusavi,
Lock,
DeckyPlugin,
- ResourceCache,
- GameMatcherWorkerManager,
} from "@main/services";
export const loadState = async () => {
await Lock.acquireLock();
- ResourceCache.initialize();
- await ResourceCache.updateResourcesOnStartup();
-
- // Initialize game matcher worker thread
- GameMatcherWorkerManager.initialize();
-
const userPreferences = await db.get(
levelKeys.userPreferences,
{
diff --git a/src/main/services/game-matcher-worker-manager.ts b/src/main/services/game-matcher-worker-manager.ts
deleted file mode 100644
index b5d306c7..00000000
--- a/src/main/services/game-matcher-worker-manager.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { Worker } from "worker_threads";
-import workerPath from "../workers/game-matcher-worker?modulePath";
-
-interface WorkerMessage {
- id: string;
- data: unknown;
-}
-
-interface WorkerResponse {
- id: string;
- success: boolean;
- result?: unknown;
- error?: string;
-}
-
-export type TitleHashMapping = Record;
-
-export type FormattedSteamGame = {
- id: string;
- name: string;
- formattedName: string;
-};
-export type FormattedSteamGamesByLetter = Record;
-
-interface DownloadToMatch {
- title: string;
- uris: string[];
- uploadDate: string;
- fileSize: string;
-}
-
-interface MatchedDownload {
- title: string;
- uris: string[];
- uploadDate: string;
- fileSize: string;
- objectIds: string[];
- usedHashMatch: boolean;
-}
-
-interface MatchResponse {
- matchedDownloads: MatchedDownload[];
- stats: {
- hashMatchCount: number;
- fuzzyMatchCount: number;
- noMatchCount: number;
- };
-}
-
-export class GameMatcherWorkerManager {
- private static worker: Worker | null = null;
- private static messageId = 0;
- private static pendingMessages = new Map<
- string,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- { resolve: (value: any) => void; reject: (error: Error) => void }
- >();
-
- public static initialize() {
- if (this.worker) {
- return;
- }
-
- try {
- console.log(
- "[GameMatcherWorker] Initializing worker with path:",
- workerPath
- );
-
- this.worker = new Worker(workerPath);
-
- this.worker.on("message", (response: WorkerResponse) => {
- const pending = this.pendingMessages.get(response.id);
- if (pending) {
- if (response.success) {
- pending.resolve(response.result);
- } else {
- pending.reject(new Error(response.error || "Unknown error"));
- }
- this.pendingMessages.delete(response.id);
- }
- });
-
- this.worker.on("error", (error) => {
- console.error("[GameMatcherWorker] Worker error:", error);
- for (const [id, pending] of this.pendingMessages.entries()) {
- pending.reject(error);
- this.pendingMessages.delete(id);
- }
- });
-
- this.worker.on("exit", (code) => {
- if (code !== 0) {
- console.error(
- `[GameMatcherWorker] Worker stopped with exit code ${code}`
- );
- }
- this.worker = null;
- for (const [id, pending] of this.pendingMessages.entries()) {
- pending.reject(new Error("Worker exited unexpectedly"));
- this.pendingMessages.delete(id);
- }
- });
-
- console.log("[GameMatcherWorker] Worker initialized successfully");
- } catch (error) {
- console.error("[GameMatcherWorker] Failed to initialize worker:", error);
- throw error;
- }
- }
-
- private static sendMessage(data: unknown): Promise {
- if (!this.worker) {
- return Promise.reject(new Error("Worker not initialized"));
- }
-
- const id = `msg_${++this.messageId}`;
- const message: WorkerMessage = { id, data };
-
- return new Promise((resolve, reject) => {
- this.pendingMessages.set(id, { resolve, reject });
- this.worker!.postMessage(message);
- });
- }
-
- public static async matchDownloads(
- downloads: DownloadToMatch[],
- steamGames: FormattedSteamGamesByLetter,
- titleHashMapping: TitleHashMapping
- ): Promise {
- return this.sendMessage({
- downloads,
- steamGames,
- titleHashMapping,
- });
- }
-
- public static terminate() {
- if (this.worker) {
- this.worker.terminate();
- this.worker = null;
- this.pendingMessages.clear();
- }
- }
-}
diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts
index dd26e6f0..ffc5756c 100644
--- a/src/main/services/hydra-api.ts
+++ b/src/main/services/hydra-api.ts
@@ -105,11 +105,6 @@ export class HydraApi {
// WSClient.close();
// WSClient.connect();
-
- const { syncDownloadSourcesFromApi } = await import(
- "../events/download-sources/sync-download-sources-from-api"
- );
- syncDownloadSourcesFromApi();
}
}
diff --git a/src/main/services/index.ts b/src/main/services/index.ts
index 0853859f..88b39d1b 100644
--- a/src/main/services/index.ts
+++ b/src/main/services/index.ts
@@ -18,5 +18,3 @@ export * from "./library-sync";
export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";
-export * from "./resource-cache";
-export * from "./game-matcher-worker-manager";
diff --git a/src/main/services/resource-cache.ts b/src/main/services/resource-cache.ts
deleted file mode 100644
index c59f873d..00000000
--- a/src/main/services/resource-cache.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { app } from "electron";
-import axios from "axios";
-import fs from "node:fs";
-import path from "node:path";
-import { logger } from "./logger";
-
-interface CachedResource {
- data: T;
- etag: string | null;
-}
-
-export class ResourceCache {
- private static cacheDir: string;
-
- static initialize() {
- this.cacheDir = path.join(app.getPath("userData"), "resource-cache");
-
- if (!fs.existsSync(this.cacheDir)) {
- fs.mkdirSync(this.cacheDir, { recursive: true });
- }
- }
-
- private static getCacheFilePath(resourceName: string): string {
- return path.join(this.cacheDir, `${resourceName}.json`);
- }
-
- private static getEtagFilePath(resourceName: string): string {
- return path.join(this.cacheDir, `${resourceName}.etag`);
- }
-
- private static readCachedResource(
- resourceName: string
- ): CachedResource | null {
- const dataPath = this.getCacheFilePath(resourceName);
- const etagPath = this.getEtagFilePath(resourceName);
-
- if (!fs.existsSync(dataPath)) {
- return null;
- }
-
- try {
- const data = JSON.parse(fs.readFileSync(dataPath, "utf-8")) as T;
- const etag = fs.existsSync(etagPath)
- ? fs.readFileSync(etagPath, "utf-8")
- : null;
-
- return { data, etag };
- } catch (error) {
- logger.error(`Failed to read cached resource ${resourceName}:`, error);
- return null;
- }
- }
-
- private static writeCachedResource(
- resourceName: string,
- data: T,
- etag: string | null
- ): void {
- const dataPath = this.getCacheFilePath(resourceName);
- const etagPath = this.getEtagFilePath(resourceName);
-
- try {
- fs.writeFileSync(dataPath, JSON.stringify(data), "utf-8");
-
- if (etag) {
- fs.writeFileSync(etagPath, etag, "utf-8");
- }
-
- logger.info(
- `Cached resource ${resourceName} with etag: ${etag || "none"}`
- );
- } catch (error) {
- logger.error(`Failed to write cached resource ${resourceName}:`, error);
- }
- }
-
- static async fetchAndCache(
- resourceName: string,
- url: string,
- timeout: number = 10000
- ): Promise {
- const cached = this.readCachedResource(resourceName);
- const headers: Record = {};
-
- if (cached?.etag) {
- headers["If-None-Match"] = cached.etag;
- }
-
- try {
- const response = await axios.get(url, {
- headers,
- timeout,
- });
-
- const newEtag = response.headers["etag"] || null;
- this.writeCachedResource(resourceName, response.data, newEtag);
-
- return response.data;
- } catch (error: unknown) {
- const axiosError = error as {
- response?: { status?: number };
- message?: string;
- };
-
- if (axiosError.response?.status === 304 && cached) {
- logger.info(`Resource ${resourceName} not modified, using cache`);
- return cached.data;
- }
-
- if (cached) {
- logger.warn(
- `Failed to fetch ${resourceName}, using cached version:`,
- axiosError.message || "Unknown error"
- );
- return cached.data;
- }
-
- logger.error(
- `Failed to fetch ${resourceName} and no cache available:`,
- error
- );
- throw error;
- }
- }
-
- static getCachedData(resourceName: string): T | null {
- const cached = this.readCachedResource(resourceName);
- return cached?.data || null;
- }
-
- static async updateResourcesOnStartup(): Promise {
- logger.info("Starting background resource cache update...");
-
- const resources = [
- {
- name: "steam-games-by-letter",
- url: `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`,
- },
- {
- name: "sources-manifest",
- url: "https://cdn.losbroxas.org/sources-manifest.json",
- },
- ];
-
- await Promise.allSettled(
- resources.map(async (resource) => {
- try {
- await this.fetchAndCache(resource.name, resource.url);
- } catch (error) {
- logger.error(`Failed to update ${resource.name} on startup:`, error);
- }
- })
- );
-
- logger.info("Resource cache update complete");
- }
-}
diff --git a/src/main/workers/game-matcher-worker.ts b/src/main/workers/game-matcher-worker.ts
deleted file mode 100644
index 4930ada0..00000000
--- a/src/main/workers/game-matcher-worker.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { parentPort } from "worker_threads";
-import crypto from "node:crypto";
-
-export type TitleHashMapping = Record;
-
-export type FormattedSteamGame = {
- id: string;
- name: string;
- formattedName: string;
-};
-export type FormattedSteamGamesByLetter = Record;
-
-interface DownloadToMatch {
- title: string;
- uris: string[];
- uploadDate: string;
- fileSize: string;
-}
-
-interface MatchedDownload {
- title: string;
- uris: string[];
- uploadDate: string;
- fileSize: string;
- objectIds: string[];
- usedHashMatch: boolean;
-}
-
-interface MatchRequest {
- downloads: DownloadToMatch[];
- steamGames: FormattedSteamGamesByLetter;
- titleHashMapping: TitleHashMapping;
-}
-
-interface MatchResponse {
- matchedDownloads: MatchedDownload[];
- stats: {
- hashMatchCount: number;
- fuzzyMatchCount: number;
- noMatchCount: number;
- };
-}
-
-const hashTitle = (title: string): string => {
- return crypto.createHash("sha256").update(title).digest("hex");
-};
-
-const formatName = (name: string) => {
- return name
- .normalize("NFD")
- .replaceAll(/[\u0300-\u036f]/g, "")
- .toLowerCase()
- .replaceAll(/[^a-z0-9]/g, "");
-};
-
-const formatRepackName = (name: string) => {
- return formatName(name.replace("[DL]", ""));
-};
-
-const matchDownloads = (request: MatchRequest): MatchResponse => {
- const { downloads, steamGames, titleHashMapping } = request;
- const matchedDownloads: MatchedDownload[] = [];
-
- let hashMatchCount = 0;
- let fuzzyMatchCount = 0;
- let noMatchCount = 0;
-
- for (const download of downloads) {
- let objectIds: string[] = [];
- let usedHashMatch = false;
-
- const titleHash = hashTitle(download.title);
- const steamIdsFromHash = titleHashMapping[titleHash];
-
- if (steamIdsFromHash && steamIdsFromHash.length > 0) {
- hashMatchCount++;
- usedHashMatch = true;
- objectIds = steamIdsFromHash.map(String);
- }
-
- if (!usedHashMatch) {
- let gamesInSteam: FormattedSteamGame[] = [];
- const formattedTitle = formatRepackName(download.title);
-
- if (formattedTitle && formattedTitle.length > 0) {
- const [firstLetter] = formattedTitle;
- const games = steamGames[firstLetter] || [];
-
- gamesInSteam = games.filter((game) =>
- formattedTitle.startsWith(game.formattedName)
- );
-
- if (gamesInSteam.length === 0) {
- gamesInSteam = games.filter(
- (game) =>
- formattedTitle.includes(game.formattedName) ||
- game.formattedName.includes(formattedTitle)
- );
- }
-
- if (gamesInSteam.length === 0) {
- for (const letter of Object.keys(steamGames)) {
- const letterGames = steamGames[letter] || [];
- const matches = letterGames.filter(
- (game) =>
- formattedTitle.includes(game.formattedName) ||
- game.formattedName.includes(formattedTitle)
- );
- if (matches.length > 0) {
- gamesInSteam = matches;
- break;
- }
- }
- }
-
- if (gamesInSteam.length > 0) {
- fuzzyMatchCount++;
- objectIds = gamesInSteam.map((game) => String(game.id));
- } else {
- noMatchCount++;
- }
- } else {
- noMatchCount++;
- }
- }
-
- matchedDownloads.push({
- ...download,
- objectIds,
- usedHashMatch,
- });
- }
-
- return {
- matchedDownloads,
- stats: {
- hashMatchCount,
- fuzzyMatchCount,
- noMatchCount,
- },
- };
-};
-
-// Message handler
-if (parentPort) {
- parentPort.on("message", (message: { id: string; data: MatchRequest }) => {
- try {
- const result = matchDownloads(message.data);
- parentPort!.postMessage({ id: message.id, success: true, result });
- } catch (error) {
- parentPort!.postMessage({
- id: message.id,
- success: false,
- error: error instanceof Error ? error.message : String(error),
- });
- }
- });
-}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index da914b92..f89ec4db 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -99,22 +99,10 @@ contextBridge.exposeInMainWorld("electron", {
/* Download sources */
addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url),
- updateMissingFingerprints: () =>
- ipcRenderer.invoke("updateMissingFingerprints"),
removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
- deleteDownloadSource: (id: number) =>
- ipcRenderer.invoke("deleteDownloadSource", id),
- deleteAllDownloadSources: () =>
- ipcRenderer.invoke("deleteAllDownloadSources"),
- validateDownloadSource: (url: string) =>
- ipcRenderer.invoke("validateDownloadSource", url),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
- getDownloadSourcesList: () => ipcRenderer.invoke("getDownloadSourcesList"),
- checkDownloadSourceExists: (url: string) =>
- ipcRenderer.invoke("checkDownloadSourceExists", url),
- getAllRepacks: () => ipcRenderer.invoke("getAllRepacks"),
/* Library */
toggleAutomaticCloudSync: (
diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx
index 74a2a97e..168a4435 100644
--- a/src/renderer/src/app.tsx
+++ b/src/renderer/src/app.tsx
@@ -7,7 +7,6 @@ import {
useAppSelector,
useDownload,
useLibrary,
- useRepacks,
useToast,
useUserDetails,
} from "@renderer/hooks";
@@ -20,7 +19,6 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
- setIsImportingSources,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
@@ -40,8 +38,6 @@ export function App() {
const { t } = useTranslation("app");
- const { updateRepacks } = useRepacks();
-
const { clearDownload, setLastPacket } = useDownload();
const {
@@ -199,36 +195,6 @@ export function App() {
});
}, [dispatch, draggingDisabled]);
- useEffect(() => {
- (async () => {
- dispatch(setIsImportingSources(true));
-
- try {
- // Initial repacks load
- await updateRepacks();
-
- // Sync all local sources (check for updates)
- const newRepacksCount = await window.electron.syncDownloadSources();
-
- if (newRepacksCount > 0) {
- window.electron.publishNewRepacksNotification(newRepacksCount);
- }
-
- // Update fingerprints for sources that don't have them
- await window.electron.updateMissingFingerprints();
-
- // Update repacks AFTER all syncing and fingerprint updates are complete
- await updateRepacks();
- } catch (error) {
- console.error("Error syncing download sources:", error);
- // Still update repacks even if sync fails
- await updateRepacks();
- } finally {
- dispatch(setIsImportingSources(false));
- }
- })();
- }, [updateRepacks, dispatch]);
-
const loadAndApplyTheme = useCallback(async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) {
diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx
index 5752ba19..598874b5 100644
--- a/src/renderer/src/components/game-card/game-card.tsx
+++ b/src/renderer/src/components/game-card/game-card.tsx
@@ -1,5 +1,5 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
-import type { GameStats } from "@types";
+import type { GameStats, ShopAssets } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
@@ -8,15 +8,15 @@ import "./game-card.scss";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { StarRating } from "../star-rating/star-rating";
-import { useCallback, useState, useMemo } from "react";
-import { useFormat, useRepacks } from "@renderer/hooks";
+import { useCallback, useState } from "react";
+import { useFormat } from "@renderer/hooks";
export interface GameCardProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes,
HTMLButtonElement
> {
- game: any;
+ game: ShopAssets;
}
const shopIcon = {
@@ -28,13 +28,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const [stats, setStats] = useState(null);
- const { getRepacksForObjectId } = useRepacks();
- const repacks = getRepacksForObjectId(game.objectId);
-
- const uniqueRepackers = Array.from(
- new Set(repacks.map((repack) => repack.repacker))
- );
-
const handleHover = useCallback(() => {
if (!stats) {
window.electron.getGameStats(game.objectId, game.shop).then((stats) => {
@@ -45,14 +38,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
const { numberFormatter } = useFormat();
- const firstThreeRepackers = useMemo(
- () => uniqueRepackers.slice(0, 3),
- [uniqueRepackers]
- );
- const remainingCount = useMemo(
- () => uniqueRepackers.length - 3,
- [uniqueRepackers]
- );
+ console.log("game", game);
return (
- {uniqueRepackers.length > 0 ? (
+ {game.downloadSources.length > 0 ? (
- {firstThreeRepackers.map((repacker) => (
- -
- {repacker}
+ {game.downloadSources.slice(0, 3).map((sourceName) => (
+
-
+ {sourceName}
))}
- {remainingCount > 0 && (
+ {game.downloadSources.length > 3 && (
-
- +{remainingCount}{" "}
- {t("game_card:available", { count: remainingCount })}
+ +{game.downloadSources.length - 3}{" "}
+ {t("game_card:available", {
+ count: game.downloadSources.length - 3,
+ })}
)}
diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx
index 14e5d587..2b8e8bf7 100644
--- a/src/renderer/src/context/game-details/game-details.context.tsx
+++ b/src/renderer/src/context/game-details/game-details.context.tsx
@@ -1,11 +1,4 @@
-import {
- createContext,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import { createContext, useCallback, useEffect, useRef, useState } from "react";
import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers";
@@ -13,11 +6,11 @@ import {
useAppDispatch,
useAppSelector,
useDownload,
- useRepacks,
useUserDetails,
} from "@renderer/hooks";
import type {
+ GameRepack,
GameShop,
GameStats,
LibraryGame,
@@ -84,12 +77,7 @@ export function GameDetailsContextProvider({
const [isGameRunning, setIsGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
-
- const { getRepacksForObjectId } = useRepacks();
-
- const repacks = useMemo(() => {
- return getRepacksForObjectId(objectId);
- }, [getRepacksForObjectId, objectId]);
+ const [repacks, setRepacks] = useState([]);
const { i18n } = useTranslation("game_details");
const location = useLocation();
@@ -287,19 +275,6 @@ export function GameDetailsContextProvider({
}
}, [location]);
- const lastDownloadedOption = useMemo(() => {
- if (game?.download) {
- const repack = repacks.find((repack) =>
- repack.uris.some((uri) => uri.includes(game.download!.uri))
- );
-
- if (!repack) return null;
- return repack;
- }
-
- return null;
- }, [game?.download, repacks]);
-
useEffect(() => {
const unsubscribe = window.electron.onUpdateAchievements(
objectId,
@@ -315,6 +290,34 @@ export function GameDetailsContextProvider({
};
}, [objectId, shop, userDetails]);
+ useEffect(() => {
+ const fetchDownloadSources = async () => {
+ try {
+ const sources = await window.electron.getDownloadSources();
+
+ const params = {
+ take: 100,
+ skip: 0,
+ downloadSourceIds: sources.map((source) => source.id),
+ };
+
+ const downloads = await window.electron.hydraApi.get(
+ `/games/${shop}/${objectId}/download-sources`,
+ {
+ params,
+ needsAuth: false,
+ }
+ );
+
+ setRepacks(downloads);
+ } catch (error) {
+ console.error("Failed to fetch download sources:", error);
+ }
+ };
+
+ fetchDownloadSources();
+ }, [shop, objectId]);
+
const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
@@ -359,7 +362,7 @@ export function GameDetailsContextProvider({
stats,
achievements,
hasNSFWContentBlocked,
- lastDownloadedOption,
+ lastDownloadedOption: null,
setHasNSFWContentBlocked,
selectGameExecutable,
updateGame,
diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts
index 9f882aed..4e004e2b 100644
--- a/src/renderer/src/declaration.d.ts
+++ b/src/renderer/src/declaration.d.ts
@@ -31,8 +31,6 @@ import type {
Game,
DiskUsage,
DownloadSource,
- DownloadSourceValidationResult,
- GameRepack,
} from "@types";
import type { AxiosProgressEvent } from "axios";
@@ -210,20 +208,12 @@ declare global {
/* Download sources */
addDownloadSource: (url: string) => Promise;
- updateMissingFingerprints: () => Promise;
- removeDownloadSource: (url: string, removeAll?: boolean) => Promise;
- getDownloadSources: () => Promise<
- Pick[]
- >;
- deleteDownloadSource: (id: number) => Promise;
- deleteAllDownloadSources: () => Promise;
- validateDownloadSource: (
- url: string
- ) => Promise;
- syncDownloadSources: () => Promise;
- getDownloadSourcesList: () => Promise;
- checkDownloadSourceExists: (url: string) => Promise;
- getAllRepacks: () => Promise;
+ removeDownloadSource: (
+ removeAll = false,
+ downloadSourceId?: string
+ ) => Promise;
+ getDownloadSources: () => Promise;
+ syncDownloadSources: () => Promise;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise;
diff --git a/src/renderer/src/features/download-sources-slice.ts b/src/renderer/src/features/download-sources-slice.ts
deleted file mode 100644
index 52e58d26..00000000
--- a/src/renderer/src/features/download-sources-slice.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { createSlice } from "@reduxjs/toolkit";
-
-export interface DownloadSourcesState {
- isImporting: boolean;
-}
-
-const initialState: DownloadSourcesState = {
- isImporting: false,
-};
-
-export const downloadSourcesSlice = createSlice({
- name: "downloadSources",
- initialState,
- reducers: {
- setIsImportingSources: (state, action) => {
- state.isImporting = action.payload;
- },
- },
-});
-
-export const { setIsImportingSources } = downloadSourcesSlice.actions;
diff --git a/src/renderer/src/features/index.ts b/src/renderer/src/features/index.ts
index 3b602cff..a7e64e1f 100644
--- a/src/renderer/src/features/index.ts
+++ b/src/renderer/src/features/index.ts
@@ -6,6 +6,4 @@ export * from "./toast-slice";
export * from "./user-details-slice";
export * from "./game-running.slice";
export * from "./subscription-slice";
-export * from "./repacks-slice";
-export * from "./download-sources-slice";
export * from "./catalogue-search";
diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts
index 8140e0cd..73733e2b 100644
--- a/src/renderer/src/hooks/index.ts
+++ b/src/renderer/src/hooks/index.ts
@@ -5,5 +5,4 @@ export * from "./use-toast";
export * from "./redux";
export * from "./use-user-details";
export * from "./use-format";
-export * from "./use-repacks";
export * from "./use-feature";
diff --git a/src/renderer/src/hooks/use-catalogue.ts b/src/renderer/src/hooks/use-catalogue.ts
index 1d0aeb57..675f5013 100644
--- a/src/renderer/src/hooks/use-catalogue.ts
+++ b/src/renderer/src/hooks/use-catalogue.ts
@@ -2,6 +2,7 @@ import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { useAppDispatch } from "./redux";
import { setGenres, setTags } from "@renderer/features";
+import type { DownloadSource } from "@types";
export const externalResourcesInstance = axios.create({
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
@@ -12,6 +13,7 @@ export function useCatalogue() {
const [steamPublishers, setSteamPublishers] = useState([]);
const [steamDevelopers, setSteamDevelopers] = useState([]);
+ const [downloadSources, setDownloadSources] = useState([]);
const getSteamUserTags = useCallback(() => {
externalResourcesInstance.get("/steam-user-tags.json").then((response) => {
@@ -37,17 +39,25 @@ export function useCatalogue() {
});
}, []);
+ const getDownloadSources = useCallback(() => {
+ window.electron.getDownloadSources().then((results) => {
+ setDownloadSources(results.filter((source) => !!source.fingerprint));
+ });
+ }, []);
+
useEffect(() => {
getSteamUserTags();
getSteamGenres();
getSteamPublishers();
getSteamDevelopers();
+ getDownloadSources();
}, [
getSteamUserTags,
getSteamGenres,
getSteamPublishers,
getSteamDevelopers,
+ getDownloadSources,
]);
- return { steamPublishers, steamDevelopers };
+ return { steamPublishers, downloadSources, steamDevelopers };
}
diff --git a/src/renderer/src/hooks/use-repacks.ts b/src/renderer/src/hooks/use-repacks.ts
deleted file mode 100644
index c024aaa4..00000000
--- a/src/renderer/src/hooks/use-repacks.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { setRepacks } from "@renderer/features";
-import { useCallback } from "react";
-import { RootState } from "@renderer/store";
-import { useSelector } from "react-redux";
-import { useAppDispatch } from "./redux";
-
-export function useRepacks() {
- const dispatch = useAppDispatch();
- const repacks = useSelector((state: RootState) => state.repacks.value);
-
- const getRepacksForObjectId = useCallback(
- (objectId: string) => {
- return repacks.filter((repack) => repack.objectIds.includes(objectId));
- },
- [repacks]
- );
-
- const updateRepacks = useCallback(async () => {
- const repacks = await window.electron.getAllRepacks();
- dispatch(
- setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds)))
- );
- }, [dispatch]);
-
- return { getRepacksForObjectId, updateRepacks };
-}
diff --git a/src/renderer/src/pages/catalogue/catalogue.tsx b/src/renderer/src/pages/catalogue/catalogue.tsx
index 07bcf3ff..bbeda906 100644
--- a/src/renderer/src/pages/catalogue/catalogue.tsx
+++ b/src/renderer/src/pages/catalogue/catalogue.tsx
@@ -1,4 +1,8 @@
-import type { CatalogueSearchResult, DownloadSource } from "@types";
+import type {
+ CatalogueSearchResult,
+ CatalogueSearchPayload,
+ DownloadSource,
+} from "@types";
import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -29,13 +33,12 @@ export default function Catalogue() {
const abortControllerRef = useRef(null);
const cataloguePageRef = useRef(null);
- const { steamDevelopers, steamPublishers } = useCatalogue();
+ const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue();
const { steamGenres, steamUserTags } = useAppSelector(
(state) => state.catalogueSearch
);
- const [downloadSources, setDownloadSources] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [results, setResults] = useState([]);
@@ -51,24 +54,41 @@ export default function Catalogue() {
const { t, i18n } = useTranslation("catalogue");
const debouncedSearch = useRef(
- debounce(async (filters, pageSize, offset) => {
- const abortController = new AbortController();
- abortControllerRef.current = abortController;
+ debounce(
+ async (
+ filters: CatalogueSearchPayload,
+ downloadSources: DownloadSource[],
+ pageSize: number,
+ offset: number
+ ) => {
+ const abortController = new AbortController();
+ abortControllerRef.current = abortController;
- const response = await window.electron.hydraApi.post<{
- edges: CatalogueSearchResult[];
- count: number;
- }>("/catalogue/search", {
- data: { ...filters, take: pageSize, skip: offset },
- needsAuth: false,
- });
+ const requestData = {
+ ...filters,
+ take: pageSize,
+ skip: offset,
+ downloadSourceIds: downloadSources.map(
+ (downloadSource) => downloadSource.id
+ ),
+ };
- if (abortController.signal.aborted) return;
+ const response = await window.electron.hydraApi.post<{
+ edges: CatalogueSearchResult[];
+ count: number;
+ }>("/catalogue/search", {
+ data: requestData,
+ needsAuth: false,
+ });
- setResults(response.edges);
- setItemsCount(response.count);
- setIsLoading(false);
- }, 500)
+ if (abortController.signal.aborted) return;
+
+ setResults(response.edges);
+ setItemsCount(response.count);
+ setIsLoading(false);
+ },
+ 500
+ )
).current;
const decodeHTML = (s: string) =>
@@ -79,18 +99,17 @@ export default function Catalogue() {
setIsLoading(true);
abortControllerRef.current?.abort();
- debouncedSearch(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE);
+ debouncedSearch(
+ filters,
+ downloadSources,
+ PAGE_SIZE,
+ (page - 1) * PAGE_SIZE
+ );
return () => {
debouncedSearch.cancel();
};
- }, [filters, page, debouncedSearch]);
-
- useEffect(() => {
- window.electron.getDownloadSourcesList().then((sources) => {
- setDownloadSources(sources.filter((source) => !!source.fingerprint));
- });
- }, []);
+ }, [filters, downloadSources, page, debouncedSearch]);
const language = i18n.language.split("-")[0];
@@ -168,7 +187,7 @@ export default function Catalogue() {
value: publisher,
})),
];
- }, [filters, steamUserTags, steamGenresMapping, language, downloadSources]);
+ }, [filters, steamUserTags, downloadSources, steamGenresMapping, language]);
const filterSections = useMemo(() => {
return [
diff --git a/src/renderer/src/pages/catalogue/game-item.tsx b/src/renderer/src/pages/catalogue/game-item.tsx
index ecfe0f73..4583afd3 100644
--- a/src/renderer/src/pages/catalogue/game-item.tsx
+++ b/src/renderer/src/pages/catalogue/game-item.tsx
@@ -1,6 +1,6 @@
import { Badge } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
-import { useAppSelector, useRepacks, useLibrary } from "@renderer/hooks";
+import { useAppSelector, useLibrary } from "@renderer/hooks";
import { useMemo, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
@@ -23,10 +23,6 @@ export function GameItem({ game }: GameItemProps) {
const { steamGenres } = useAppSelector((state) => state.catalogueSearch);
- const { getRepacksForObjectId } = useRepacks();
-
- const repacks = getRepacksForObjectId(game.objectId);
-
const [isAddingToLibrary, setIsAddingToLibrary] = useState(false);
const [added, setAdded] = useState(false);
@@ -63,10 +59,6 @@ export function GameItem({ game }: GameItemProps) {
}
};
- const uniqueRepackers = useMemo(() => {
- return Array.from(new Set(repacks.map((repack) => repack.repacker)));
- }, [repacks]);
-
const genres = useMemo(() => {
return game.genres?.map((genre) => {
const index = steamGenres["en"]?.findIndex(
@@ -117,8 +109,8 @@ export function GameItem({ game }: GameItemProps) {
{genres.join(", ")}
- {uniqueRepackers.map((repacker) => (
- {repacker}
+ {game.downloadSources.map((sourceName) => (
+ {sourceName}
))}
diff --git a/src/renderer/src/pages/game-details/game-reviews.tsx b/src/renderer/src/pages/game-details/game-reviews.tsx
index f8117f43..1ce44550 100644
--- a/src/renderer/src/pages/game-details/game-reviews.tsx
+++ b/src/renderer/src/pages/game-details/game-reviews.tsx
@@ -144,8 +144,6 @@ export function GameReviews({
}
}, [objectId, userDetailsId, shop, game, onUserReviewedChange]);
- console.log("reviews", reviews);
-
const loadReviews = useCallback(
async (reset = false) => {
if (!objectId) return;
@@ -440,8 +438,6 @@ export function GameReviews({
});
}, [reviews]);
- console.log("reviews", reviews);
-
return (
{showReviewPrompt &&
diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
index 7551a31e..306e8647 100644
--- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
+++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
@@ -54,7 +54,7 @@ export function RepacksModal({
{}
);
- const { repacks, game } = useContext(gameDetailsContext);
+ const { game, repacks } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@@ -88,6 +88,15 @@ export function RepacksModal({
});
}, [repacks, isFeatureEnabled, Feature]);
+ useEffect(() => {
+ const fetchDownloadSources = async () => {
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources);
+ };
+
+ fetchDownloadSources();
+ }, []);
+
const sortedRepacks = useMemo(() => {
return orderBy(
repacks,
@@ -103,23 +112,13 @@ export function RepacksModal({
);
}, [repacks, hashesInDebrid]);
- useEffect(() => {
- window.electron.getDownloadSourcesList().then((sources) => {
- const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker));
- const filteredSources = sources.filter(
- (s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint
- );
- setDownloadSources(filteredSources);
- });
- }, [sortedRepacks]);
-
useEffect(() => {
const term = filterTerm.trim().toLowerCase();
const byTerm = sortedRepacks.filter((repack) => {
if (!term) return true;
const lowerTitle = repack.title.toLowerCase();
- const lowerRepacker = repack.repacker.toLowerCase();
+ const lowerRepacker = repack.downloadSourceName.toLowerCase();
return lowerTitle.includes(term) || lowerRepacker.includes(term);
});
@@ -130,7 +129,7 @@ export function RepacksModal({
(src) =>
src.fingerprint &&
selectedFingerprints.includes(src.fingerprint) &&
- src.name === repack.repacker
+ src.name === repack.downloadSourceName
);
});
@@ -281,7 +280,7 @@ export function RepacksModal({
)}
- {repack.fileSize} - {repack.repacker} -{" "}
+ {repack.fileSize} - {repack.downloadSourceName} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx
index 40bf181d..b8f632a6 100644
--- a/src/renderer/src/pages/home/home.tsx
+++ b/src/renderer/src/pages/home/home.tsx
@@ -40,14 +40,20 @@ export default function Home() {
setCurrentCatalogueCategory(category);
setIsLoading(true);
- const params = new URLSearchParams({
- take: "12",
- skip: "0",
- });
+ const downloadSources = await window.electron.getDownloadSources();
+
+ const params = {
+ take: 12,
+ skip: 0,
+ downloadSourceIds: downloadSources.map((source) => source.id),
+ };
const catalogue = await window.electron.hydraApi.get
(
- `/catalogue/${category}?${params.toString()}`,
- { needsAuth: false }
+ `/catalogue/${category}`,
+ {
+ params,
+ needsAuth: false,
+ }
);
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
diff --git a/src/renderer/src/pages/settings/add-download-source-modal.scss b/src/renderer/src/pages/settings/add-download-source-modal.scss
index ea92ca71..d938f7f0 100644
--- a/src/renderer/src/pages/settings/add-download-source-modal.scss
+++ b/src/renderer/src/pages/settings/add-download-source-modal.scss
@@ -38,4 +38,11 @@
animation: spin 1s linear infinite;
margin-right: calc(globals.$spacing-unit / 2);
}
+
+ &__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: globals.$spacing-unit;
+ margin-top: calc(globals.$spacing-unit * 2);
+ }
}
diff --git a/src/renderer/src/pages/settings/add-download-source-modal.tsx b/src/renderer/src/pages/settings/add-download-source-modal.tsx
index c2b47513..2b45ed72 100644
--- a/src/renderer/src/pages/settings/add-download-source-modal.tsx
+++ b/src/renderer/src/pages/settings/add-download-source-modal.tsx
@@ -1,15 +1,12 @@
-import { useCallback, useContext, useEffect, useState } from "react";
+import { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import { settingsContext } from "@renderer/context";
import { useForm } from "react-hook-form";
-import { useAppDispatch } from "@renderer/hooks";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
-import type { DownloadSourceValidationResult } from "@types";
-import { setIsImportingSources } from "@renderer/features";
import { SyncIcon } from "@primer/octicons-react";
import "./add-download-source-modal.scss";
@@ -28,7 +25,6 @@ export function AddDownloadSourceModal({
onClose,
onAddDownloadSource,
}: Readonly) {
- const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation("settings");
@@ -48,77 +44,38 @@ export function AddDownloadSourceModal({
resolver: yupResolver(schema),
});
- const [validationResult, setValidationResult] =
- useState(null);
-
const { sourceUrl } = useContext(settingsContext);
- const dispatch = useAppDispatch();
+ const onSubmit = async (values: FormValues) => {
+ setIsLoading(true);
- const onSubmit = useCallback(
- async (values: FormValues) => {
- const exists = await window.electron.checkDownloadSourceExists(
- values.url
- );
+ try {
+ await window.electron.addDownloadSource(values.url);
- if (exists) {
- setError("url", {
- type: "server",
- message: t("source_already_exists"),
- });
-
- return;
- }
-
- const validationResult = await window.electron.validateDownloadSource(
- values.url
- );
-
- setValidationResult(validationResult);
- setUrl(values.url);
- },
- [setError, t]
- );
+ onClose();
+ onAddDownloadSource();
+ } catch (error) {
+ console.error("Failed to add download source:", error);
+ setError("url", {
+ type: "server",
+ message: "Failed to add download source. Please try again.",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
useEffect(() => {
setValue("url", "");
clearErrors();
setIsLoading(false);
- setValidationResult(null);
if (sourceUrl) {
setValue("url", sourceUrl);
- handleSubmit(onSubmit)();
}
- }, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
-
- const handleAddDownloadSource = async () => {
- if (validationResult) {
- setIsLoading(true);
- dispatch(setIsImportingSources(true));
-
- try {
- // Single call that handles: import → API sync → fingerprint
- await window.electron.addDownloadSource(url);
-
- // Close modal and update UI
- onClose();
- onAddDownloadSource();
- } catch (error) {
- console.error("Failed to add download source:", error);
- setError("url", {
- type: "server",
- message: "Failed to import source. Please try again.",
- });
- } finally {
- setIsLoading(false);
- dispatch(setIsImportingSources(false));
- }
- }
- };
+ }, [visible, clearErrors, setValue, sourceUrl]);
const handleClose = () => {
- // Prevent closing while importing
if (isLoading) return;
onClose();
};
@@ -132,49 +89,32 @@ export function AddDownloadSourceModal({
clickOutsideToClose={!isLoading}
>
-
+
+
+
- }
- />
-
- {validationResult && (
-
-
-
{validationResult?.name}
-
- {t("found_download_option", {
- count: validationResult?.downloadCount,
- countFormatted:
- validationResult?.downloadCount.toLocaleString(),
- })}
-
-
-
-
+
+
- )}
+
);
diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx
index f873b321..85c0569a 100644
--- a/src/renderer/src/pages/settings/settings-download-sources.tsx
+++ b/src/renderer/src/pages/settings/settings-download-sources.tsx
@@ -16,7 +16,7 @@ import {
TrashIcon,
} from "@primer/octicons-react";
import { AddDownloadSourceModal } from "./add-download-source-modal";
-import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
+import { useAppDispatch, useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { settingsContext } from "@renderer/context";
import { useNavigate } from "react-router-dom";
@@ -35,7 +35,6 @@ export function SettingsDownloadSources() {
useState(false);
const [isRemovingDownloadSource, setIsRemovingDownloadSource] =
useState(false);
- const [isFetchingSources, setIsFetchingSources] = useState(true);
const { sourceUrl, clearSourceUrl } = useContext(settingsContext);
@@ -46,37 +45,29 @@ export function SettingsDownloadSources() {
const navigate = useNavigate();
- const { updateRepacks } = useRepacks();
-
- const getDownloadSources = async () => {
- await window.electron
- .getDownloadSourcesList()
- .then((sources) => {
- setDownloadSources(sources);
- })
- .finally(() => {
- setIsFetchingSources(false);
- });
- };
-
- useEffect(() => {
- getDownloadSources();
- }, []);
-
useEffect(() => {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
+ useEffect(() => {
+ const fetchDownloadSources = async () => {
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources);
+ };
+
+ fetchDownloadSources();
+ }, []);
+
const handleRemoveSource = async (downloadSource: DownloadSource) => {
setIsRemovingDownloadSource(true);
try {
- await window.electron.deleteDownloadSource(downloadSource.id);
- await window.electron.removeDownloadSource(downloadSource.url);
-
+ await window.electron.removeDownloadSource(false, downloadSource.id);
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources as DownloadSource[]);
showSuccessToast(t("removed_download_source"));
- await getDownloadSources();
- updateRepacks();
+ } catch (error) {
+ console.error("Failed to remove download source:", error);
} finally {
setIsRemovingDownloadSource(false);
}
@@ -86,53 +77,44 @@ export function SettingsDownloadSources() {
setIsRemovingDownloadSource(true);
try {
- await window.electron.deleteAllDownloadSources();
- await window.electron.removeDownloadSource("", true);
-
- showSuccessToast(t("removed_download_sources"));
- await getDownloadSources();
- setShowConfirmationDeleteAllSourcesModal(false);
- updateRepacks();
+ await window.electron.removeDownloadSource(true);
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources as DownloadSource[]);
+ showSuccessToast(t("removed_all_download_sources"));
+ } catch (error) {
+ console.error("Failed to remove all download sources:", error);
} finally {
setIsRemovingDownloadSource(false);
+ setShowConfirmationDeleteAllSourcesModal(false);
}
};
const handleAddDownloadSource = async () => {
- // Refresh sources list and repacks after import completes
- await getDownloadSources();
-
- // Force repacks update to ensure UI reflects new data
- await updateRepacks();
-
- showSuccessToast(t("added_download_source"));
+ try {
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources as DownloadSource[]);
+ } catch (error) {
+ console.error("Failed to refresh download sources:", error);
+ }
};
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
-
try {
- // Sync local sources (check for updates)
- await window.electron.syncDownloadSources();
-
- // Refresh sources and repacks AFTER sync completes
- await getDownloadSources();
- await updateRepacks();
-
- showSuccessToast(t("download_sources_synced"));
- } catch (error) {
- console.error("Error syncing download sources:", error);
- // Still refresh the UI even if sync fails
- await getDownloadSources();
- await updateRepacks();
+ const sources = await window.electron.syncDownloadSources();
+ setDownloadSources(sources);
} finally {
setIsSyncingDownloadSources(false);
}
};
const statusTitle = {
- [DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"),
- [DownloadSourceStatus.Errored]: t("download_source_errored"),
+ [DownloadSourceStatus.PendingMatching]: t(
+ "download_source_pending_matching"
+ ),
+ [DownloadSourceStatus.Matched]: t("download_source_matched"),
+ [DownloadSourceStatus.Matching]: t("download_source_matching"),
+ [DownloadSourceStatus.Failed]: t("download_source_failed"),
};
const handleModalClose = () => {
@@ -180,8 +162,7 @@ export function SettingsDownloadSources() {
disabled={
!downloadSources.length ||
isSyncingDownloadSources ||
- isRemovingDownloadSource ||
- isFetchingSources
+ isRemovingDownloadSource
}
onClick={syncDownloadSources}
>
@@ -197,8 +178,7 @@ export function SettingsDownloadSources() {
disabled={
isRemovingDownloadSource ||
isSyncingDownloadSources ||
- !downloadSources.length ||
- isFetchingSources
+ !downloadSources.length
}
>
@@ -209,11 +189,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => setShowAddDownloadSourceModal(true)}
- disabled={
- isSyncingDownloadSources ||
- isFetchingSources ||
- isRemovingDownloadSource
- }
+ disabled={isSyncingDownloadSources || isRemovingDownloadSource}
>
{t("add_download_source")}
diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts
index 264b1296..9903271c 100644
--- a/src/renderer/src/store.ts
+++ b/src/renderer/src/store.ts
@@ -8,8 +8,6 @@ import {
userDetailsSlice,
gameRunningSlice,
subscriptionSlice,
- repacksSlice,
- downloadSourcesSlice,
catalogueSearchSlice,
} from "@renderer/features";
@@ -23,8 +21,6 @@ export const store = configureStore({
userDetails: userDetailsSlice.reducer,
gameRunning: gameRunningSlice.reducer,
subscription: subscriptionSlice.reducer,
- repacks: repacksSlice.reducer,
- downloadSources: downloadSourcesSlice.reducer,
catalogueSearch: catalogueSearchSlice.reducer,
},
});
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 851aec49..619dca65 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -11,8 +11,10 @@ export enum Downloader {
}
export enum DownloadSourceStatus {
- UpToDate,
- Errored,
+ PendingMatching = "PENDING_MATCHING",
+ Matched = "MATCHED",
+ Matching = "MATCHING",
+ Failed = "FAILED",
}
export enum CatalogueCategory {
diff --git a/src/types/index.ts b/src/types/index.ts
index 63b18645..092adaf8 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -16,29 +16,22 @@ export interface DiskUsage {
}
export interface GameRepack {
- id: number;
+ id: string;
title: string;
- uris: string[];
- repacker: string;
fileSize: string | null;
- objectIds: string[];
- uploadDate: Date | string | null;
- createdAt: Date;
- updatedAt: Date;
+ uris: string[];
+ uploadDate: string | null;
+ downloadSourceId: string;
+ downloadSourceName: string;
}
export interface DownloadSource {
- id: number;
+ id: string;
name: string;
url: string;
- repackCount: number;
status: DownloadSourceStatus;
- objectIds: string[];
downloadCount: number;
fingerprint?: string;
- etag: string | null;
- createdAt: Date;
- updatedAt: Date;
}
export interface ShopAssets {
@@ -51,6 +44,7 @@ export interface ShopAssets {
logoImageUrl: string;
logoPosition: string | null;
coverImageUrl: string | null;
+ downloadSources: string[];
}
export type ShopDetails = SteamAppDetails & {
@@ -231,12 +225,6 @@ export interface DownloadSourceDownload {
fileSize: string;
}
-export interface DownloadSourceValidationResult {
- name: string;
- etag: string;
- downloadCount: number;
-}
-
export interface GameStats {
downloadCount: number;
playerCount: number;
@@ -366,7 +354,7 @@ export type CatalogueSearchResult = {
title: string;
shop: GameShop;
genres: string[];
-} & Pick;
+} & Pick;
export type LibraryGame = Game &
Partial & {
From e1ce5bc6cb8e6d2cbc14a7e7615c0b95619d9cf2 Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Tue, 21 Oct 2025 04:20:11 +0100
Subject: [PATCH 03/81] feat: using api download sources
---
src/main/events/library/add-custom-game-to-library.ts | 1 +
src/main/services/library-sync/merge-with-remote-games.ts | 1 +
2 files changed, 2 insertions(+)
diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts
index f2f2dd40..6a90087e 100644
--- a/src/main/events/library/add-custom-game-to-library.ts
+++ b/src/main/events/library/add-custom-game-to-library.ts
@@ -37,6 +37,7 @@ const addCustomGameToLibrary = async (
logoImageUrl: logoImageUrl || "",
logoPosition: null,
coverImageUrl: iconUrl || "",
+ downloadSources: [],
};
await gamesShopAssetsSublevel.put(gameKey, assets);
diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts
index f7ea2744..c00e4961 100644
--- a/src/main/services/library-sync/merge-with-remote-games.ts
+++ b/src/main/services/library-sync/merge-with-remote-games.ts
@@ -72,6 +72,7 @@ export const mergeWithRemoteGames = async () => {
logoImageUrl: game.logoImageUrl,
iconUrl: game.iconUrl,
logoPosition: game.logoPosition,
+ downloadSources: game.downloadSources,
});
}
})
From 8a40c678f7c2db1d4558cf1cca018359a592e3d7 Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Tue, 21 Oct 2025 04:21:56 +0100
Subject: [PATCH 04/81] feat: using api download sources
---
src/renderer/src/pages/game-details/game-details.tsx | 1 -
src/types/index.ts | 1 -
2 files changed, 2 deletions(-)
diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx
index f0778494..04b78aa4 100644
--- a/src/renderer/src/pages/game-details/game-details.tsx
+++ b/src/renderer/src/pages/game-details/game-details.tsx
@@ -102,7 +102,6 @@ export default function GameDetails() {
automaticallyExtract: boolean
) => {
const response = await startDownload({
- repackId: repack.id,
objectId: objectId!,
title: gameTitle,
downloader,
diff --git a/src/types/index.ts b/src/types/index.ts
index 092adaf8..7d11171b 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -107,7 +107,6 @@ export type AppUpdaterEvent =
/* Events */
export interface StartGameDownloadPayload {
- repackId: number;
objectId: string;
title: string;
shop: GameShop;
From 945173f48e7f4017a409705325ca7fdffd3133c1 Mon Sep 17 00:00:00 2001
From: spectre365
Date: Wed, 22 Oct 2025 16:32:39 -0300
Subject: [PATCH 05/81] Interface modification for the default game page
---
.env.example | 5 ---
.../game-details/game-details-content.tsx | 12 +++---
.../src/pages/game-details/game-details.scss | 34 ++++++++++++++---
.../pages/game-details/hero/hero-panel.scss | 8 +++-
.../pages/game-details/hero/hero-panel.tsx | 38 ++++++++++---------
5 files changed, 63 insertions(+), 34 deletions(-)
delete mode 100644 .env.example
diff --git a/.env.example b/.env.example
deleted file mode 100644
index 3f914eb3..00000000
--- a/.env.example
+++ /dev/null
@@ -1,5 +0,0 @@
-MAIN_VITE_API_URL=
-MAIN_VITE_AUTH_URL=
-MAIN_VITE_WS_URL=
-RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
-RENDERER_VITE_TORBOX_REFERRAL_CODE=
diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx
index edf314c7..e019d984 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -153,11 +153,13 @@ export function GameDetailsContent() {
>
-

+
+

+
-
{getInfo()}
-
-
-
+
+
+
{getInfo()}
+
+
+
- {showProgressBar && (
-
- )}
+ {showProgressBar && (
+
+ )}
+
);
}
From 864fd282f002e0fda04f5e995f24799730a7273f Mon Sep 17 00:00:00 2001
From: spectre365
Date: Wed, 22 Oct 2025 16:42:21 -0300
Subject: [PATCH 06/81] Interface modification for the default game page
---
.../src/pages/game-details/game-details.scss | 8 +-
.../pages/game-details/hero/hero-panel.scss | 124 +++++++++---------
2 files changed, 68 insertions(+), 64 deletions(-)
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss
index 38319b0a..14b583ee 100644
--- a/src/renderer/src/pages/game-details/game-details.scss
+++ b/src/renderer/src/pages/game-details/game-details.scss
@@ -584,13 +584,17 @@ $hero-height: 300px;
z-index: 0;
&::after {
- content: '';
+ content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
- background: linear-gradient(0deg, rgba(0, 0, 0, 0.3) 60%, transparent 100%);
+ background: linear-gradient(
+ 0deg,
+ rgba(0, 0, 0, 0.3) 60%,
+ transparent 100%
+ );
z-index: 1;
pointer-events: none;
border-radius: inherit;
diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.scss b/src/renderer/src/pages/game-details/hero/hero-panel.scss
index 8a3611ec..9fbdf453 100644
--- a/src/renderer/src/pages/game-details/hero/hero-panel.scss
+++ b/src/renderer/src/pages/game-details/hero/hero-panel.scss
@@ -4,75 +4,75 @@
padding: 0px 12px 12px;
margin: 0;
-.hero-panel {
- width: 100%;
- height: 72px;
- min-height: 72px;
- padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(10px);
- -webkit-backdrop-filter: blur(10px);
- border: solid 1px rgba(255, 255, 255, 0.15);
- display: flex;
- align-items: center;
- justify-content: space-between;
- transition: all ease 0.2s;
- border-bottom: solid 1px globals.$border-color;
- overflow: hidden;
- top: 0;
- z-index: 2;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
- border-radius: 8px;
-
- &--stuck {
- background: rgba(0, 0, 0, 0.7);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
- }
-
- &__content {
- display: flex;
- flex-direction: column;
- gap: globals.$spacing-unit;
- }
-
- &__actions {
- display: flex;
- gap: globals.$spacing-unit;
- }
-
- &__download-details {
- gap: globals.$spacing-unit;
- display: flex;
- color: globals.$body-color;
- align-items: center;
- }
-
- &__downloads-link {
- color: globals.$body-color;
- text-decoration: underline;
- }
-
- &__progress-bar {
- position: absolute;
- bottom: 0;
- left: 0;
+ .hero-panel {
width: 100%;
- height: 3px;
+ height: 72px;
+ min-height: 72px;
+ padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border: solid 1px rgba(255, 255, 255, 0.15);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
transition: all ease 0.2s;
+ border-bottom: solid 1px globals.$border-color;
+ overflow: hidden;
+ top: 0;
+ z-index: 2;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ border-radius: 8px;
- &::-webkit-progress-bar {
- background-color: transparent;
+ &--stuck {
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
}
- &::-webkit-progress-value {
- background-color: globals.$muted-color;
+ &__content {
+ display: flex;
+ flex-direction: column;
+ gap: globals.$spacing-unit;
}
- &--disabled {
- opacity: globals.$disabled-opacity;
+ &__actions {
+ display: flex;
+ gap: globals.$spacing-unit;
+ }
+
+ &__download-details {
+ gap: globals.$spacing-unit;
+ display: flex;
+ color: globals.$body-color;
+ align-items: center;
+ }
+
+ &__downloads-link {
+ color: globals.$body-color;
+ text-decoration: underline;
+ }
+
+ &__progress-bar {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 3px;
+ transition: all ease 0.2s;
+
+ &::-webkit-progress-bar {
+ background-color: transparent;
+ }
+
+ &::-webkit-progress-value {
+ background-color: globals.$muted-color;
+ }
+
+ &--disabled {
+ opacity: globals.$disabled-opacity;
+ }
}
}
}
-}
\ No newline at end of file
From 7435bff64f1da9f94430509bd6da736c3999b1ad Mon Sep 17 00:00:00 2001
From: spectre365
Date: Wed, 22 Oct 2025 17:17:37 -0300
Subject: [PATCH 07/81] Interface modification for the default game page
---
.env.example | 8 ++
.../pages/game-details/hero/hero-panel.scss | 124 +++++++++---------
.../pages/game-details/hero/hero-panel.tsx | 2 +-
3 files changed, 71 insertions(+), 63 deletions(-)
create mode 100644 .env.example
diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..c71b5e68
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,8 @@
+MAIN_VITE_API_URL=https://api-staging.hydralauncher.gg
+MAIN_VITE_AUTH_URL=https://auth-staging.hydralauncher.gg
+MAIN_VITE_WS_URL=wss://ws-staging.hydralauncher.gg
+MAIN_VITE_EXTERNAL_RESOURCES_URL=https://assets.hydralauncher.gg
+RENDERER_VITE_EXTERNAL_RESOURCES_URL=https://assets.hydralauncher.gg
+MAIN_VITE_CHECKOUT_URL=https://checkout-staging.hydralauncher.gg
+RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
+RENDERER_VITE_TORBOX_REFERRAL_CODE=
diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.scss b/src/renderer/src/pages/game-details/hero/hero-panel.scss
index 9fbdf453..fa797988 100644
--- a/src/renderer/src/pages/game-details/hero/hero-panel.scss
+++ b/src/renderer/src/pages/game-details/hero/hero-panel.scss
@@ -1,78 +1,78 @@
@use "../../../scss/globals.scss";
-.hero-panel-wrapper {
+.hero-panel {
+ width: 100%;
+ height: 72px;
+ min-height: 72px;
+ padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border: solid 1px rgba(255, 255, 255, 0.15);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ transition: all ease 0.2s;
+ border-bottom: solid 1px globals.$border-color;
+ overflow: hidden;
+ top: 0;
+ z-index: 2;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ border-radius: 8px;
+
+ &__container {
padding: 0px 12px 12px;
margin: 0;
+ }
- .hero-panel {
- width: 100%;
- height: 72px;
- min-height: 72px;
- padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(10px);
- -webkit-backdrop-filter: blur(10px);
- border: solid 1px rgba(255, 255, 255, 0.15);
+ &--stuck {
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
+ }
+
+ &__content {
display: flex;
+ flex-direction: column;
+ gap: globals.$spacing-unit;
+ }
+
+ &__actions {
+ display: flex;
+ gap: globals.$spacing-unit;
+ }
+
+ &__download-details {
+ gap: globals.$spacing-unit;
+ display: flex;
+ color: globals.$body-color;
align-items: center;
- justify-content: space-between;
+ }
+
+ &__downloads-link {
+ color: globals.$body-color;
+ text-decoration: underline;
+ }
+
+ &__progress-bar {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 3px;
transition: all ease 0.2s;
- border-bottom: solid 1px globals.$border-color;
- overflow: hidden;
- top: 0;
- z-index: 2;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
- border-radius: 8px;
- &--stuck {
- background: rgba(0, 0, 0, 0.7);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
+ &::-webkit-progress-bar {
+ background-color: transparent;
}
- &__content {
- display: flex;
- flex-direction: column;
- gap: globals.$spacing-unit;
+ &::-webkit-progress-value {
+ background-color: globals.$muted-color;
}
- &__actions {
- display: flex;
- gap: globals.$spacing-unit;
- }
-
- &__download-details {
- gap: globals.$spacing-unit;
- display: flex;
- color: globals.$body-color;
- align-items: center;
- }
-
- &__downloads-link {
- color: globals.$body-color;
- text-decoration: underline;
- }
-
- &__progress-bar {
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- height: 3px;
- transition: all ease 0.2s;
-
- &::-webkit-progress-bar {
- background-color: transparent;
- }
-
- &::-webkit-progress-value {
- background-color: globals.$muted-color;
- }
-
- &--disabled {
- opacity: globals.$disabled-opacity;
- }
+ &--disabled {
+ opacity: globals.$disabled-opacity;
}
}
}
diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.tsx b/src/renderer/src/pages/game-details/hero/hero-panel.tsx
index db0164de..a1e6fed5 100644
--- a/src/renderer/src/pages/game-details/hero/hero-panel.tsx
+++ b/src/renderer/src/pages/game-details/hero/hero-panel.tsx
@@ -50,7 +50,7 @@ export function HeroPanel() {
game?.download?.status === "paused";
return (
-
+
{getInfo()}
From ca35da37ededae768923576e4b4fef78aadd818b Mon Sep 17 00:00:00 2001
From: spectre365
Date: Wed, 22 Oct 2025 17:20:48 -0300
Subject: [PATCH 08/81] Interface modification for the default game page
---
src/renderer/src/pages/game-details/hero/hero-panel.scss | 8 ++++----
src/renderer/src/pages/game-details/hero/hero-panel.tsx | 4 +++-
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.scss b/src/renderer/src/pages/game-details/hero/hero-panel.scss
index fa797988..a0d32e9e 100644
--- a/src/renderer/src/pages/game-details/hero/hero-panel.scss
+++ b/src/renderer/src/pages/game-details/hero/hero-panel.scss
@@ -18,11 +18,11 @@
top: 0;
z-index: 2;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
- border-radius: 8px;
-
+ border-radius: 8px;
+
&__container {
- padding: 0px 12px 12px;
- margin: 0;
+ padding: 0px 12px 12px;
+ margin: 0;
}
&--stuck {
diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.tsx b/src/renderer/src/pages/game-details/hero/hero-panel.tsx
index a1e6fed5..799f2c36 100644
--- a/src/renderer/src/pages/game-details/hero/hero-panel.tsx
+++ b/src/renderer/src/pages/game-details/hero/hero-panel.tsx
@@ -61,7 +61,9 @@ export function HeroPanel() {