From 3dc71a8d1fb203d1403c07abcd0fa4e1806f80bb Mon Sep 17 00:00:00 2001 From: whintersnow0 Date: Wed, 15 Oct 2025 19:19:08 +0200 Subject: [PATCH 01/26] refactor: remove unnecessary useMemo hooks --- .../src/components/text-field/text-field.tsx | 52 +++++-------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/src/renderer/src/components/text-field/text-field.tsx b/src/renderer/src/components/text-field/text-field.tsx index 1c4d54af..7c0cbb58 100644 --- a/src/renderer/src/components/text-field/text-field.tsx +++ b/src/renderer/src/components/text-field/text-field.tsx @@ -1,16 +1,13 @@ -import React, { useId, useMemo, useState } from "react"; +import React, { useId, useState } from "react"; import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; - import cn from "classnames"; - import "./text-field.scss"; -export interface TextFieldProps - extends React.DetailedHTMLProps< - React.InputHTMLAttributes, - HTMLInputElement - > { +export interface TextFieldProps extends React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement +> { theme?: "primary" | "dark"; label?: string | React.ReactNode; hint?: string | React.ReactNode; @@ -42,44 +39,27 @@ export const TextField = React.forwardRef( ) => { const id = useId(); const [isFocused, setIsFocused] = useState(false); - const [isPasswordVisible, setIsPasswordVisible] = useState(false); - const { t } = useTranslation("forms"); - const showPasswordToggleButton = props.type === "password"; - - const inputType = useMemo(() => { - if (props.type === "password" && isPasswordVisible) return "text"; - return props.type ?? "text"; - }, [props.type, isPasswordVisible]); - - const hintContent = useMemo(() => { - if (error) - return ( - {error} - ); - - if (hint) return {hint}; - return null; - }, [hint, error]); - + const inputType = props.type === "password" && isPasswordVisible ? "text" : props.type ?? "text"; + const hintContent = error ? ( + {error} + ) : hint ? ( + {hint} + ) : null; const handleFocus: React.FocusEventHandler = (event) => { setIsFocused(true); - if (props.onFocus) props.onFocus(event); + props.onFocus?.(event); }; - const handleBlur: React.FocusEventHandler = (event) => { setIsFocused(false); - if (props.onBlur) props.onBlur(event); + props.onBlur?.(event); }; - const hasError = !!error; - return (
{label && } -
( onBlur={handleBlur} type={inputType} /> - {showPasswordToggleButton && ( )}
- {rightContent}
- {hintContent}
); } ); - -TextField.displayName = "TextField"; +TextField.displayName = "TextField"; \ No newline at end of file From c2273dbf712842ef7f6241d8a68b154816bbb64a Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sat, 18 Oct 2025 14:07:44 +0100 Subject: [PATCH 02/26] 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 03/26] 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 ( - } - /> - - {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 04/26] 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 05/26] 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 40f7e6e2ad210d56bddd15b2b59828358f3919fb Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:47:54 -0300 Subject: [PATCH 06/26] chore: bump electron version to 35 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 342b078a..59497aad 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "@types/winreg": "^1.2.36", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.2.1", - "electron": "^33.4.11", + "electron": "^35.7.5", "electron-builder": "^26.0.12", "electron-vite": "^3.0.0", "eslint": "^8.56.0", From a388acf9481ac1dcdff964b7c24749d084ba6247 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:51:15 -0300 Subject: [PATCH 07/26] chore: update node version on gh actions --- .github/workflows/build-renderer.yml | 4 ++-- .github/workflows/build.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- yarn.lock | 19 +++++++++++++------ 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-renderer.yml b/.github/workflows/build-renderer.yml index 6aefac43..ed7a99ab 100644 --- a/.github/workflows/build-renderer.yml +++ b/.github/workflows/build-renderer.yml @@ -6,7 +6,7 @@ concurrency: on: push: - branches: main + branches: [main] jobs: build: @@ -19,7 +19,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.0 + node-version: 22.19.5 - name: Install dependencies run: yarn --frozen-lockfile --ignore-scripts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5062c7ad..32688379 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.19.5 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ac359364..6d08525c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.19.5 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ceb42c7..a06eeb21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.19.5 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/yarn.lock b/yarn.lock index 0337a77b..5ffc3f03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3206,13 +3206,20 @@ dependencies: undici-types "~7.14.0" -"@types/node@^20.12.7", "@types/node@^20.9.0": +"@types/node@^20.12.7": version "20.19.21" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.21.tgz#6e5378e04993c40395473b13baf94a09875157b8" integrity sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA== dependencies: undici-types "~6.21.0" +"@types/node@^22.7.7": + version "22.18.12" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.18.12.tgz#e165d87bc25d7bf6d3657035c914db7485de84fb" + integrity sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog== + dependencies: + undici-types "~6.21.0" + "@types/parse-torrent-file@*": version "4.0.6" resolved "https://registry.yarnpkg.com/@types/parse-torrent-file/-/parse-torrent-file-4.0.6.tgz#11801dfd5b0a017302a164b72c8869f2bcba15b1" @@ -4651,13 +4658,13 @@ electron-vite@^3.0.0: magic-string "^0.30.17" picocolors "^1.1.1" -electron@^33.4.11: - version "33.4.11" - resolved "https://registry.yarnpkg.com/electron/-/electron-33.4.11.tgz#225d7f106ed3edf788ced318c63858d8b8a446dc" - integrity sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg== +electron@^35.7.5: + version "35.7.5" + resolved "https://registry.yarnpkg.com/electron/-/electron-35.7.5.tgz#294a4aebb2ad2a884de730c410f2358d061e8d53" + integrity sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw== dependencies: "@electron/get" "^2.0.0" - "@types/node" "^20.9.0" + "@types/node" "^22.7.7" extract-zip "^2.0.1" embla-carousel-autoplay@^8.6.0: From 29e822f2f110a3ae83d0f18862a37d5614112fbe Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:56:45 -0300 Subject: [PATCH 08/26] fix: node version on gh actions files --- .github/workflows/build-renderer.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-renderer.yml b/.github/workflows/build-renderer.yml index ed7a99ab..f7361883 100644 --- a/.github/workflows/build-renderer.yml +++ b/.github/workflows/build-renderer.yml @@ -19,7 +19,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.5 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile --ignore-scripts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 32688379..86fce350 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.5 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6d08525c..89e8b59f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.5 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a06eeb21..11df9b9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.5 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile From 11c19f5fe5bd49e7fb5560f65e383e98fb3000fa Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Fri, 24 Oct 2025 20:20:51 -0300 Subject: [PATCH 09/26] chore: downgrade to latest of 34 --- package.json | 2 +- src/renderer/src/pages/game-details/game-reviews.tsx | 4 ---- yarn.lock | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 59497aad..f74825a1 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "@types/winreg": "^1.2.36", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.2.1", - "electron": "^35.7.5", + "electron": "^34.5.8", "electron-builder": "^26.0.12", "electron-vite": "^3.0.0", "eslint": "^8.56.0", 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/yarn.lock b/yarn.lock index 5ffc3f03..d936ff61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4658,7 +4658,7 @@ electron-vite@^3.0.0: magic-string "^0.30.17" picocolors "^1.1.1" -electron@^35.7.5: +electron@^35.2.1: version "35.7.5" resolved "https://registry.yarnpkg.com/electron/-/electron-35.7.5.tgz#294a4aebb2ad2a884de730c410f2358d061e8d53" integrity sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw== From 4471bf0f8bc9ee7e27a48dbfc2c9a3be3299f6a7 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Fri, 24 Oct 2025 21:05:40 -0300 Subject: [PATCH 10/26] chore: bump to electron 37 --- package.json | 4 ++-- yarn.lock | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index f74825a1..b34425a0 100644 --- a/package.json +++ b/package.json @@ -116,9 +116,9 @@ "@types/winreg": "^1.2.36", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.2.1", - "electron": "^34.5.8", + "electron": "^37.7.1", "electron-builder": "^26.0.12", - "electron-vite": "^3.0.0", + "electron-vite": "^3.1.0", "eslint": "^8.56.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", diff --git a/yarn.lock b/yarn.lock index d936ff61..c362ada8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4646,7 +4646,7 @@ electron-updater@^6.6.2: semver "^7.6.3" tiny-typed-emitter "^2.1.0" -electron-vite@^3.0.0: +electron-vite@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/electron-vite/-/electron-vite-3.1.0.tgz#1784907a83d23c6c8093ec68b8e414a74d814385" integrity sha512-M7aAzaRvSl5VO+6KN4neJCYLHLpF/iWo5ztchI/+wMxIieDZQqpbCYfaEHHHPH6eupEzfvZdLYdPdmvGqoVe0Q== @@ -4658,10 +4658,10 @@ electron-vite@^3.0.0: magic-string "^0.30.17" picocolors "^1.1.1" -electron@^35.2.1: - version "35.7.5" - resolved "https://registry.yarnpkg.com/electron/-/electron-35.7.5.tgz#294a4aebb2ad2a884de730c410f2358d061e8d53" - integrity sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw== +electron@^37.7.1: + version "37.7.1" + resolved "https://registry.yarnpkg.com/electron/-/electron-37.7.1.tgz#7d771b3d3365b5458f8bc758385defee14387034" + integrity sha512-2EmIqWv4T8BtgFQosB3/0Fezs09X3l0wXhIzes/cNt/GI+UDljbQr3NiF2J9WnqP0aFSbUEfztGUQMiX+qDvsw== dependencies: "@electron/get" "^2.0.0" "@types/node" "^22.7.7" From 87a57f7a37ea51a547a9fa213120710c44a7c173 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sun, 26 Oct 2025 23:22:20 +0000 Subject: [PATCH 11/26] feat: adding sources migration --- .../download-sources/add-download-source.ts | 16 +++++------ .../download-sources/get-download-sources.ts | 4 ++- src/main/helpers/migrate-download-sources.ts | 27 +++++++++++++++++++ src/main/main.ts | 2 ++ .../src/components/game-card/game-card.tsx | 2 -- .../settings/settings-download-sources.tsx | 2 ++ src/types/index.ts | 2 ++ 7 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 src/main/helpers/migrate-download-sources.ts diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts index 45bcd27c..d4e65ef3 100644 --- a/src/main/events/download-sources/add-download-source.ts +++ b/src/main/events/download-sources/add-download-source.ts @@ -2,6 +2,7 @@ import { registerEvent } from "../register-event"; import { HydraApi } from "@main/services/hydra-api"; import { downloadSourcesSublevel } from "@main/level"; import type { DownloadSource } from "@types"; +import { logger } from "@main/services"; const addDownloadSource = async ( _event: Electron.IpcMainInvokeEvent, @@ -22,22 +23,19 @@ const addDownloadSource = async ( urls: [url], }); } catch (error) { - console.error("Failed to add download source to profile:", error); + logger.error("Failed to add download source to profile:", error); } } - const downloadSourceForStorage = { + await downloadSourcesSublevel.put(downloadSource.id, { ...downloadSource, - fingerprint: downloadSource.fingerprint || "", - }; - await downloadSourcesSublevel.put( - downloadSource.id, - downloadSourceForStorage - ); + isRemote: true, + createdAt: new Date().toISOString(), + }); return downloadSource; } catch (error) { - console.error("Failed to add download source:", error); + logger.error("Failed to add download source:", error); throw error; } }; diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts index cf7cd4d7..48583d9e 100644 --- a/src/main/events/download-sources/get-download-sources.ts +++ b/src/main/events/download-sources/get-download-sources.ts @@ -1,8 +1,10 @@ import { downloadSourcesSublevel } from "@main/level"; import { registerEvent } from "../register-event"; +import { orderBy } from "lodash-es"; const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { - return downloadSourcesSublevel.values().all(); + const allSources = await downloadSourcesSublevel.values().all(); + return orderBy(allSources, "createdAt", "desc"); }; registerEvent("getDownloadSources", getDownloadSources); diff --git a/src/main/helpers/migrate-download-sources.ts b/src/main/helpers/migrate-download-sources.ts new file mode 100644 index 00000000..fd627f20 --- /dev/null +++ b/src/main/helpers/migrate-download-sources.ts @@ -0,0 +1,27 @@ +import { downloadSourcesSublevel } from "@main/level"; +import { HydraApi } from "@main/services/hydra-api"; +import { DownloadSource } from "@types"; + +export const migrateDownloadSources = async () => { + const downloadSources = downloadSourcesSublevel.iterator(); + + for await (const [key, value] of downloadSources) { + if (!value.isRemote) { + const downloadSource = await HydraApi.post( + "/download-sources", + { + url: value.url, + }, + { needsAuth: false } + ); + + await downloadSourcesSublevel.put(downloadSource.id, { + ...downloadSource, + isRemote: true, + createdAt: new Date().toISOString(), + }); + + await downloadSourcesSublevel.del(key); + } + } +}; diff --git a/src/main/main.ts b/src/main/main.ts index 617dd135..6e477a18 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -17,6 +17,7 @@ import { Lock, DeckyPlugin, } from "@main/services"; +import { migrateDownloadSources } from "./helpers/migrate-download-sources"; export const loadState = async () => { await Lock.acquireLock(); @@ -51,6 +52,7 @@ export const loadState = async () => { await HydraApi.setupApi().then(() => { uploadGamesBatch(); + void migrateDownloadSources(); // WSClient.connect(); }); diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx index 598874b5..edea8d50 100644 --- a/src/renderer/src/components/game-card/game-card.tsx +++ b/src/renderer/src/components/game-card/game-card.tsx @@ -38,8 +38,6 @@ export function GameCard({ game, ...props }: GameCardProps) { const { numberFormatter } = useFormat(); - console.log("game", game); - return (
diff --git a/src/renderer/src/pages/settings/settings-real-debrid.tsx b/src/renderer/src/pages/settings/settings-real-debrid.tsx index 42ba6ad9..db3a29a3 100644 --- a/src/renderer/src/pages/settings/settings-real-debrid.tsx +++ b/src/renderer/src/pages/settings/settings-real-debrid.tsx @@ -133,7 +133,7 @@ export function SettingsRealDebrid() { {t("save_changes")} } - placeholder="API Token" + placeholder={t("api_token")} hint={ diff --git a/src/renderer/src/pages/settings/settings-torbox.tsx b/src/renderer/src/pages/settings/settings-torbox.tsx index 610dc942..46c8e2f9 100644 --- a/src/renderer/src/pages/settings/settings-torbox.tsx +++ b/src/renderer/src/pages/settings/settings-torbox.tsx @@ -116,7 +116,7 @@ export function SettingsTorBox() { onChange={(event) => setForm({ ...form, torBoxApiToken: event.target.value }) } - placeholder="API Token" + placeholder={t("api_token")} rightContent={