feat: removing dexie

This commit is contained in:
Chubby Granny Chaser
2025-10-14 13:15:09 +01:00
parent f9c585d12f
commit 1a99305aa0
49 changed files with 3346 additions and 3806 deletions

View File

@@ -57,7 +57,6 @@
"crc": "^4.3.2", "crc": "^4.3.2",
"create-desktop-shortcuts": "^1.11.1", "create-desktop-shortcuts": "^1.11.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dexie": "^4.0.10",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"embla-carousel-autoplay": "^8.6.0", "embla-carousel-autoplay": "^8.6.0",

View File

@@ -447,6 +447,7 @@
"found_download_option_one": "Found {{countFormatted}} download option", "found_download_option_one": "Found {{countFormatted}} download option",
"found_download_option_other": "Found {{countFormatted}} download options", "found_download_option_other": "Found {{countFormatted}} download options",
"import": "Import", "import": "Import",
"importing": "Importing...",
"public": "Public", "public": "Public",
"private": "Private", "private": "Private",
"friends_only": "Friends only", "friends_only": "Friends only",

View File

@@ -376,6 +376,7 @@
"found_download_option_one": "Encontrada {{countFormatted}} fuente de descarga", "found_download_option_one": "Encontrada {{countFormatted}} fuente de descarga",
"found_download_option_other": "Encontradas {{countFormatted}} opciones de descargas", "found_download_option_other": "Encontradas {{countFormatted}} opciones de descargas",
"import": "Importar", "import": "Importar",
"importing": "Importando...",
"public": "Público", "public": "Público",
"private": "Privado", "private": "Privado",
"friends_only": "Sólo amigos", "friends_only": "Sólo amigos",

View File

@@ -435,6 +435,7 @@
"found_download_option_one": "{{countFormatted}} opção de download encontrada", "found_download_option_one": "{{countFormatted}} opção de download encontrada",
"found_download_option_other": "{{countFormatted}} opções de download encontradas", "found_download_option_other": "{{countFormatted}} opções de download encontradas",
"import": "Importar", "import": "Importar",
"importing": "Importando...",
"privacy": "Privacidade", "privacy": "Privacidade",
"private": "Privado", "private": "Privado",
"friends_only": "Apenas amigos", "friends_only": "Apenas amigos",

View File

@@ -267,6 +267,7 @@
"found_download_option_one": "{{countFormatted}} opção de transferência encontrada", "found_download_option_one": "{{countFormatted}} opção de transferência encontrada",
"found_download_option_other": "{{countFormatted}} opções de transferência encontradas", "found_download_option_other": "{{countFormatted}} opções de transferência encontradas",
"import": "Importar", "import": "Importar",
"importing": "A importar...",
"privacy": "Privacidade", "privacy": "Privacidade",
"private": "Privado", "private": "Privado",
"friends_only": "Apenas amigos", "friends_only": "Apenas amigos",

View File

@@ -446,6 +446,7 @@
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки", "found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
"found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки", "found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки",
"import": "Импортировать", "import": "Импортировать",
"importing": "Импортируется...",
"public": "Публичный", "public": "Публичный",
"private": "Частный", "private": "Частный",
"friends_only": "Только для друзей", "friends_only": "Только для друзей",

View File

@@ -0,0 +1,42 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel } from "@main/level";
import { HydraApi } from "@main/services";
import { importDownloadSourceToLocal } from "./helpers";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const result = await importDownloadSourceToLocal(url, true);
if (!result) {
throw new Error("Failed to import download source");
}
await HydraApi.post("/profile/download-sources", {
urls: [url],
});
const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds: result.objectIds,
},
{ needsAuth: false }
);
const updatedSource = await downloadSourcesSublevel.get(`${result.id}`);
if (updatedSource) {
await downloadSourcesSublevel.put(`${result.id}`, {
...updatedSource,
fingerprint,
updatedAt: new Date(),
});
}
return {
...result,
fingerprint,
};
};
registerEvent("addDownloadSource", addDownloadSource);

View File

@@ -0,0 +1,17 @@
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 { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const createDownloadSources = async (
_event: Electron.IpcMainInvokeEvent,
urls: string[]
) => {
await HydraApi.post("/profile/download-sources", {
urls,
});
};
registerEvent("createDownloadSources", createDownloadSources);

View File

@@ -0,0 +1,10 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
const deleteAllDownloadSources = async (
_event: Electron.IpcMainInvokeEvent
) => {
await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]);
};
registerEvent("deleteAllDownloadSources", deleteAllDownloadSources);

View File

@@ -0,0 +1,25 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
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}`);
};
registerEvent("deleteDownloadSource", deleteDownloadSource);

View File

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,171 @@
import axios from "axios";
import { z } from "zod";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { DownloadSourceStatus } from "@shared";
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),
})
),
});
type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
const formatName = (name: string) => {
return name
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
};
const formatRepackName = (name: string) => {
return formatName(name.replace("[DL]", ""));
};
export const checkUrlExists = async (url: string): Promise<boolean> => {
for await (const [, source] of downloadSourcesSublevel.iterator()) {
if (source.url === url) {
return true;
}
}
return false;
};
const getSteamGames = async () => {
const response = await axios.get<SteamGamesByLetter>(
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
);
return response.data;
};
type SublevelIterator = AsyncIterable<[string, { id: number }]>;
interface SublevelWithId {
iterator: () => SublevelIterator;
}
const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
let maxId = 0;
for await (const [, value] of sublevel.iterator()) {
if (value.id > maxId) {
maxId = value.id;
}
}
return maxId + 1;
};
const addNewDownloads = async (
downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: SteamGamesByLetter
) => {
const now = new Date();
const objectIdsOnSource = new Set<string>();
let nextRepackId = await getNextId(repacksSublevel);
for (const download of downloads) {
const formattedTitle = formatRepackName(download.title);
const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || [];
const gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(formatName(game.name))
);
if (gamesInSteam.length === 0) continue;
for (const game of gamesInSteam) {
objectIdsOnSource.add(String(game.id));
}
const repack = {
id: nextRepackId++,
objectIds: gamesInSteam.map((game) => String(game.id)),
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
};
await repacksSublevel.put(`${repack.id}`, repack);
}
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 urlExistsBeforeInsert = await checkUrlExists(url);
if (urlExistsBeforeInsert) {
if (throwOnDuplicate) {
throw new Error("Download source with this URL already exists");
}
return null;
}
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
);
return {
...downloadSource,
objectIds,
};
};

View File

@@ -1,17 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const putDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
objectIds: string[]
) => {
return HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds,
},
{ needsAuth: false }
);
};
registerEvent("putDownloadSource", putDownloadSource);

View File

@@ -0,0 +1,26 @@
import { HydraApi } from "@main/services";
import { downloadSourcesSublevel } from "@main/level";
import { importDownloadSourceToLocal } from "./helpers";
export const syncDownloadSourcesFromApi = async () => {
try {
const apiSources = await HydraApi.get<
{ url: string; createdAt: string; updatedAt: string }[]
>("/profile/download-sources");
const localSources: { url: string }[] = [];
for await (const [, source] of downloadSourcesSublevel.iterator()) {
localSources.push(source);
}
const localUrls = new Set(localSources.map((s) => s.url));
for (const apiSource of apiSources) {
if (!localUrls.has(apiSource.url)) {
await importDownloadSourceToLocal(apiSource.url, false);
}
}
} catch (error) {
console.error("Failed to sync download sources from API:", error);
}
};

View File

@@ -0,0 +1,267 @@
import { registerEvent } from "../register-event";
import axios, { AxiosError } from "axios";
import { z } from "zod";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { DownloadSourceStatus } from "@shared";
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),
})
),
});
type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
const formatName = (name: string) => {
return name
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
};
const formatRepackName = (name: string) => {
return formatName(name.replace("[DL]", ""));
};
const checkUrlExists = async (url: string): Promise<boolean> => {
for await (const [, source] of downloadSourcesSublevel.iterator()) {
if (source.url === url) {
return true;
}
}
return false;
};
const getSteamGames = async () => {
const response = await axios.get<SteamGamesByLetter>(
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
);
return response.data;
};
type SublevelIterator = AsyncIterable<[string, { id: number }]>;
interface SublevelWithId {
iterator: () => SublevelIterator;
}
const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
let maxId = 0;
for await (const [, value] of sublevel.iterator()) {
if (value.id > maxId) {
maxId = value.id;
}
}
return maxId + 1;
};
const addNewDownloads = async (
downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: SteamGamesByLetter
) => {
const now = new Date();
const objectIdsOnSource = new Set<string>();
let nextRepackId = await getNextId(repacksSublevel);
for (const download of downloads) {
const formattedTitle = formatRepackName(download.title);
const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || [];
const gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(formatName(game.name))
);
if (gamesInSteam.length === 0) continue;
for (const game of gamesInSteam) {
objectIdsOnSource.add(String(game.id));
}
const repack = {
id: nextRepackId++,
objectIds: gamesInSteam.map((game) => String(game.id)),
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
};
await repacksSublevel.put(`${repack.id}`, repack);
}
const existingSource = await downloadSourcesSublevel.get(
`${downloadSource.id}`
);
if (existingSource) {
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
...existingSource,
objectIds: Array.from(objectIdsOnSource),
});
}
};
const deleteDownloadSource = async (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}`);
};
const importDownloadSource = async (url: string) => {
const urlExists = await checkUrlExists(url);
if (urlExists) {
return;
}
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const steamGames = await getSteamGames();
const now = new Date();
const urlExistsBeforeInsert = await checkUrlExists(url);
if (urlExistsBeforeInsert) {
return;
}
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);
await addNewDownloads(downloadSource, response.data.downloads, steamGames);
};
const syncDownloadSources = async (
_event: Electron.IpcMainInvokeEvent
): Promise<number> => {
let newRepacksCount = 0;
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 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;
}> = [];
for await (const [, repack] of repacksSublevel.iterator()) {
existingRepacks.push(repack);
}
if (downloadSources.some((source) => !source.fingerprint)) {
await Promise.all(
downloadSources.map(async (source) => {
await deleteDownloadSource(source.id);
await importDownloadSource(source.url);
})
);
} else {
for (const downloadSource of downloadSources) {
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();
const repacks = source.downloads.filter(
(download) =>
!existingRepacks.some((repack) => repack.title === 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,
});
}
}
}
return newRepacksCount;
} catch (err) {
return -1;
}
};
registerEvent("syncDownloadSources", syncDownloadSources);

View File

@@ -0,0 +1,67 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel } from "@main/level";
import { HydraApi } 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;
}
console.log(
`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) {
console.error(
`Failed to update fingerprint for source ${source.id}:`,
error
);
}
})
);
return sourcesNeedingFingerprints.length;
};
registerEvent("updateMissingFingerprints", updateMissingFingerprints);

View File

@@ -0,0 +1,32 @@
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,7 +63,15 @@ import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid"; import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-all-debrid"; import "./user-preferences/authenticate-all-debrid";
import "./user-preferences/authenticate-torbox"; import "./user-preferences/authenticate-torbox";
import "./download-sources/put-download-source"; 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/sign-out";
import "./auth/open-auth-window"; import "./auth/open-auth-window";
import "./auth/get-session-hash"; import "./auth/get-session-hash";
@@ -91,7 +99,6 @@ import "./themes/get-custom-theme-by-id";
import "./themes/get-active-custom-theme"; import "./themes/get-active-custom-theme";
import "./themes/close-editor-window"; import "./themes/close-editor-window";
import "./themes/toggle-custom-theme"; import "./themes/toggle-custom-theme";
import "./download-sources/create-download-sources";
import "./download-sources/remove-download-source"; import "./download-sources/remove-download-source";
import "./download-sources/get-download-sources"; import "./download-sources/get-download-sources";
import { isPortableVersion } from "@main/helpers"; import { isPortableVersion } from "@main/helpers";

View File

@@ -19,7 +19,6 @@ const getAllCustomGameAssets = async (): Promise<string[]> => {
}; };
const getUsedAssetPaths = async (): Promise<Set<string>> => { const getUsedAssetPaths = async (): Promise<Set<string>> => {
// Get all custom games from the level database
const { gamesSublevel } = await import("@main/level"); const { gamesSublevel } = await import("@main/level");
const allGames = await gamesSublevel.iterator().all(); const allGames = await gamesSublevel.iterator().all();
@@ -30,7 +29,6 @@ const getUsedAssetPaths = async (): Promise<Set<string>> => {
const usedPaths = new Set<string>(); const usedPaths = new Set<string>();
customGames.forEach((game) => { customGames.forEach((game) => {
// Extract file paths from local URLs
if (game.iconUrl?.startsWith("local:")) { if (game.iconUrl?.startsWith("local:")) {
usedPaths.add(game.iconUrl.replace("local:", "")); usedPaths.add(game.iconUrl.replace("local:", ""));
} }

View File

@@ -13,29 +13,23 @@ const copyCustomGameAsset = async (
throw new Error("Source file does not exist"); throw new Error("Source file does not exist");
} }
// Ensure assets directory exists
if (!fs.existsSync(ASSETS_PATH)) { if (!fs.existsSync(ASSETS_PATH)) {
fs.mkdirSync(ASSETS_PATH, { recursive: true }); fs.mkdirSync(ASSETS_PATH, { recursive: true });
} }
// Create custom games assets subdirectory
const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games"); const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games");
if (!fs.existsSync(customGamesAssetsPath)) { if (!fs.existsSync(customGamesAssetsPath)) {
fs.mkdirSync(customGamesAssetsPath, { recursive: true }); fs.mkdirSync(customGamesAssetsPath, { recursive: true });
} }
// Get file extension
const fileExtension = path.extname(sourcePath); const fileExtension = path.extname(sourcePath);
// Generate unique filename
const uniqueId = randomUUID(); const uniqueId = randomUUID();
const fileName = `${assetType}-${uniqueId}${fileExtension}`; const fileName = `${assetType}-${uniqueId}${fileExtension}`;
const destinationPath = path.join(customGamesAssetsPath, fileName); const destinationPath = path.join(customGamesAssetsPath, fileName);
// Copy the file
await fs.promises.copyFile(sourcePath, destinationPath); await fs.promises.copyFile(sourcePath, destinationPath);
// Return the local URL format
return `local:${destinationPath}`; return `local:${destinationPath}`;
}; };

View File

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,22 @@
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;
}
export const downloadSourcesSublevel = db.sublevel<string, DownloadSource>(
levelKeys.downloadSources,
{
valueEncoding: "json",
}
);

View File

@@ -6,3 +6,5 @@ export * from "./game-stats-cache";
export * from "./game-achievements"; export * from "./game-achievements";
export * from "./keys"; export * from "./keys";
export * from "./themes"; export * from "./themes";
export * from "./download-sources";
export * from "./repacks";

View File

@@ -17,4 +17,6 @@ export const levelKeys = {
language: "language", language: "language",
screenState: "screenState", screenState: "screenState",
rpcPassword: "rpcPassword", rpcPassword: "rpcPassword",
downloadSources: "downloadSources",
repacks: "repacks",
}; };

View File

@@ -0,0 +1,22 @@
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

@@ -102,8 +102,14 @@ export class HydraApi {
WindowManager.mainWindow.webContents.send("on-signin"); WindowManager.mainWindow.webContents.send("on-signin");
await clearGamesRemoteIds(); await clearGamesRemoteIds();
uploadGamesBatch(); uploadGamesBatch();
// WSClient.close(); // WSClient.close();
// WSClient.connect(); // WSClient.connect();
const { syncDownloadSourcesFromApi } = await import(
"../events/download-sources/sync-download-sources-from-api"
);
syncDownloadSourcesFromApi();
} }
} }

View File

@@ -99,13 +99,24 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("authenticateTorBox", apiToken), ipcRenderer.invoke("authenticateTorBox", apiToken),
/* Download sources */ /* Download sources */
putDownloadSource: (objectIds: string[]) => addDownloadSource: (url: string) =>
ipcRenderer.invoke("putDownloadSource", objectIds), ipcRenderer.invoke("addDownloadSource", url),
createDownloadSources: (urls: string[]) => updateMissingFingerprints: () =>
ipcRenderer.invoke("createDownloadSources", urls), ipcRenderer.invoke("updateMissingFingerprints"),
removeDownloadSource: (url: string, removeAll?: boolean) => removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll), ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), 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 */ /* Library */
toggleAutomaticCloudSync: ( toggleAutomaticCloudSync: (

View File

@@ -20,14 +20,12 @@ import {
setUserDetails, setUserDetails,
setProfileBackground, setProfileBackground,
setGameRunning, setGameRunning,
setIsImportingSources,
} from "@renderer/features"; } from "@renderer/features";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { downloadSourcesWorker } from "./workers";
import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription"; import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { generateUUID } from "./helpers";
import { injectCustomCss, removeCustomCss } from "./helpers"; import { injectCustomCss, removeCustomCss } from "./helpers";
import "./app.scss"; import "./app.scss";
@@ -137,15 +135,6 @@ export function App() {
}, [fetchUserDetails, updateUserDetails, dispatch]); }, [fetchUserDetails, updateUserDetails, dispatch]);
const onSignIn = useCallback(() => { const onSignIn = useCallback(() => {
window.electron.getDownloadSources().then((sources) => {
sources.forEach((source) => {
downloadSourcesWorker.postMessage([
"IMPORT_DOWNLOAD_SOURCE",
source.url,
]);
});
});
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
@@ -211,41 +200,34 @@ export function App() {
}, [dispatch, draggingDisabled]); }, [dispatch, draggingDisabled]);
useEffect(() => { useEffect(() => {
updateRepacks(); (async () => {
dispatch(setIsImportingSources(true));
const id = generateUUID(); try {
const channel = new BroadcastChannel(`download_sources:sync:${id}`); // Initial repacks load
await updateRepacks();
channel.onmessage = async (event: MessageEvent<number>) => { // Sync all local sources (check for updates)
const newRepacksCount = event.data; const newRepacksCount = await window.electron.syncDownloadSources();
window.electron.publishNewRepacksNotification(newRepacksCount);
updateRepacks();
const downloadSources = await downloadSourcesTable.toArray(); if (newRepacksCount > 0) {
window.electron.publishNewRepacksNotification(newRepacksCount);
}
await Promise.all( // Update fingerprints for sources that don't have them
downloadSources await window.electron.updateMissingFingerprints();
.filter((source) => !source.fingerprint)
.map(async (downloadSource) => {
const { fingerprint } = await window.electron.putDownloadSource(
downloadSource.objectIds
);
return downloadSourcesTable.update(downloadSource.id, { // Update repacks AFTER all syncing and fingerprint updates are complete
fingerprint, await updateRepacks();
}); } catch (error) {
}) console.error("Error syncing download sources:", error);
); // Still update repacks even if sync fails
await updateRepacks();
channel.close(); } finally {
}; dispatch(setIsImportingSources(false));
}
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); })();
}, [updateRepacks, dispatch]);
return () => {
channel.close();
};
}, [updateRepacks]);
const loadAndApplyTheme = useCallback(async () => { const loadAndApplyTheme = useCallback(async () => {
const activeTheme = await window.electron.getActiveCustomTheme(); const activeTheme = await window.electron.getActiveCustomTheme();

View File

@@ -109,12 +109,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
</span> </span>
</div> </div>
<div className="game-card__specifics-item"> <div className="game-card__specifics-item">
<StarRating <StarRating rating={stats?.averageScore || null} size={14} />
rating={stats?.averageScore || null}
size={14}
showCalculating={!!(stats && stats.averageScore === null)}
calculatingText={t("calculating")}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,76 +1,31 @@
import { StarIcon, StarFillIcon } from "@primer/octicons-react"; import { StarFillIcon } from "@primer/octicons-react";
import "./star-rating.scss"; import "./star-rating.scss";
export interface StarRatingProps { export interface StarRatingProps {
rating: number | null; rating: number | null;
maxStars?: number;
size?: number; size?: number;
showCalculating?: boolean;
calculatingText?: string;
hideIcon?: boolean;
} }
export function StarRating({ export function StarRating({ rating, size = 12 }: Readonly<StarRatingProps>) {
rating,
maxStars = 5,
size = 12,
showCalculating = false,
calculatingText = "Calculating",
hideIcon = false,
}: Readonly<StarRatingProps>) {
if (rating === null && showCalculating) {
return (
<div className="star-rating star-rating--calculating">
{!hideIcon && <StarIcon size={size} />}
<span className="star-rating__calculating-text">{calculatingText}</span>
</div>
);
}
if (rating === null || rating === undefined) { if (rating === null || rating === undefined) {
return ( return (
<div className="star-rating star-rating--no-rating"> <div className="star-rating star-rating--single">
{!hideIcon && <StarIcon size={size} />}
<span className="star-rating__no-rating-text"></span>
</div>
);
}
const filledStars = Math.floor(rating);
const hasHalfStar = rating % 1 >= 0.5;
const emptyStars = maxStars - filledStars - (hasHalfStar ? 1 : 0);
return (
<div className="star-rating">
{Array.from({ length: filledStars }, (_, index) => (
<StarFillIcon <StarFillIcon
key={`filled-${index}`}
size={size} size={size}
className="star-rating__star star-rating__star--filled" className="star-rating__star star-rating__star--filled"
/> />
))} <span className="star-rating__value"></span>
</div>
{hasHalfStar && ( );
<div className="star-rating__half-star" key="half-star"> }
<StarIcon
size={size}
className="star-rating__star star-rating__star--empty"
/>
<StarFillIcon
size={size}
className="star-rating__star star-rating__star--half"
/>
</div>
)}
{Array.from({ length: emptyStars }, (_, index) => (
<StarIcon
key={`empty-${index}`}
size={size}
className="star-rating__star star-rating__star--empty"
/>
))}
// Always use single star mode with numeric score
return (
<div className="star-rating star-rating--single">
<StarFillIcon
size={size}
className="star-rating__star star-rating__star--filled"
/>
<span className="star-rating__value">{rating.toFixed(1)}</span> <span className="star-rating__value">{rating.toFixed(1)}</span>
</div> </div>
); );

View File

@@ -31,6 +31,9 @@ import type {
AchievementNotificationInfo, AchievementNotificationInfo,
Game, Game,
DiskUsage, DiskUsage,
DownloadSource,
DownloadSourceValidationResult,
GameRepack,
} from "@types"; } from "@types";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
@@ -210,14 +213,21 @@ declare global {
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>; createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
/* Download sources */ /* Download sources */
putDownloadSource: ( addDownloadSource: (url: string) => Promise<DownloadSource>;
objectIds: string[] updateMissingFingerprints: () => Promise<number>;
) => Promise<{ fingerprint: string }>;
createDownloadSources: (urls: string[]) => Promise<void>;
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>; removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>;
getDownloadSources: () => Promise< getDownloadSources: () => Promise<
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[] 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[]>;
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>; getDiskFreeSpace: (path: string) => Promise<DiskUsage>;

View File

@@ -1,27 +0,0 @@
import type { GameShop, HowLongToBeatCategory } from "@types";
import { Dexie } from "dexie";
export interface HowLongToBeatEntry {
id?: number;
objectId: string;
categories: HowLongToBeatCategory[];
shop: GameShop;
createdAt: Date;
updatedAt: Date;
}
export const db = new Dexie("Hydra");
db.version(9).stores({
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`,
downloadSources: `++id, &url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
});
export const downloadSourcesTable = db.table("downloadSources");
export const repacksTable = db.table("repacks");
export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
"howLongToBeatEntries"
);
db.open();

View File

@@ -0,0 +1,21 @@
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

@@ -7,4 +7,5 @@ export * from "./user-details-slice";
export * from "./game-running.slice"; export * from "./game-running.slice";
export * from "./subscription-slice"; export * from "./subscription-slice";
export * from "./repacks-slice"; export * from "./repacks-slice";
export * from "./download-sources-slice";
export * from "./catalogue-search"; export * from "./catalogue-search";

View File

@@ -1,4 +1,3 @@
import { repacksTable } from "@renderer/dexie";
import { setRepacks } from "@renderer/features"; import { setRepacks } from "@renderer/features";
import { useCallback } from "react"; import { useCallback } from "react";
import { RootState } from "@renderer/store"; import { RootState } from "@renderer/store";
@@ -16,18 +15,11 @@ export function useRepacks() {
[repacks] [repacks]
); );
const updateRepacks = useCallback(() => { const updateRepacks = useCallback(async () => {
repacksTable.toArray().then((repacks) => { const repacks = await window.electron.getAllRepacks();
dispatch( dispatch(
setRepacks( setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds)))
JSON.parse( );
JSON.stringify(
repacks.filter((repack) => Array.isArray(repack.objectIds))
)
)
)
);
});
}, [dispatch]); }, [dispatch]);
return { getRepacksForObjectId, updateRepacks }; return { getRepacksForObjectId, updateRepacks };

View File

@@ -1,16 +1,10 @@
import type { CatalogueSearchResult, DownloadSource } from "@types"; import type { CatalogueSearchResult, DownloadSource } from "@types";
import { import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
useAppDispatch,
useAppSelector,
useFormat,
useRepacks,
} from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import "./catalogue.scss"; import "./catalogue.scss";
import { downloadSourcesTable } from "@renderer/dexie";
import { FilterSection } from "./filter-section"; import { FilterSection } from "./filter-section";
import { setFilters, setPage } from "@renderer/features"; import { setFilters, setPage } from "@renderer/features";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -56,8 +50,6 @@ export default function Catalogue() {
const { t, i18n } = useTranslation("catalogue"); const { t, i18n } = useTranslation("catalogue");
const { getRepacksForObjectId } = useRepacks();
const debouncedSearch = useRef( const debouncedSearch = useRef(
debounce(async (filters, pageSize, offset) => { debounce(async (filters, pageSize, offset) => {
const abortController = new AbortController(); const abortController = new AbortController();
@@ -95,10 +87,10 @@ export default function Catalogue() {
}, [filters, page, debouncedSearch]); }, [filters, page, debouncedSearch]);
useEffect(() => { useEffect(() => {
downloadSourcesTable.toArray().then((sources) => { window.electron.getDownloadSourcesList().then((sources) => {
setDownloadSources(sources.filter((source) => !!source.fingerprint)); setDownloadSources(sources.filter((source) => !!source.fingerprint));
}); });
}, [getRepacksForObjectId]); }, []);
const language = i18n.language.split("-")[0]; const language = i18n.language.split("-")[0];
@@ -192,13 +184,15 @@ export default function Catalogue() {
}, },
{ {
title: t("download_sources"), title: t("download_sources"),
items: downloadSources.map((source) => ({ items: downloadSources
label: source.name, .filter((source) => source.fingerprint)
value: source.fingerprint, .map((source) => ({
checked: filters.downloadSourceFingerprints.includes( label: source.name,
source.fingerprint value: source.fingerprint!,
), checked: filters.downloadSourceFingerprints.includes(
})), source.fingerprint!
),
})),
key: "downloadSourceFingerprints", key: "downloadSourceFingerprints",
}, },
{ {

View File

@@ -19,6 +19,68 @@ export interface EditGameModalProps {
type AssetType = "icon" | "logo" | "hero"; type AssetType = "icon" | "logo" | "hero";
interface ElectronFile extends File {
path?: string;
}
interface GameWithOriginalAssets extends Game {
originalIconPath?: string;
originalLogoPath?: string;
originalHeroPath?: string;
}
interface LibraryGameWithCustomOriginalAssets extends LibraryGame {
customOriginalIconPath?: string;
customOriginalLogoPath?: string;
customOriginalHeroPath?: string;
}
interface AssetPaths {
icon: string;
logo: string;
hero: string;
}
interface AssetUrls {
icon: string | null;
logo: string | null;
hero: string | null;
}
interface RemovedAssets {
icon: boolean;
logo: boolean;
hero: boolean;
}
const VALID_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
] as const;
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp"] as const;
const INITIAL_ASSET_PATHS: AssetPaths = {
icon: "",
logo: "",
hero: "",
};
const INITIAL_REMOVED_ASSETS: RemovedAssets = {
icon: false,
logo: false,
hero: false,
};
const INITIAL_ASSET_URLS: AssetUrls = {
icon: null,
logo: null,
hero: null,
};
export function EditGameModal({ export function EditGameModal({
visible, visible,
onClose, onClose,
@@ -30,33 +92,18 @@ export function EditGameModal({
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
const [gameName, setGameName] = useState(""); const [gameName, setGameName] = useState("");
const [assetPaths, setAssetPaths] = useState({ const [assetPaths, setAssetPaths] = useState<AssetPaths>(INITIAL_ASSET_PATHS);
icon: "", const [assetDisplayPaths, setAssetDisplayPaths] =
logo: "", useState<AssetPaths>(INITIAL_ASSET_PATHS);
hero: "", const [originalAssetPaths, setOriginalAssetPaths] =
}); useState<AssetPaths>(INITIAL_ASSET_PATHS);
const [assetDisplayPaths, setAssetDisplayPaths] = useState({ const [removedAssets, setRemovedAssets] = useState<RemovedAssets>(
icon: "", INITIAL_REMOVED_ASSETS
logo: "", );
hero: "", const [defaultUrls, setDefaultUrls] = useState<AssetUrls>(INITIAL_ASSET_URLS);
});
const [originalAssetPaths, setOriginalAssetPaths] = useState({
icon: "",
logo: "",
hero: "",
});
const [removedAssets, setRemovedAssets] = useState({
icon: false,
logo: false,
hero: false,
});
const [defaultUrls, setDefaultUrls] = useState({
icon: null as string | null,
logo: null as string | null,
hero: null as string | null,
});
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [selectedAssetType, setSelectedAssetType] = useState<AssetType>("icon"); const [selectedAssetType, setSelectedAssetType] = useState<AssetType>("icon");
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
const isCustomGame = (game: LibraryGame | Game): boolean => { const isCustomGame = (game: LibraryGame | Game): boolean => {
return game.shop === "custom"; return game.shop === "custom";
@@ -66,12 +113,18 @@ export function EditGameModal({
return url?.startsWith("local:") ? url.replace("local:", "") : ""; return url?.startsWith("local:") ? url.replace("local:", "") : "";
}; };
const capitalizeAssetType = (assetType: AssetType): string => {
return assetType.charAt(0).toUpperCase() + assetType.slice(1);
};
const setCustomGameAssets = useCallback((game: LibraryGame | Game) => { const setCustomGameAssets = useCallback((game: LibraryGame | Game) => {
// Check if assets were removed (URLs are null but original paths exist) const gameWithAssets = game as GameWithOriginalAssets;
const iconRemoved = !game.iconUrl && (game as any).originalIconPath; const iconRemoved =
const logoRemoved = !game.logoImageUrl && (game as any).originalLogoPath; !game.iconUrl && Boolean(gameWithAssets.originalIconPath);
const logoRemoved =
!game.logoImageUrl && Boolean(gameWithAssets.originalLogoPath);
const heroRemoved = const heroRemoved =
!game.libraryHeroImageUrl && (game as any).originalHeroPath; !game.libraryHeroImageUrl && Boolean(gameWithAssets.originalHeroPath);
setAssetPaths({ setAssetPaths({
icon: extractLocalPath(game.iconUrl), icon: extractLocalPath(game.iconUrl),
@@ -84,15 +137,14 @@ export function EditGameModal({
hero: extractLocalPath(game.libraryHeroImageUrl), hero: extractLocalPath(game.libraryHeroImageUrl),
}); });
setOriginalAssetPaths({ setOriginalAssetPaths({
icon: (game as any).originalIconPath || extractLocalPath(game.iconUrl), icon: gameWithAssets.originalIconPath || extractLocalPath(game.iconUrl),
logo: logo:
(game as any).originalLogoPath || extractLocalPath(game.logoImageUrl), gameWithAssets.originalLogoPath || extractLocalPath(game.logoImageUrl),
hero: hero:
(game as any).originalHeroPath || gameWithAssets.originalHeroPath ||
extractLocalPath(game.libraryHeroImageUrl), extractLocalPath(game.libraryHeroImageUrl),
}); });
// Set removed assets state based on whether assets were explicitly removed
setRemovedAssets({ setRemovedAssets({
icon: iconRemoved, icon: iconRemoved,
logo: logoRemoved, logo: logoRemoved,
@@ -102,13 +154,15 @@ export function EditGameModal({
const setNonCustomGameAssets = useCallback( const setNonCustomGameAssets = useCallback(
(game: LibraryGame) => { (game: LibraryGame) => {
// Check if assets were removed (custom URLs are null but original paths exist) const gameWithAssets = game as LibraryGameWithCustomOriginalAssets;
const iconRemoved = const iconRemoved =
!game.customIconUrl && (game as any).customOriginalIconPath; !game.customIconUrl && Boolean(gameWithAssets.customOriginalIconPath);
const logoRemoved = const logoRemoved =
!game.customLogoImageUrl && (game as any).customOriginalLogoPath; !game.customLogoImageUrl &&
Boolean(gameWithAssets.customOriginalLogoPath);
const heroRemoved = const heroRemoved =
!game.customHeroImageUrl && (game as any).customOriginalHeroPath; !game.customHeroImageUrl &&
Boolean(gameWithAssets.customOriginalHeroPath);
setAssetPaths({ setAssetPaths({
icon: extractLocalPath(game.customIconUrl), icon: extractLocalPath(game.customIconUrl),
@@ -122,17 +176,16 @@ export function EditGameModal({
}); });
setOriginalAssetPaths({ setOriginalAssetPaths({
icon: icon:
(game as any).customOriginalIconPath || gameWithAssets.customOriginalIconPath ||
extractLocalPath(game.customIconUrl), extractLocalPath(game.customIconUrl),
logo: logo:
(game as any).customOriginalLogoPath || gameWithAssets.customOriginalLogoPath ||
extractLocalPath(game.customLogoImageUrl), extractLocalPath(game.customLogoImageUrl),
hero: hero:
(game as any).customOriginalHeroPath || gameWithAssets.customOriginalHeroPath ||
extractLocalPath(game.customHeroImageUrl), extractLocalPath(game.customHeroImageUrl),
}); });
// Set removed assets state based on whether assets were explicitly removed
setRemovedAssets({ setRemovedAssets({
icon: iconRemoved, icon: iconRemoved,
logo: logoRemoved, logo: logoRemoved,
@@ -171,29 +224,22 @@ export function EditGameModal({
setSelectedAssetType(assetType); setSelectedAssetType(assetType);
}; };
const getAssetPath = (assetType: AssetType): string => {
return assetPaths[assetType];
};
const getAssetDisplayPath = (assetType: AssetType): string => { const getAssetDisplayPath = (assetType: AssetType): string => {
// If asset was removed, don't show any path
if (removedAssets[assetType]) { if (removedAssets[assetType]) {
return ""; return "";
} }
// Use display path first, then fall back to original path
return assetDisplayPaths[assetType] || originalAssetPaths[assetType]; return assetDisplayPaths[assetType] || originalAssetPaths[assetType];
}; };
const setAssetPath = (assetType: AssetType, path: string): void => { const updateAssetPaths = (
assetType: AssetType,
path: string,
displayPath: string
): void => {
setAssetPaths((prev) => ({ ...prev, [assetType]: path })); setAssetPaths((prev) => ({ ...prev, [assetType]: path }));
}; setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: displayPath }));
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: displayPath }));
const setAssetDisplayPath = (assetType: AssetType, path: string): void => { setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: path }));
};
const getDefaultUrl = (assetType: AssetType): string | null => {
return defaultUrls[assetType];
}; };
const getOriginalAssetUrl = (assetType: AssetType): string | null => { const getOriginalAssetUrl = (assetType: AssetType): string | null => {
@@ -217,7 +263,7 @@ export function EditGameModal({
filters: [ filters: [
{ {
name: t("edit_game_modal_image_filter"), name: t("edit_game_modal_image_filter"),
extensions: ["jpg", "jpeg", "png", "gif", "webp"], extensions: [...IMAGE_EXTENSIONS],
}, },
], ],
}); });
@@ -229,41 +275,26 @@ export function EditGameModal({
originalPath, originalPath,
assetType assetType
); );
setAssetPath(assetType, copiedAssetUrl.replace("local:", "")); updateAssetPaths(
setAssetDisplayPath(assetType, originalPath); assetType,
// Store the original path for display purposes copiedAssetUrl.replace("local:", ""),
setOriginalAssetPaths((prev) => ({ originalPath
...prev, );
[assetType]: originalPath,
}));
// Clear the removed flag when a new asset is selected
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
} catch (error) { } catch (error) {
console.error(`Failed to copy ${assetType} asset:`, error); console.error(`Failed to copy ${assetType} asset:`, error);
setAssetPath(assetType, originalPath); updateAssetPaths(assetType, originalPath, originalPath);
setAssetDisplayPath(assetType, originalPath);
setOriginalAssetPaths((prev) => ({
...prev,
[assetType]: originalPath,
}));
// Clear the removed flag when a new asset is selected
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
} }
} }
}; };
const handleRestoreDefault = (assetType: AssetType) => { const handleRestoreDefault = (assetType: AssetType) => {
// Mark asset as removed and clear paths (for both custom and non-custom games)
setRemovedAssets((prev) => ({ ...prev, [assetType]: true })); setRemovedAssets((prev) => ({ ...prev, [assetType]: true }));
setAssetPath(assetType, ""); setAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
setAssetDisplayPath(assetType, ""); setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: "" }));
// Don't clear originalAssetPaths - keep them for reference but don't use them for display
}; };
const getOriginalTitle = (): string => { const getOriginalTitle = (): string => {
if (!game) return ""; if (!game) return "";
// For non-custom games, the original title is from shopDetails assets
return shopDetails?.assets?.title || game.title || ""; return shopDetails?.assets?.title || game.title || "";
}; };
@@ -274,12 +305,10 @@ export function EditGameModal({
const isTitleChanged = useMemo((): boolean => { const isTitleChanged = useMemo((): boolean => {
if (!game || isCustomGame(game)) return false; if (!game || isCustomGame(game)) return false;
const originalTitle = getOriginalTitle(); const originalTitle = shopDetails?.assets?.title || game.title || "";
return gameName.trim() !== originalTitle.trim(); return gameName.trim() !== originalTitle.trim();
}, [game, gameName, shopDetails]); }, [game, gameName, shopDetails]);
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -300,14 +329,9 @@ export function EditGameModal({
}; };
const validateImageFile = (file: File): boolean => { const validateImageFile = (file: File): boolean => {
const validTypes = [ return VALID_IMAGE_TYPES.includes(
"image/jpeg", file.type as (typeof VALID_IMAGE_TYPES)[number]
"image/jpg", );
"image/png",
"image/gif",
"image/webp",
];
return validTypes.includes(file.type);
}; };
const processDroppedFile = async (file: File, assetType: AssetType) => { const processDroppedFile = async (file: File, assetType: AssetType) => {
@@ -321,10 +345,6 @@ export function EditGameModal({
try { try {
let filePath: string; let filePath: string;
interface ElectronFile extends File {
path?: string;
}
if ("path" in file && typeof (file as ElectronFile).path === "string") { if ("path" in file && typeof (file as ElectronFile).path === "string") {
filePath = (file as ElectronFile).path!; filePath = (file as ElectronFile).path!;
} else { } else {
@@ -351,12 +371,13 @@ export function EditGameModal({
assetType assetType
); );
const assetPath = copiedAssetUrl.replace("local:", ""); updateAssetPaths(
setAssetPath(assetType, assetPath); assetType,
setAssetDisplayPath(assetType, filePath); copiedAssetUrl.replace("local:", ""),
filePath
);
showSuccessToast( showSuccessToast(
`${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!` `${capitalizeAssetType(assetType)} updated successfully!`
); );
if (!("path" in file) && filePath) { if (!("path" in file) && filePath) {
@@ -387,63 +408,45 @@ export function EditGameModal({
} }
}; };
// Helper function to prepare custom game assets
const prepareCustomGameAssets = (game: LibraryGame | Game) => { const prepareCustomGameAssets = (game: LibraryGame | Game) => {
// For custom games, check if asset was explicitly removed const iconUrl = removedAssets.icon
let iconUrl; ? null
if (removedAssets.icon) { : assetPaths.icon
iconUrl = null; ? `local:${assetPaths.icon}`
} else if (assetPaths.icon) { : game.iconUrl;
iconUrl = `local:${assetPaths.icon}`;
} else {
iconUrl = game.iconUrl;
}
let logoImageUrl; const logoImageUrl = removedAssets.logo
if (removedAssets.logo) { ? null
logoImageUrl = null; : assetPaths.logo
} else if (assetPaths.logo) { ? `local:${assetPaths.logo}`
logoImageUrl = `local:${assetPaths.logo}`; : game.logoImageUrl;
} else {
logoImageUrl = game.logoImageUrl;
}
// For hero image, if removed, restore to the original gradient or keep the original const libraryHeroImageUrl = removedAssets.hero
let libraryHeroImageUrl; ? game.libraryHeroImageUrl?.startsWith("data:image/svg+xml")
if (removedAssets.hero) { ? game.libraryHeroImageUrl
// If the original hero was a gradient (data URL), keep it, otherwise generate a new one : generateRandomGradient()
const originalHero = game.libraryHeroImageUrl; : assetPaths.hero
libraryHeroImageUrl = originalHero?.startsWith("data:image/svg+xml")
? originalHero
: generateRandomGradient();
} else {
libraryHeroImageUrl = assetPaths.hero
? `local:${assetPaths.hero}` ? `local:${assetPaths.hero}`
: game.libraryHeroImageUrl; : game.libraryHeroImageUrl;
}
return { iconUrl, logoImageUrl, libraryHeroImageUrl }; return { iconUrl, logoImageUrl, libraryHeroImageUrl };
}; };
// Helper function to prepare non-custom game assets
const prepareNonCustomGameAssets = () => { const prepareNonCustomGameAssets = () => {
const hasIconPath = assetPaths.icon; const customIconUrl =
let customIconUrl: string | null = null; !removedAssets.icon && assetPaths.icon
if (!removedAssets.icon && hasIconPath) { ? `local:${assetPaths.icon}`
customIconUrl = `local:${assetPaths.icon}`; : null;
}
const hasLogoPath = assetPaths.logo; const customLogoImageUrl =
let customLogoImageUrl: string | null = null; !removedAssets.logo && assetPaths.logo
if (!removedAssets.logo && hasLogoPath) { ? `local:${assetPaths.logo}`
customLogoImageUrl = `local:${assetPaths.logo}`; : null;
}
const hasHeroPath = assetPaths.hero; const customHeroImageUrl =
let customHeroImageUrl: string | null = null; !removedAssets.hero && assetPaths.hero
if (!removedAssets.hero && hasHeroPath) { ? `local:${assetPaths.hero}`
customHeroImageUrl = `local:${assetPaths.hero}`; : null;
}
return { return {
customIconUrl, customIconUrl,
@@ -452,7 +455,6 @@ export function EditGameModal({
}; };
}; };
// Helper function to update custom game
const updateCustomGame = async (game: LibraryGame | Game) => { const updateCustomGame = async (game: LibraryGame | Game) => {
const { iconUrl, logoImageUrl, libraryHeroImageUrl } = const { iconUrl, logoImageUrl, libraryHeroImageUrl } =
prepareCustomGameAssets(game); prepareCustomGameAssets(game);
@@ -470,7 +472,6 @@ export function EditGameModal({
}); });
}; };
// Helper function to update non-custom game
const updateNonCustomGame = async (game: LibraryGame) => { const updateNonCustomGame = async (game: LibraryGame) => {
const { customIconUrl, customLogoImageUrl, customHeroImageUrl } = const { customIconUrl, customLogoImageUrl, customHeroImageUrl } =
prepareNonCustomGameAssets(); prepareNonCustomGameAssets();
@@ -521,43 +522,17 @@ export function EditGameModal({
} }
}; };
// Helper function to reset form to initial state
const resetFormToInitialState = useCallback( const resetFormToInitialState = useCallback(
(game: LibraryGame | Game) => { (game: LibraryGame | Game) => {
setGameName(game.title || ""); setGameName(game.title || "");
setRemovedAssets(INITIAL_REMOVED_ASSETS);
// Reset removed assets state setAssetPaths(INITIAL_ASSET_PATHS);
setRemovedAssets({ setAssetDisplayPaths(INITIAL_ASSET_PATHS);
icon: false, setOriginalAssetPaths(INITIAL_ASSET_PATHS);
logo: false,
hero: false,
});
// Clear all asset paths to ensure clean state
setAssetPaths({
icon: "",
logo: "",
hero: "",
});
setAssetDisplayPaths({
icon: "",
logo: "",
hero: "",
});
setOriginalAssetPaths({
icon: "",
logo: "",
hero: "",
});
if (isCustomGame(game)) { if (isCustomGame(game)) {
setCustomGameAssets(game); setCustomGameAssets(game);
// Clear default URLs for custom games setDefaultUrls(INITIAL_ASSET_URLS);
setDefaultUrls({
icon: null,
logo: null,
hero: null,
});
} else { } else {
setNonCustomGameAssets(game as LibraryGame); setNonCustomGameAssets(game as LibraryGame);
} }
@@ -575,8 +550,8 @@ export function EditGameModal({
const isFormValid = gameName.trim(); const isFormValid = gameName.trim();
const getPreviewUrl = (assetType: AssetType): string | undefined => { const getPreviewUrl = (assetType: AssetType): string | undefined => {
const assetPath = getAssetPath(assetType); const assetPath = assetPaths[assetType];
const defaultUrl = getDefaultUrl(assetType); const defaultUrl = defaultUrls[assetType];
if (game && !isCustomGame(game)) { if (game && !isCustomGame(game)) {
return assetPath ? `local:${assetPath}` : defaultUrl || undefined; return assetPath ? `local:${assetPath}` : defaultUrl || undefined;
@@ -585,9 +560,9 @@ export function EditGameModal({
}; };
const renderImageSection = (assetType: AssetType) => { const renderImageSection = (assetType: AssetType) => {
const assetPath = getAssetPath(assetType); const assetPath = assetPaths[assetType];
const assetDisplayPath = getAssetDisplayPath(assetType); const assetDisplayPath = getAssetDisplayPath(assetType);
const defaultUrl = getDefaultUrl(assetType); const defaultUrl = defaultUrls[assetType];
const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl); const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl);
const isDragOver = dragOverTarget === assetType; const isDragOver = dragOverTarget === assetType;

View File

@@ -15,7 +15,6 @@ import {
TextField, TextField,
CheckboxField, CheckboxField,
} from "@renderer/components"; } from "@renderer/components";
import { downloadSourcesTable } from "@renderer/dexie";
import type { DownloadSource } from "@types"; import type { DownloadSource } from "@types";
import type { GameRepack } from "@types"; import type { GameRepack } from "@types";
@@ -105,7 +104,7 @@ export function RepacksModal({
}, [repacks, hashesInDebrid]); }, [repacks, hashesInDebrid]);
useEffect(() => { useEffect(() => {
downloadSourcesTable.toArray().then((sources) => { window.electron.getDownloadSourcesList().then((sources) => {
const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker)); const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker));
const filteredSources = sources.filter( const filteredSources = sources.filter(
(s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint (s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint
@@ -129,6 +128,7 @@ export function RepacksModal({
return downloadSources.some( return downloadSources.some(
(src) => (src) =>
src.fingerprint &&
selectedFingerprints.includes(src.fingerprint) && selectedFingerprints.includes(src.fingerprint) &&
src.name === repack.repacker src.name === repack.repacker
); );

View File

@@ -25,7 +25,7 @@ export function HowLongToBeatSection({
return `${value} ${t(durationTranslation[unit])}`; return `${value} ${t(durationTranslation[unit])}`;
}; };
if (!howLongToBeatData || !isLoading) return null; if (!howLongToBeatData && !isLoading) return null;
return ( return (
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444"> <SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">

View File

@@ -17,7 +17,6 @@ import {
StarIcon, StarIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { HowLongToBeatSection } from "./how-long-to-beat-section"; import { HowLongToBeatSection } from "./how-long-to-beat-section";
import { howLongToBeatEntriesTable } from "@renderer/dexie";
import { SidebarSection } from "../sidebar-section/sidebar-section"; import { SidebarSection } from "../sidebar-section/sidebar-section";
import { buildGameAchievementPath } from "@renderer/helpers"; import { buildGameAchievementPath } from "@renderer/helpers";
import { useSubscription } from "@renderer/hooks/use-subscription"; import { useSubscription } from "@renderer/hooks/use-subscription";
@@ -80,41 +79,22 @@ export function Sidebar() {
if (objectId) { if (objectId) {
setHowLongToBeat({ isLoading: true, data: null }); setHowLongToBeat({ isLoading: true, data: null });
howLongToBeatEntriesTable // Directly fetch from API without checking cache
.where({ shop, objectId }) window.electron.hydraApi
.first() .get<HowLongToBeatCategory[] | null>(
.then(async (cachedHowLongToBeat) => { `/games/${shop}/${objectId}/how-long-to-beat`,
if (cachedHowLongToBeat) { {
setHowLongToBeat({ needsAuth: false,
isLoading: false,
data: cachedHowLongToBeat.categories,
});
} else {
try {
const howLongToBeat = await window.electron.hydraApi.get<
HowLongToBeatCategory[] | null
>(`/games/${shop}/${objectId}/how-long-to-beat`, {
needsAuth: false,
});
if (howLongToBeat) {
howLongToBeatEntriesTable.add({
objectId,
shop: "steam",
createdAt: new Date(),
updatedAt: new Date(),
categories: howLongToBeat,
});
}
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
} catch (err) {
setHowLongToBeat({ isLoading: false, data: null });
}
} }
)
.then((howLongToBeatData) => {
setHowLongToBeat({ isLoading: false, data: howLongToBeatData });
})
.catch(() => {
setHowLongToBeat({ isLoading: false, data: null });
}); });
} }
}, [objectId, shop, gameTitle]); }, [objectId, shop]);
return ( return (
<aside className="content-sidebar"> <aside className="content-sidebar">
@@ -240,14 +220,6 @@ export function Sidebar() {
: (stats?.averageScore ?? null) : (stats?.averageScore ?? null)
} }
size={16} size={16}
showCalculating={
!!(
stats &&
(stats.averageScore === null || stats.averageScore === 0)
)
}
calculatingText={t("calculating", { ns: "game_card" })}
hideIcon={true}
/> />
</div> </div>
</div> </div>

View File

@@ -1,5 +1,14 @@
@use "../../scss/globals.scss"; @use "../../scss/globals.scss";
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.add-download-source-modal { .add-download-source-modal {
&__container { &__container {
display: flex; display: flex;
@@ -24,4 +33,9 @@
&__validate-button { &__validate-button {
align-self: flex-end; align-self: flex-end;
} }
&__spinner {
animation: spin 1s linear infinite;
margin-right: calc(globals.$spacing-unit / 2);
}
} }

View File

@@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components"; import { Button, Modal, TextField } from "@renderer/components";
import { settingsContext } from "@renderer/context"; import { settingsContext } from "@renderer/context";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useAppDispatch } from "@renderer/hooks";
import * as yup from "yup"; import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { downloadSourcesTable } from "@renderer/dexie";
import type { DownloadSourceValidationResult } from "@types"; import type { DownloadSourceValidationResult } from "@types";
import { downloadSourcesWorker } from "@renderer/workers"; import { setIsImportingSources } from "@renderer/features";
import { SyncIcon } from "@primer/octicons-react";
import "./add-download-source-modal.scss"; import "./add-download-source-modal.scss";
interface AddDownloadSourceModalProps { interface AddDownloadSourceModalProps {
@@ -52,13 +53,15 @@ export function AddDownloadSourceModal({
const { sourceUrl } = useContext(settingsContext); const { sourceUrl } = useContext(settingsContext);
const dispatch = useAppDispatch();
const onSubmit = useCallback( const onSubmit = useCallback(
async (values: FormValues) => { async (values: FormValues) => {
const existingDownloadSource = await downloadSourcesTable const exists = await window.electron.checkDownloadSourceExists(
.where({ url: values.url }) values.url
.first(); );
if (existingDownloadSource) { if (exists) {
setError("url", { setError("url", {
type: "server", type: "server",
message: t("source_already_exists"), message: t("source_already_exists"),
@@ -67,22 +70,11 @@ export function AddDownloadSourceModal({
return; return;
} }
downloadSourcesWorker.postMessage([ const validationResult = await window.electron.validateDownloadSource(
"VALIDATE_DOWNLOAD_SOURCE", values.url
values.url,
]);
const channel = new BroadcastChannel(
`download_sources:validate:${values.url}`
); );
channel.onmessage = ( setValidationResult(validationResult);
event: MessageEvent<DownloadSourceValidationResult>
) => {
setValidationResult(event.data);
channel.close();
};
setUrl(values.url); setUrl(values.url);
}, },
[setError, t] [setError, t]
@@ -100,44 +92,44 @@ export function AddDownloadSourceModal({
} }
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]); }, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const putDownloadSource = async () => {
const downloadSource = await downloadSourcesTable.where({ url }).first();
if (!downloadSource) return;
window.electron
.putDownloadSource(downloadSource.objectIds)
.then(({ fingerprint }) => {
downloadSourcesTable.update(downloadSource.id, { fingerprint });
});
};
const handleAddDownloadSource = async () => { const handleAddDownloadSource = async () => {
if (validationResult) { if (validationResult) {
setIsLoading(true); setIsLoading(true);
dispatch(setIsImportingSources(true));
const channel = new BroadcastChannel(`download_sources:import:${url}`); try {
// Single call that handles: import → API sync → fingerprint
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]); await window.electron.addDownloadSource(url);
channel.onmessage = () => {
window.electron.createDownloadSources([url]);
setIsLoading(false);
putDownloadSource();
// Close modal and update UI
onClose(); onClose();
onAddDownloadSource(); onAddDownloadSource();
channel.close(); } 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));
}
} }
}; };
const handleClose = () => {
// Prevent closing while importing
if (isLoading) return;
onClose();
};
return ( return (
<Modal <Modal
visible={visible} visible={visible}
title={t("add_download_source")} title={t("add_download_source")}
description={t("add_download_source_description")} description={t("add_download_source_description")}
onClose={onClose} onClose={handleClose}
clickOutsideToClose={!isLoading}
> >
<div className="add-download-source-modal__container"> <div className="add-download-source-modal__container">
<TextField <TextField
@@ -176,7 +168,10 @@ export function AddDownloadSourceModal({
onClick={handleAddDownloadSource} onClick={handleAddDownloadSource}
disabled={isLoading} disabled={isLoading}
> >
{t("import")} {isLoading && (
<SyncIcon className="add-download-source-modal__spinner" />
)}
{isLoading ? t("importing") : t("import")}
</Button> </Button>
</div> </div>
)} )}

View File

@@ -19,11 +19,8 @@ import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks"; import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared"; import { DownloadSourceStatus } from "@shared";
import { settingsContext } from "@renderer/context"; import { settingsContext } from "@renderer/context";
import { downloadSourcesTable } from "@renderer/dexie";
import { downloadSourcesWorker } from "@renderer/workers";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { setFilters, clearFilters } from "@renderer/features"; import { setFilters, clearFilters } from "@renderer/features";
import { generateUUID } from "@renderer/helpers";
import "./settings-download-sources.scss"; import "./settings-download-sources.scss";
export function SettingsDownloadSources() { export function SettingsDownloadSources() {
@@ -52,11 +49,10 @@ export function SettingsDownloadSources() {
const { updateRepacks } = useRepacks(); const { updateRepacks } = useRepacks();
const getDownloadSources = async () => { const getDownloadSources = async () => {
await downloadSourcesTable await window.electron
.toCollection() .getDownloadSourcesList()
.sortBy("createdAt")
.then((sources) => { .then((sources) => {
setDownloadSources(sources.reverse()); setDownloadSources(sources);
}) })
.finally(() => { .finally(() => {
setIsFetchingSources(false); setIsFetchingSources(false);
@@ -71,68 +67,67 @@ export function SettingsDownloadSources() {
if (sourceUrl) setShowAddDownloadSourceModal(true); if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]); }, [sourceUrl]);
const handleRemoveSource = (downloadSource: DownloadSource) => { const handleRemoveSource = async (downloadSource: DownloadSource) => {
setIsRemovingDownloadSource(true); setIsRemovingDownloadSource(true);
const channel = new BroadcastChannel(
`download_sources:delete:${downloadSource.id}`
);
downloadSourcesWorker.postMessage([ try {
"DELETE_DOWNLOAD_SOURCE", await window.electron.deleteDownloadSource(downloadSource.id);
downloadSource.id, await window.electron.removeDownloadSource(downloadSource.url);
]);
channel.onmessage = () => {
showSuccessToast(t("removed_download_source")); showSuccessToast(t("removed_download_source"));
window.electron.removeDownloadSource(downloadSource.url); await getDownloadSources();
getDownloadSources();
setIsRemovingDownloadSource(false);
channel.close();
updateRepacks(); updateRepacks();
}; } finally {
setIsRemovingDownloadSource(false);
}
}; };
const handleRemoveAllDownloadSources = () => { const handleRemoveAllDownloadSources = async () => {
setIsRemovingDownloadSource(true); setIsRemovingDownloadSource(true);
const id = generateUUID(); try {
const channel = new BroadcastChannel(`download_sources:delete_all:${id}`); await window.electron.deleteAllDownloadSources();
await window.electron.removeDownloadSource("", true);
downloadSourcesWorker.postMessage(["DELETE_ALL_DOWNLOAD_SOURCES", id]);
channel.onmessage = () => {
showSuccessToast(t("removed_download_sources")); showSuccessToast(t("removed_download_sources"));
window.electron.removeDownloadSource("", true); await getDownloadSources();
getDownloadSources();
setIsRemovingDownloadSource(false);
setShowConfirmationDeleteAllSourcesModal(false); setShowConfirmationDeleteAllSourcesModal(false);
channel.close();
updateRepacks(); updateRepacks();
}; } finally {
setIsRemovingDownloadSource(false);
}
}; };
const handleAddDownloadSource = async () => { const handleAddDownloadSource = async () => {
// Refresh sources list and repacks after import completes
await getDownloadSources(); await getDownloadSources();
// Force repacks update to ensure UI reflects new data
await updateRepacks();
showSuccessToast(t("added_download_source")); showSuccessToast(t("added_download_source"));
updateRepacks();
}; };
const syncDownloadSources = async () => { const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true); setIsSyncingDownloadSources(true);
const id = generateUUID(); try {
const channel = new BroadcastChannel(`download_sources:sync:${id}`); // Sync local sources (check for updates)
await window.electron.syncDownloadSources();
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); // Refresh sources and repacks AFTER sync completes
await getDownloadSources();
await updateRepacks();
channel.onmessage = () => {
showSuccessToast(t("download_sources_synced")); showSuccessToast(t("download_sources_synced"));
getDownloadSources(); } catch (error) {
console.error("Error syncing download sources:", error);
// Still refresh the UI even if sync fails
await getDownloadSources();
await updateRepacks();
} finally {
setIsSyncingDownloadSources(false); setIsSyncingDownloadSources(false);
channel.close(); }
updateRepacks();
};
}; };
const statusTitle = { const statusTitle = {
@@ -145,7 +140,12 @@ export function SettingsDownloadSources() {
setShowAddDownloadSourceModal(false); setShowAddDownloadSourceModal(false);
}; };
const navigateToCatalogue = (fingerprint: string) => { const navigateToCatalogue = (fingerprint?: string) => {
if (!fingerprint) {
console.error("Cannot navigate: fingerprint is undefined");
return;
}
dispatch(clearFilters()); dispatch(clearFilters());
dispatch(setFilters({ downloadSourceFingerprints: [fingerprint] })); dispatch(setFilters({ downloadSourceFingerprints: [fingerprint] }));
@@ -222,54 +222,58 @@ export function SettingsDownloadSources() {
</div> </div>
<ul className="settings-download-sources__list"> <ul className="settings-download-sources__list">
{downloadSources.map((downloadSource) => ( {downloadSources.map((downloadSource) => {
<li return (
key={downloadSource.id} <li
className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""}`} key={downloadSource.id}
> className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""}`}
<div className="settings-download-sources__item-header"> >
<h2>{downloadSource.name}</h2> <div className="settings-download-sources__item-header">
<h2>{downloadSource.name}</h2>
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
<Badge>{statusTitle[downloadSource.status]}</Badge> <Badge>{statusTitle[downloadSource.status]}</Badge>
</div>
<button
type="button"
className="settings-download-sources__navigate-button"
disabled={!downloadSource.fingerprint}
onClick={() =>
navigateToCatalogue(downloadSource.fingerprint)
}
>
<small>
{t("download_count", {
count: downloadSource.downloadCount,
countFormatted:
downloadSource.downloadCount.toLocaleString(),
})}
</small>
</button>
</div> </div>
<button <TextField
type="button" label={t("download_source_url")}
className="settings-download-sources__navigate-button" value={downloadSource.url}
disabled={!downloadSource.fingerprint} readOnly
onClick={() => navigateToCatalogue(downloadSource.fingerprint)} theme="dark"
> disabled
<small> rightContent={
{t("download_count", { <Button
count: downloadSource.downloadCount, type="button"
countFormatted: theme="outline"
downloadSource.downloadCount.toLocaleString(), onClick={() => handleRemoveSource(downloadSource)}
})} disabled={isRemovingDownloadSource}
</small> >
</button> <NoEntryIcon />
</div> {t("remove_download_source")}
</Button>
<TextField }
label={t("download_source_url")} />
value={downloadSource.url} </li>
readOnly );
theme="dark" })}
disabled
rightContent={
<Button
type="button"
theme="outline"
onClick={() => handleRemoveSource(downloadSource)}
disabled={isRemovingDownloadSource}
>
<NoEntryIcon />
{t("remove_download_source")}
</Button>
}
/>
</li>
))}
</ul> </ul>
</> </>
); );

View File

@@ -9,6 +9,7 @@ import {
gameRunningSlice, gameRunningSlice,
subscriptionSlice, subscriptionSlice,
repacksSlice, repacksSlice,
downloadSourcesSlice,
catalogueSearchSlice, catalogueSearchSlice,
} from "@renderer/features"; } from "@renderer/features";
@@ -23,6 +24,7 @@ export const store = configureStore({
gameRunning: gameRunningSlice.reducer, gameRunning: gameRunningSlice.reducer,
subscription: subscriptionSlice.reducer, subscription: subscriptionSlice.reducer,
repacks: repacksSlice.reducer, repacks: repacksSlice.reducer,
downloadSources: downloadSourcesSlice.reducer,
catalogueSearch: catalogueSearchSlice.reducer, catalogueSearch: catalogueSearchSlice.reducer,
}, },
}); });

View File

@@ -1,238 +0,0 @@
import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
import { z } from "zod";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { DownloadSourceStatus, formatName, pipe } from "@shared";
import { GameRepack } from "@types";
const formatRepackName = pipe((name) => name.replace("[DL]", ""), formatName);
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),
})
),
});
type Payload =
| ["IMPORT_DOWNLOAD_SOURCE", string]
| ["DELETE_DOWNLOAD_SOURCE", number]
| ["VALIDATE_DOWNLOAD_SOURCE", string]
| ["SYNC_DOWNLOAD_SOURCES", string]
| ["DELETE_ALL_DOWNLOAD_SOURCES", string];
export type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
const addNewDownloads = async (
downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: SteamGamesByLetter
) => {
const now = new Date();
const results = [] as (Omit<GameRepack, "id"> & {
downloadSourceId: number;
})[];
const objectIdsOnSource = new Set<string>();
for (const download of downloads) {
const formattedTitle = formatRepackName(download.title);
const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || [];
const gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(game.name)
);
if (gamesInSteam.length === 0) continue;
for (const game of gamesInSteam) {
objectIdsOnSource.add(String(game.id));
}
results.push({
objectIds: gamesInSteam.map((game) => String(game.id)),
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
});
}
await repacksTable.bulkAdd(results);
await downloadSourcesTable.update(downloadSource.id, {
objectIds: Array.from(objectIdsOnSource),
});
};
const getSteamGames = async () => {
const response = await axios.get<SteamGamesByLetter>(
`${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
);
return response.data;
};
const importDownloadSource = async (url: string) => {
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const steamGames = await getSteamGames();
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
const now = new Date();
const id = await downloadSourcesTable.add({
url,
name: response.data.name,
etag: response.headers["etag"],
status: DownloadSourceStatus.UpToDate,
downloadCount: response.data.downloads.length,
createdAt: now,
updatedAt: now,
});
const downloadSource = await downloadSourcesTable.get(id);
await addNewDownloads(downloadSource, response.data.downloads, steamGames);
});
};
const deleteDownloadSource = async (id: number) => {
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
await repacksTable.where({ downloadSourceId: id }).delete();
await downloadSourcesTable.where({ id }).delete();
});
};
const deleteAllDowloadSources = async () => {
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
await repacksTable.clear();
await downloadSourcesTable.clear();
});
};
self.onmessage = async (event: MessageEvent<Payload>) => {
const [type, data] = event.data;
if (type === "VALIDATE_DOWNLOAD_SOURCE") {
const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
const { name } = downloadSourceSchema.parse(response.data);
const channel = new BroadcastChannel(`download_sources:validate:${data}`);
channel.postMessage({
name,
etag: response.headers["etag"],
downloadCount: response.data.downloads.length,
});
}
if (type === "DELETE_ALL_DOWNLOAD_SOURCES") {
await deleteAllDowloadSources();
const channel = new BroadcastChannel(`download_sources:delete_all:${data}`);
channel.postMessage(true);
}
if (type === "DELETE_DOWNLOAD_SOURCE") {
await deleteDownloadSource(data);
const channel = new BroadcastChannel(`download_sources:delete:${data}`);
channel.postMessage(true);
}
if (type === "IMPORT_DOWNLOAD_SOURCE") {
await importDownloadSource(data);
const channel = new BroadcastChannel(`download_sources:import:${data}`);
channel.postMessage(true);
}
if (type === "SYNC_DOWNLOAD_SOURCES") {
const channel = new BroadcastChannel(`download_sources:sync:${data}`);
let newRepacksCount = 0;
try {
const downloadSources = await downloadSourcesTable.toArray();
const existingRepacks = await repacksTable.toArray();
if (downloadSources.some((source) => !source.fingerprint)) {
await Promise.all(
downloadSources.map(async (source) => {
await deleteDownloadSource(source.id);
await importDownloadSource(source.url);
})
);
} else {
for (const downloadSource of downloadSources) {
const headers = new AxiosHeaders();
if (downloadSource.etag) {
headers.set("If-None-Match", downloadSource.etag);
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
const steamGames = await getSteamGames();
await db.transaction(
"rw",
repacksTable,
downloadSourcesTable,
async () => {
await downloadSourcesTable.update(downloadSource.id, {
etag: response.headers["etag"],
downloadCount: source.downloads.length,
status: DownloadSourceStatus.UpToDate,
});
const repacks = source.downloads.filter(
(download) =>
!existingRepacks.some(
(repack) => repack.title === download.title
)
);
await addNewDownloads(downloadSource, repacks, steamGames);
newRepacksCount += repacks.length;
}
);
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
await downloadSourcesTable.update(downloadSource.id, {
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
}
channel.postMessage(newRepacksCount);
} catch (err) {
channel.postMessage(-1);
}
}
};

View File

@@ -1,3 +0,0 @@
import DownloadSourcesWorker from "./download-sources.worker?worker";
export const downloadSourcesWorker = new DownloadSourcesWorker();

View File

@@ -35,7 +35,7 @@ export interface DownloadSource {
status: DownloadSourceStatus; status: DownloadSourceStatus;
objectIds: string[]; objectIds: string[];
downloadCount: number; downloadCount: number;
fingerprint: string; fingerprint?: string;
etag: string | null; etag: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;

5150
yarn.lock

File diff suppressed because it is too large Load Diff