feat: using api download sources

This commit is contained in:
Chubby Granny Chaser
2025-10-21 04:18:11 +01:00
parent c2273dbf71
commit 48ce9a2476
45 changed files with 295 additions and 1625 deletions

View File

@@ -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<DownloadSource>(
"/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);

View File

@@ -1,17 +0,0 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel } from "@main/level";
const checkDownloadSourceExists = async (
_event: Electron.IpcMainInvokeEvent,
url: string
): Promise<boolean> => {
for await (const [, source] of downloadSourcesSublevel.iterator()) {
if (source.url === url) {
return true;
}
}
return false;
};
registerEvent("checkDownloadSourceExists", checkDownloadSourceExists);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<string, number[]>;
let titleHashMappingCache: TitleHashMapping | null = null;
export const getTitleHashMapping = async (): Promise<TitleHashMapping> => {
if (titleHashMappingCache) {
return titleHashMappingCache;
}
try {
const cached =
ResourceCache.getCachedData<TitleHashMapping>("sources-manifest");
if (cached) {
titleHashMappingCache = cached;
return cached;
}
const fetched = await ResourceCache.fetchAndCache<TitleHashMapping>(
"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<string, { id: string; name: string }[]>;
export type FormattedSteamGame = {
id: string;
name: string;
formattedName: string;
};
export type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
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<string, DownloadSource>
> => {
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<boolean> => {
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<FormattedSteamGamesByLetter> => {
if (steamGamesFormattedCache) {
return steamGamesFormattedCache;
}
let steamGames: SteamGamesByLetter;
const cached = ResourceCache.getCachedData<SteamGamesByLetter>(
"steam-games-by-letter"
);
if (cached) {
steamGames = cached;
} else {
steamGames = await ResourceCache.fetchAndCache<SteamGamesByLetter>(
"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<number> => {
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<typeof downloadSourceSchema>["downloads"],
steamGames: FormattedSteamGamesByLetter
) => {
const now = new Date();
const objectIdsOnSource = new Set<string>();
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<z.infer<typeof downloadSourceSchema>>(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<z.infer<typeof downloadSourceSchema>>(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;
};

View File

@@ -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);

View File

@@ -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);
}
};

View File

@@ -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<number> => {
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<DownloadSource[]>(
"/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<string>();
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<string, string> = {};
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);

View File

@@ -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<number> => {
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);

View File

@@ -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<z.infer<typeof downloadSourceSchema>>(url);
const { name } = downloadSourceSchema.parse(response.data);
return {
name,
etag: response.headers["etag"] || null,
downloadCount: response.data.downloads.length,
};
};
registerEvent("validateDownloadSource", validateDownloadSource);

View File

@@ -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";

View File

@@ -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);

View File

@@ -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<string, DownloadSource>(
levelKeys.downloadSources,

View File

@@ -7,4 +7,3 @@ export * from "./game-achievements";
export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./repacks";

View File

@@ -18,5 +18,4 @@ export const levelKeys = {
screenState: "screenState",
rpcPassword: "rpcPassword",
downloadSources: "downloadSources",
repacks: "repacks",
};

View File

@@ -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<string, GameRepack>(
levelKeys.repacks,
{
valueEncoding: "json",
}
);

View File

@@ -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<string, UserPreferences | null>(
levelKeys.userPreferences,
{

View File

@@ -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<string, number[]>;
export type FormattedSteamGame = {
id: string;
name: string;
formattedName: string;
};
export type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
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<T>(data: unknown): Promise<T> {
if (!this.worker) {
return Promise.reject(new Error("Worker not initialized"));
}
const id = `msg_${++this.messageId}`;
const message: WorkerMessage = { id, data };
return new Promise<T>((resolve, reject) => {
this.pendingMessages.set(id, { resolve, reject });
this.worker!.postMessage(message);
});
}
public static async matchDownloads(
downloads: DownloadToMatch[],
steamGames: FormattedSteamGamesByLetter,
titleHashMapping: TitleHashMapping
): Promise<MatchResponse> {
return this.sendMessage<MatchResponse>({
downloads,
steamGames,
titleHashMapping,
});
}
public static terminate() {
if (this.worker) {
this.worker.terminate();
this.worker = null;
this.pendingMessages.clear();
}
}
}

View File

@@ -105,11 +105,6 @@ export class HydraApi {
// WSClient.close();
// WSClient.connect();
const { syncDownloadSourcesFromApi } = await import(
"../events/download-sources/sync-download-sources-from-api"
);
syncDownloadSourcesFromApi();
}
}

View File

@@ -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";

View File

@@ -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<T = unknown> {
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<T = unknown>(
resourceName: string
): CachedResource<T> | 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<T = unknown>(
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<T = unknown>(
resourceName: string,
url: string,
timeout: number = 10000
): Promise<T> {
const cached = this.readCachedResource<T>(resourceName);
const headers: Record<string, string> = {};
if (cached?.etag) {
headers["If-None-Match"] = cached.etag;
}
try {
const response = await axios.get<T>(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<T = unknown>(resourceName: string): T | null {
const cached = this.readCachedResource<T>(resourceName);
return cached?.data || null;
}
static async updateResourcesOnStartup(): Promise<void> {
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");
}
}

View File

@@ -1,158 +0,0 @@
import { parentPort } from "worker_threads";
import crypto from "node:crypto";
export type TitleHashMapping = Record<string, number[]>;
export type FormattedSteamGame = {
id: string;
name: string;
formattedName: string;
};
export type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
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),
});
}
});
}

View File

@@ -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: (

View File

@@ -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) {

View File

@@ -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>,
HTMLButtonElement
> {
game: any;
game: ShopAssets;
}
const shopIcon = {
@@ -28,13 +28,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const [stats, setStats] = useState<GameStats | null>(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 (
<button
@@ -75,18 +61,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
<p className="game-card__title">{game.title}</p>
</div>
{uniqueRepackers.length > 0 ? (
{game.downloadSources.length > 0 ? (
<ul className="game-card__download-options">
{firstThreeRepackers.map((repacker) => (
<li key={repacker}>
<Badge>{repacker}</Badge>
{game.downloadSources.slice(0, 3).map((sourceName) => (
<li key={sourceName}>
<Badge>{sourceName}</Badge>
</li>
))}
{remainingCount > 0 && (
{game.downloadSources.length > 3 && (
<li>
<Badge>
+{remainingCount}{" "}
{t("game_card:available", { count: remainingCount })}
+{game.downloadSources.length - 3}{" "}
{t("game_card:available", {
count: game.downloadSources.length - 3,
})}
</Badge>
</li>
)}

View File

@@ -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<GameRepack[]>([]);
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<GameRepack[]>(
`/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,

View File

@@ -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<DownloadSource>;
updateMissingFingerprints: () => Promise<number>;
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>;
getDownloadSources: () => Promise<
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[]
>;
deleteDownloadSource: (id: number) => Promise<void>;
deleteAllDownloadSources: () => Promise<void>;
validateDownloadSource: (
url: string
) => Promise<DownloadSourceValidationResult>;
syncDownloadSources: () => Promise<number>;
getDownloadSourcesList: () => Promise<DownloadSource[]>;
checkDownloadSourceExists: (url: string) => Promise<boolean>;
getAllRepacks: () => Promise<GameRepack[]>;
removeDownloadSource: (
removeAll = false,
downloadSourceId?: string
) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<DownloadSource[]>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;

View File

@@ -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;

View File

@@ -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";

View File

@@ -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";

View File

@@ -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<string[]>([]);
const [steamDevelopers, setSteamDevelopers] = useState<string[]>([]);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
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 };
}

View File

@@ -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 };
}

View File

@@ -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<AbortController | null>(null);
const cataloguePageRef = useRef<HTMLDivElement>(null);
const { steamDevelopers, steamPublishers } = useCatalogue();
const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue();
const { steamGenres, steamUserTags } = useAppSelector(
(state) => state.catalogueSearch
);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [results, setResults] = useState<CatalogueSearchResult[]>([]);
@@ -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 [

View File

@@ -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) {
<span className="game-item__genres">{genres.join(", ")}</span>
<div className="game-item__repackers">
{uniqueRepackers.map((repacker) => (
<Badge key={repacker}>{repacker}</Badge>
{game.downloadSources.map((sourceName) => (
<Badge key={sourceName}>{sourceName}</Badge>
))}
</div>
</div>

View File

@@ -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 (
<div className="game-details__reviews-section">
{showReviewPrompt &&

View File

@@ -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({
)}
<p className="repacks-modal__repack-info">
{repack.fileSize} - {repack.repacker} -{" "}
{repack.fileSize} - {repack.downloadSourceName} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
</p>

View File

@@ -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<ShopAssets[]>(
`/catalogue/${category}?${params.toString()}`,
{ needsAuth: false }
`/catalogue/${category}`,
{
params,
needsAuth: false,
}
);
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));

View File

@@ -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);
}
}

View File

@@ -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<AddDownloadSourceModalProps>) {
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<DownloadSourceValidationResult | null>(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}
>
<div className="add-download-source-modal__container">
<TextField
{...register("url")}
label={t("download_source_url")}
placeholder={t("insert_valid_json_url")}
error={errors.url?.message}
rightContent={
<form onSubmit={handleSubmit(onSubmit)}>
<TextField
{...register("url")}
label={t("download_source_url")}
placeholder={t("insert_valid_json_url")}
error={errors.url?.message}
/>
<div className="add-download-source-modal__actions">
<Button
type="button"
theme="outline"
className="add-download-source-modal__validate-button"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting || isLoading}
>
{t("validate_download_source")}
</Button>
}
/>
{validationResult && (
<div className="add-download-source-modal__validation-result">
<div className="add-download-source-modal__validation-info">
<h4>{validationResult?.name}</h4>
<small>
{t("found_download_option", {
count: validationResult?.downloadCount,
countFormatted:
validationResult?.downloadCount.toLocaleString(),
})}
</small>
</div>
<Button
type="button"
onClick={handleAddDownloadSource}
onClick={handleClose}
disabled={isLoading}
>
{t("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting || isLoading}>
{isLoading && (
<SyncIcon className="add-download-source-modal__spinner" />
)}
{isLoading ? t("importing") : t("import")}
{isLoading ? t("adding") : t("add_download_source")}
</Button>
</div>
)}
</form>
</div>
</Modal>
);

View File

@@ -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
}
>
<TrashIcon />
@@ -209,11 +189,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => setShowAddDownloadSourceModal(true)}
disabled={
isSyncingDownloadSources ||
isFetchingSources ||
isRemovingDownloadSource
}
disabled={isSyncingDownloadSources || isRemovingDownloadSource}
>
<PlusCircleIcon />
{t("add_download_source")}

View File

@@ -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,
},
});

View File

@@ -11,8 +11,10 @@ export enum Downloader {
}
export enum DownloadSourceStatus {
UpToDate,
Errored,
PendingMatching = "PENDING_MATCHING",
Matched = "MATCHED",
Matching = "MATCHING",
Failed = "FAILED",
}
export enum CatalogueCategory {

View File

@@ -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<ShopAssets, "libraryImageUrl">;
} & Pick<ShopAssets, "libraryImageUrl" | "downloadSources">;
export type LibraryGame = Game &
Partial<ShopAssets> & {