mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
feat: removing dexie
This commit is contained in:
@@ -57,7 +57,6 @@
|
||||
"crc": "^4.3.2",
|
||||
"create-desktop-shortcuts": "^1.11.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dexie": "^4.0.10",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
|
||||
@@ -447,6 +447,7 @@
|
||||
"found_download_option_one": "Found {{countFormatted}} download option",
|
||||
"found_download_option_other": "Found {{countFormatted}} download options",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"friends_only": "Friends only",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"found_download_option_one": "Encontrada {{countFormatted}} fuente de descarga",
|
||||
"found_download_option_other": "Encontradas {{countFormatted}} opciones de descargas",
|
||||
"import": "Importar",
|
||||
"importing": "Importando...",
|
||||
"public": "Público",
|
||||
"private": "Privado",
|
||||
"friends_only": "Sólo amigos",
|
||||
|
||||
@@ -435,6 +435,7 @@
|
||||
"found_download_option_one": "{{countFormatted}} opção de download encontrada",
|
||||
"found_download_option_other": "{{countFormatted}} opções de download encontradas",
|
||||
"import": "Importar",
|
||||
"importing": "Importando...",
|
||||
"privacy": "Privacidade",
|
||||
"private": "Privado",
|
||||
"friends_only": "Apenas amigos",
|
||||
|
||||
@@ -267,6 +267,7 @@
|
||||
"found_download_option_one": "{{countFormatted}} opção de transferência encontrada",
|
||||
"found_download_option_other": "{{countFormatted}} opções de transferência encontradas",
|
||||
"import": "Importar",
|
||||
"importing": "A importar...",
|
||||
"privacy": "Privacidade",
|
||||
"private": "Privado",
|
||||
"friends_only": "Apenas amigos",
|
||||
|
||||
@@ -446,6 +446,7 @@
|
||||
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
|
||||
"found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки",
|
||||
"import": "Импортировать",
|
||||
"importing": "Импортируется...",
|
||||
"public": "Публичный",
|
||||
"private": "Частный",
|
||||
"friends_only": "Только для друзей",
|
||||
|
||||
42
src/main/events/download-sources/add-download-source.ts
Normal file
42
src/main/events/download-sources/add-download-source.ts
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
25
src/main/events/download-sources/delete-download-source.ts
Normal file
25
src/main/events/download-sources/delete-download-source.ts
Normal 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);
|
||||
@@ -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);
|
||||
171
src/main/events/download-sources/helpers.ts
Normal file
171
src/main/events/download-sources/helpers.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
267
src/main/events/download-sources/sync-download-sources.ts
Normal file
267
src/main/events/download-sources/sync-download-sources.ts
Normal 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);
|
||||
@@ -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);
|
||||
32
src/main/events/download-sources/validate-download-source.ts
Normal file
32
src/main/events/download-sources/validate-download-source.ts
Normal 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);
|
||||
@@ -63,7 +63,15 @@ import "./autoupdater/restart-and-install-update";
|
||||
import "./user-preferences/authenticate-real-debrid";
|
||||
import "./user-preferences/authenticate-all-debrid";
|
||||
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/open-auth-window";
|
||||
import "./auth/get-session-hash";
|
||||
@@ -91,7 +99,6 @@ import "./themes/get-custom-theme-by-id";
|
||||
import "./themes/get-active-custom-theme";
|
||||
import "./themes/close-editor-window";
|
||||
import "./themes/toggle-custom-theme";
|
||||
import "./download-sources/create-download-sources";
|
||||
import "./download-sources/remove-download-source";
|
||||
import "./download-sources/get-download-sources";
|
||||
import { isPortableVersion } from "@main/helpers";
|
||||
|
||||
@@ -19,7 +19,6 @@ const getAllCustomGameAssets = async (): Promise<string[]> => {
|
||||
};
|
||||
|
||||
const getUsedAssetPaths = async (): Promise<Set<string>> => {
|
||||
// Get all custom games from the level database
|
||||
const { gamesSublevel } = await import("@main/level");
|
||||
const allGames = await gamesSublevel.iterator().all();
|
||||
|
||||
@@ -30,7 +29,6 @@ const getUsedAssetPaths = async (): Promise<Set<string>> => {
|
||||
const usedPaths = new Set<string>();
|
||||
|
||||
customGames.forEach((game) => {
|
||||
// Extract file paths from local URLs
|
||||
if (game.iconUrl?.startsWith("local:")) {
|
||||
usedPaths.add(game.iconUrl.replace("local:", ""));
|
||||
}
|
||||
|
||||
@@ -13,29 +13,23 @@ const copyCustomGameAsset = async (
|
||||
throw new Error("Source file does not exist");
|
||||
}
|
||||
|
||||
// Ensure assets directory exists
|
||||
if (!fs.existsSync(ASSETS_PATH)) {
|
||||
fs.mkdirSync(ASSETS_PATH, { recursive: true });
|
||||
}
|
||||
|
||||
// Create custom games assets subdirectory
|
||||
const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games");
|
||||
if (!fs.existsSync(customGamesAssetsPath)) {
|
||||
fs.mkdirSync(customGamesAssetsPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Get file extension
|
||||
const fileExtension = path.extname(sourcePath);
|
||||
|
||||
// Generate unique filename
|
||||
const uniqueId = randomUUID();
|
||||
const fileName = `${assetType}-${uniqueId}${fileExtension}`;
|
||||
const destinationPath = path.join(customGamesAssetsPath, fileName);
|
||||
|
||||
// Copy the file
|
||||
await fs.promises.copyFile(sourcePath, destinationPath);
|
||||
|
||||
// Return the local URL format
|
||||
return `local:${destinationPath}`;
|
||||
};
|
||||
|
||||
|
||||
16
src/main/events/repacks/get-all-repacks.ts
Normal file
16
src/main/events/repacks/get-all-repacks.ts
Normal 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);
|
||||
22
src/main/level/sublevels/download-sources.ts
Normal file
22
src/main/level/sublevels/download-sources.ts
Normal 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",
|
||||
}
|
||||
);
|
||||
@@ -6,3 +6,5 @@ export * from "./game-stats-cache";
|
||||
export * from "./game-achievements";
|
||||
export * from "./keys";
|
||||
export * from "./themes";
|
||||
export * from "./download-sources";
|
||||
export * from "./repacks";
|
||||
|
||||
@@ -17,4 +17,6 @@ export const levelKeys = {
|
||||
language: "language",
|
||||
screenState: "screenState",
|
||||
rpcPassword: "rpcPassword",
|
||||
downloadSources: "downloadSources",
|
||||
repacks: "repacks",
|
||||
};
|
||||
|
||||
22
src/main/level/sublevels/repacks.ts
Normal file
22
src/main/level/sublevels/repacks.ts
Normal 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",
|
||||
}
|
||||
);
|
||||
@@ -102,8 +102,14 @@ export class HydraApi {
|
||||
WindowManager.mainWindow.webContents.send("on-signin");
|
||||
await clearGamesRemoteIds();
|
||||
uploadGamesBatch();
|
||||
|
||||
// WSClient.close();
|
||||
// WSClient.connect();
|
||||
|
||||
const { syncDownloadSourcesFromApi } = await import(
|
||||
"../events/download-sources/sync-download-sources-from-api"
|
||||
);
|
||||
syncDownloadSourcesFromApi();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,13 +99,24 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("authenticateTorBox", apiToken),
|
||||
|
||||
/* Download sources */
|
||||
putDownloadSource: (objectIds: string[]) =>
|
||||
ipcRenderer.invoke("putDownloadSource", objectIds),
|
||||
createDownloadSources: (urls: string[]) =>
|
||||
ipcRenderer.invoke("createDownloadSources", urls),
|
||||
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: (
|
||||
|
||||
@@ -20,14 +20,12 @@ import {
|
||||
setUserDetails,
|
||||
setProfileBackground,
|
||||
setGameRunning,
|
||||
setIsImportingSources,
|
||||
} from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
import { downloadSourcesWorker } from "./workers";
|
||||
import { downloadSourcesTable } from "./dexie";
|
||||
import { useSubscription } from "./hooks/use-subscription";
|
||||
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
||||
import { generateUUID } from "./helpers";
|
||||
|
||||
import { injectCustomCss, removeCustomCss } from "./helpers";
|
||||
import "./app.scss";
|
||||
@@ -137,15 +135,6 @@ export function App() {
|
||||
}, [fetchUserDetails, updateUserDetails, dispatch]);
|
||||
|
||||
const onSignIn = useCallback(() => {
|
||||
window.electron.getDownloadSources().then((sources) => {
|
||||
sources.forEach((source) => {
|
||||
downloadSourcesWorker.postMessage([
|
||||
"IMPORT_DOWNLOAD_SOURCE",
|
||||
source.url,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
fetchUserDetails().then((response) => {
|
||||
if (response) {
|
||||
updateUserDetails(response);
|
||||
@@ -211,41 +200,34 @@ export function App() {
|
||||
}, [dispatch, draggingDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
updateRepacks();
|
||||
(async () => {
|
||||
dispatch(setIsImportingSources(true));
|
||||
|
||||
const id = generateUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
try {
|
||||
// Initial repacks load
|
||||
await updateRepacks();
|
||||
|
||||
channel.onmessage = async (event: MessageEvent<number>) => {
|
||||
const newRepacksCount = event.data;
|
||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||
updateRepacks();
|
||||
// Sync all local sources (check for updates)
|
||||
const newRepacksCount = await window.electron.syncDownloadSources();
|
||||
|
||||
const downloadSources = await downloadSourcesTable.toArray();
|
||||
if (newRepacksCount > 0) {
|
||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
downloadSources
|
||||
.filter((source) => !source.fingerprint)
|
||||
.map(async (downloadSource) => {
|
||||
const { fingerprint } = await window.electron.putDownloadSource(
|
||||
downloadSource.objectIds
|
||||
);
|
||||
// Update fingerprints for sources that don't have them
|
||||
await window.electron.updateMissingFingerprints();
|
||||
|
||||
return downloadSourcesTable.update(downloadSource.id, {
|
||||
fingerprint,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
channel.close();
|
||||
};
|
||||
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
|
||||
return () => {
|
||||
channel.close();
|
||||
};
|
||||
}, [updateRepacks]);
|
||||
// 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();
|
||||
|
||||
@@ -109,12 +109,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="game-card__specifics-item">
|
||||
<StarRating
|
||||
rating={stats?.averageScore || null}
|
||||
size={14}
|
||||
showCalculating={!!(stats && stats.averageScore === null)}
|
||||
calculatingText={t("calculating")}
|
||||
/>
|
||||
<StarRating rating={stats?.averageScore || null} size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,76 +1,31 @@
|
||||
import { StarIcon, StarFillIcon } from "@primer/octicons-react";
|
||||
import { StarFillIcon } from "@primer/octicons-react";
|
||||
import "./star-rating.scss";
|
||||
|
||||
export interface StarRatingProps {
|
||||
rating: number | null;
|
||||
maxStars?: number;
|
||||
size?: number;
|
||||
showCalculating?: boolean;
|
||||
calculatingText?: string;
|
||||
hideIcon?: boolean;
|
||||
}
|
||||
|
||||
export function StarRating({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function StarRating({ rating, size = 12 }: Readonly<StarRatingProps>) {
|
||||
if (rating === null || rating === undefined) {
|
||||
return (
|
||||
<div className="star-rating star-rating--no-rating">
|
||||
{!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) => (
|
||||
<div className="star-rating star-rating--single">
|
||||
<StarFillIcon
|
||||
key={`filled-${index}`}
|
||||
size={size}
|
||||
className="star-rating__star star-rating__star--filled"
|
||||
/>
|
||||
))}
|
||||
|
||||
{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"
|
||||
/>
|
||||
))}
|
||||
<span className="star-rating__value">…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
18
src/renderer/src/declaration.d.ts
vendored
18
src/renderer/src/declaration.d.ts
vendored
@@ -31,6 +31,9 @@ import type {
|
||||
AchievementNotificationInfo,
|
||||
Game,
|
||||
DiskUsage,
|
||||
DownloadSource,
|
||||
DownloadSourceValidationResult,
|
||||
GameRepack,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
|
||||
@@ -210,14 +213,21 @@ declare global {
|
||||
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
|
||||
/* Download sources */
|
||||
putDownloadSource: (
|
||||
objectIds: string[]
|
||||
) => Promise<{ fingerprint: string }>;
|
||||
createDownloadSources: (urls: string[]) => Promise<void>;
|
||||
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[]>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
|
||||
|
||||
@@ -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();
|
||||
21
src/renderer/src/features/download-sources-slice.ts
Normal file
21
src/renderer/src/features/download-sources-slice.ts
Normal 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;
|
||||
@@ -7,4 +7,5 @@ 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";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { repacksTable } from "@renderer/dexie";
|
||||
import { setRepacks } from "@renderer/features";
|
||||
import { useCallback } from "react";
|
||||
import { RootState } from "@renderer/store";
|
||||
@@ -16,18 +15,11 @@ export function useRepacks() {
|
||||
[repacks]
|
||||
);
|
||||
|
||||
const updateRepacks = useCallback(() => {
|
||||
repacksTable.toArray().then((repacks) => {
|
||||
dispatch(
|
||||
setRepacks(
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
repacks.filter((repack) => Array.isArray(repack.objectIds))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
const updateRepacks = useCallback(async () => {
|
||||
const repacks = await window.electron.getAllRepacks();
|
||||
dispatch(
|
||||
setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds)))
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
return { getRepacksForObjectId, updateRepacks };
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { CatalogueSearchResult, DownloadSource } from "@types";
|
||||
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useFormat,
|
||||
useRepacks,
|
||||
} from "@renderer/hooks";
|
||||
import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import "./catalogue.scss";
|
||||
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { FilterSection } from "./filter-section";
|
||||
import { setFilters, setPage } from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -56,8 +50,6 @@ export default function Catalogue() {
|
||||
|
||||
const { t, i18n } = useTranslation("catalogue");
|
||||
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
|
||||
const debouncedSearch = useRef(
|
||||
debounce(async (filters, pageSize, offset) => {
|
||||
const abortController = new AbortController();
|
||||
@@ -95,10 +87,10 @@ export default function Catalogue() {
|
||||
}, [filters, page, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
downloadSourcesTable.toArray().then((sources) => {
|
||||
window.electron.getDownloadSourcesList().then((sources) => {
|
||||
setDownloadSources(sources.filter((source) => !!source.fingerprint));
|
||||
});
|
||||
}, [getRepacksForObjectId]);
|
||||
}, []);
|
||||
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
@@ -192,13 +184,15 @@ export default function Catalogue() {
|
||||
},
|
||||
{
|
||||
title: t("download_sources"),
|
||||
items: downloadSources.map((source) => ({
|
||||
label: source.name,
|
||||
value: source.fingerprint,
|
||||
checked: filters.downloadSourceFingerprints.includes(
|
||||
source.fingerprint
|
||||
),
|
||||
})),
|
||||
items: downloadSources
|
||||
.filter((source) => source.fingerprint)
|
||||
.map((source) => ({
|
||||
label: source.name,
|
||||
value: source.fingerprint!,
|
||||
checked: filters.downloadSourceFingerprints.includes(
|
||||
source.fingerprint!
|
||||
),
|
||||
})),
|
||||
key: "downloadSourceFingerprints",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,6 +19,68 @@ export interface EditGameModalProps {
|
||||
|
||||
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({
|
||||
visible,
|
||||
onClose,
|
||||
@@ -30,33 +92,18 @@ export function EditGameModal({
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [gameName, setGameName] = useState("");
|
||||
const [assetPaths, setAssetPaths] = useState({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
const [assetDisplayPaths, setAssetDisplayPaths] = useState({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
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 [assetPaths, setAssetPaths] = useState<AssetPaths>(INITIAL_ASSET_PATHS);
|
||||
const [assetDisplayPaths, setAssetDisplayPaths] =
|
||||
useState<AssetPaths>(INITIAL_ASSET_PATHS);
|
||||
const [originalAssetPaths, setOriginalAssetPaths] =
|
||||
useState<AssetPaths>(INITIAL_ASSET_PATHS);
|
||||
const [removedAssets, setRemovedAssets] = useState<RemovedAssets>(
|
||||
INITIAL_REMOVED_ASSETS
|
||||
);
|
||||
const [defaultUrls, setDefaultUrls] = useState<AssetUrls>(INITIAL_ASSET_URLS);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [selectedAssetType, setSelectedAssetType] = useState<AssetType>("icon");
|
||||
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
|
||||
|
||||
const isCustomGame = (game: LibraryGame | Game): boolean => {
|
||||
return game.shop === "custom";
|
||||
@@ -66,12 +113,18 @@ export function EditGameModal({
|
||||
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) => {
|
||||
// Check if assets were removed (URLs are null but original paths exist)
|
||||
const iconRemoved = !game.iconUrl && (game as any).originalIconPath;
|
||||
const logoRemoved = !game.logoImageUrl && (game as any).originalLogoPath;
|
||||
const gameWithAssets = game as GameWithOriginalAssets;
|
||||
const iconRemoved =
|
||||
!game.iconUrl && Boolean(gameWithAssets.originalIconPath);
|
||||
const logoRemoved =
|
||||
!game.logoImageUrl && Boolean(gameWithAssets.originalLogoPath);
|
||||
const heroRemoved =
|
||||
!game.libraryHeroImageUrl && (game as any).originalHeroPath;
|
||||
!game.libraryHeroImageUrl && Boolean(gameWithAssets.originalHeroPath);
|
||||
|
||||
setAssetPaths({
|
||||
icon: extractLocalPath(game.iconUrl),
|
||||
@@ -84,15 +137,14 @@ export function EditGameModal({
|
||||
hero: extractLocalPath(game.libraryHeroImageUrl),
|
||||
});
|
||||
setOriginalAssetPaths({
|
||||
icon: (game as any).originalIconPath || extractLocalPath(game.iconUrl),
|
||||
icon: gameWithAssets.originalIconPath || extractLocalPath(game.iconUrl),
|
||||
logo:
|
||||
(game as any).originalLogoPath || extractLocalPath(game.logoImageUrl),
|
||||
gameWithAssets.originalLogoPath || extractLocalPath(game.logoImageUrl),
|
||||
hero:
|
||||
(game as any).originalHeroPath ||
|
||||
gameWithAssets.originalHeroPath ||
|
||||
extractLocalPath(game.libraryHeroImageUrl),
|
||||
});
|
||||
|
||||
// Set removed assets state based on whether assets were explicitly removed
|
||||
setRemovedAssets({
|
||||
icon: iconRemoved,
|
||||
logo: logoRemoved,
|
||||
@@ -102,13 +154,15 @@ export function EditGameModal({
|
||||
|
||||
const setNonCustomGameAssets = useCallback(
|
||||
(game: LibraryGame) => {
|
||||
// Check if assets were removed (custom URLs are null but original paths exist)
|
||||
const gameWithAssets = game as LibraryGameWithCustomOriginalAssets;
|
||||
const iconRemoved =
|
||||
!game.customIconUrl && (game as any).customOriginalIconPath;
|
||||
!game.customIconUrl && Boolean(gameWithAssets.customOriginalIconPath);
|
||||
const logoRemoved =
|
||||
!game.customLogoImageUrl && (game as any).customOriginalLogoPath;
|
||||
!game.customLogoImageUrl &&
|
||||
Boolean(gameWithAssets.customOriginalLogoPath);
|
||||
const heroRemoved =
|
||||
!game.customHeroImageUrl && (game as any).customOriginalHeroPath;
|
||||
!game.customHeroImageUrl &&
|
||||
Boolean(gameWithAssets.customOriginalHeroPath);
|
||||
|
||||
setAssetPaths({
|
||||
icon: extractLocalPath(game.customIconUrl),
|
||||
@@ -122,17 +176,16 @@ export function EditGameModal({
|
||||
});
|
||||
setOriginalAssetPaths({
|
||||
icon:
|
||||
(game as any).customOriginalIconPath ||
|
||||
gameWithAssets.customOriginalIconPath ||
|
||||
extractLocalPath(game.customIconUrl),
|
||||
logo:
|
||||
(game as any).customOriginalLogoPath ||
|
||||
gameWithAssets.customOriginalLogoPath ||
|
||||
extractLocalPath(game.customLogoImageUrl),
|
||||
hero:
|
||||
(game as any).customOriginalHeroPath ||
|
||||
gameWithAssets.customOriginalHeroPath ||
|
||||
extractLocalPath(game.customHeroImageUrl),
|
||||
});
|
||||
|
||||
// Set removed assets state based on whether assets were explicitly removed
|
||||
setRemovedAssets({
|
||||
icon: iconRemoved,
|
||||
logo: logoRemoved,
|
||||
@@ -171,29 +224,22 @@ export function EditGameModal({
|
||||
setSelectedAssetType(assetType);
|
||||
};
|
||||
|
||||
const getAssetPath = (assetType: AssetType): string => {
|
||||
return assetPaths[assetType];
|
||||
};
|
||||
|
||||
const getAssetDisplayPath = (assetType: AssetType): string => {
|
||||
// If asset was removed, don't show any path
|
||||
if (removedAssets[assetType]) {
|
||||
return "";
|
||||
}
|
||||
// Use display path first, then fall back to original path
|
||||
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 }));
|
||||
};
|
||||
|
||||
const setAssetDisplayPath = (assetType: AssetType, path: string): void => {
|
||||
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: path }));
|
||||
};
|
||||
|
||||
const getDefaultUrl = (assetType: AssetType): string | null => {
|
||||
return defaultUrls[assetType];
|
||||
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: displayPath }));
|
||||
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: displayPath }));
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
|
||||
};
|
||||
|
||||
const getOriginalAssetUrl = (assetType: AssetType): string | null => {
|
||||
@@ -217,7 +263,7 @@ export function EditGameModal({
|
||||
filters: [
|
||||
{
|
||||
name: t("edit_game_modal_image_filter"),
|
||||
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
|
||||
extensions: [...IMAGE_EXTENSIONS],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -229,41 +275,26 @@ export function EditGameModal({
|
||||
originalPath,
|
||||
assetType
|
||||
);
|
||||
setAssetPath(assetType, copiedAssetUrl.replace("local:", ""));
|
||||
setAssetDisplayPath(assetType, originalPath);
|
||||
// Store the original path for display purposes
|
||||
setOriginalAssetPaths((prev) => ({
|
||||
...prev,
|
||||
[assetType]: originalPath,
|
||||
}));
|
||||
// Clear the removed flag when a new asset is selected
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
|
||||
updateAssetPaths(
|
||||
assetType,
|
||||
copiedAssetUrl.replace("local:", ""),
|
||||
originalPath
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy ${assetType} asset:`, error);
|
||||
setAssetPath(assetType, originalPath);
|
||||
setAssetDisplayPath(assetType, originalPath);
|
||||
setOriginalAssetPaths((prev) => ({
|
||||
...prev,
|
||||
[assetType]: originalPath,
|
||||
}));
|
||||
// Clear the removed flag when a new asset is selected
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
|
||||
updateAssetPaths(assetType, originalPath, originalPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreDefault = (assetType: AssetType) => {
|
||||
// Mark asset as removed and clear paths (for both custom and non-custom games)
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: true }));
|
||||
setAssetPath(assetType, "");
|
||||
setAssetDisplayPath(assetType, "");
|
||||
// Don't clear originalAssetPaths - keep them for reference but don't use them for display
|
||||
setAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
|
||||
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: "" }));
|
||||
};
|
||||
|
||||
const getOriginalTitle = (): string => {
|
||||
if (!game) return "";
|
||||
|
||||
// For non-custom games, the original title is from shopDetails assets
|
||||
return shopDetails?.assets?.title || game.title || "";
|
||||
};
|
||||
|
||||
@@ -274,12 +305,10 @@ export function EditGameModal({
|
||||
|
||||
const isTitleChanged = useMemo((): boolean => {
|
||||
if (!game || isCustomGame(game)) return false;
|
||||
const originalTitle = getOriginalTitle();
|
||||
const originalTitle = shopDetails?.assets?.title || game.title || "";
|
||||
return gameName.trim() !== originalTitle.trim();
|
||||
}, [game, gameName, shopDetails]);
|
||||
|
||||
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -300,14 +329,9 @@ export function EditGameModal({
|
||||
};
|
||||
|
||||
const validateImageFile = (file: File): boolean => {
|
||||
const validTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
return validTypes.includes(file.type);
|
||||
return VALID_IMAGE_TYPES.includes(
|
||||
file.type as (typeof VALID_IMAGE_TYPES)[number]
|
||||
);
|
||||
};
|
||||
|
||||
const processDroppedFile = async (file: File, assetType: AssetType) => {
|
||||
@@ -321,10 +345,6 @@ export function EditGameModal({
|
||||
try {
|
||||
let filePath: string;
|
||||
|
||||
interface ElectronFile extends File {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
if ("path" in file && typeof (file as ElectronFile).path === "string") {
|
||||
filePath = (file as ElectronFile).path!;
|
||||
} else {
|
||||
@@ -351,12 +371,13 @@ export function EditGameModal({
|
||||
assetType
|
||||
);
|
||||
|
||||
const assetPath = copiedAssetUrl.replace("local:", "");
|
||||
setAssetPath(assetType, assetPath);
|
||||
setAssetDisplayPath(assetType, filePath);
|
||||
|
||||
updateAssetPaths(
|
||||
assetType,
|
||||
copiedAssetUrl.replace("local:", ""),
|
||||
filePath
|
||||
);
|
||||
showSuccessToast(
|
||||
`${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!`
|
||||
`${capitalizeAssetType(assetType)} updated successfully!`
|
||||
);
|
||||
|
||||
if (!("path" in file) && filePath) {
|
||||
@@ -387,63 +408,45 @@ export function EditGameModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to prepare custom game assets
|
||||
const prepareCustomGameAssets = (game: LibraryGame | Game) => {
|
||||
// For custom games, check if asset was explicitly removed
|
||||
let iconUrl;
|
||||
if (removedAssets.icon) {
|
||||
iconUrl = null;
|
||||
} else if (assetPaths.icon) {
|
||||
iconUrl = `local:${assetPaths.icon}`;
|
||||
} else {
|
||||
iconUrl = game.iconUrl;
|
||||
}
|
||||
const iconUrl = removedAssets.icon
|
||||
? null
|
||||
: assetPaths.icon
|
||||
? `local:${assetPaths.icon}`
|
||||
: game.iconUrl;
|
||||
|
||||
let logoImageUrl;
|
||||
if (removedAssets.logo) {
|
||||
logoImageUrl = null;
|
||||
} else if (assetPaths.logo) {
|
||||
logoImageUrl = `local:${assetPaths.logo}`;
|
||||
} else {
|
||||
logoImageUrl = game.logoImageUrl;
|
||||
}
|
||||
const logoImageUrl = removedAssets.logo
|
||||
? null
|
||||
: assetPaths.logo
|
||||
? `local:${assetPaths.logo}`
|
||||
: game.logoImageUrl;
|
||||
|
||||
// For hero image, if removed, restore to the original gradient or keep the original
|
||||
let libraryHeroImageUrl;
|
||||
if (removedAssets.hero) {
|
||||
// If the original hero was a gradient (data URL), keep it, otherwise generate a new one
|
||||
const originalHero = game.libraryHeroImageUrl;
|
||||
libraryHeroImageUrl = originalHero?.startsWith("data:image/svg+xml")
|
||||
? originalHero
|
||||
: generateRandomGradient();
|
||||
} else {
|
||||
libraryHeroImageUrl = assetPaths.hero
|
||||
const libraryHeroImageUrl = removedAssets.hero
|
||||
? game.libraryHeroImageUrl?.startsWith("data:image/svg+xml")
|
||||
? game.libraryHeroImageUrl
|
||||
: generateRandomGradient()
|
||||
: assetPaths.hero
|
||||
? `local:${assetPaths.hero}`
|
||||
: game.libraryHeroImageUrl;
|
||||
}
|
||||
|
||||
return { iconUrl, logoImageUrl, libraryHeroImageUrl };
|
||||
};
|
||||
|
||||
// Helper function to prepare non-custom game assets
|
||||
const prepareNonCustomGameAssets = () => {
|
||||
const hasIconPath = assetPaths.icon;
|
||||
let customIconUrl: string | null = null;
|
||||
if (!removedAssets.icon && hasIconPath) {
|
||||
customIconUrl = `local:${assetPaths.icon}`;
|
||||
}
|
||||
const customIconUrl =
|
||||
!removedAssets.icon && assetPaths.icon
|
||||
? `local:${assetPaths.icon}`
|
||||
: null;
|
||||
|
||||
const hasLogoPath = assetPaths.logo;
|
||||
let customLogoImageUrl: string | null = null;
|
||||
if (!removedAssets.logo && hasLogoPath) {
|
||||
customLogoImageUrl = `local:${assetPaths.logo}`;
|
||||
}
|
||||
const customLogoImageUrl =
|
||||
!removedAssets.logo && assetPaths.logo
|
||||
? `local:${assetPaths.logo}`
|
||||
: null;
|
||||
|
||||
const hasHeroPath = assetPaths.hero;
|
||||
let customHeroImageUrl: string | null = null;
|
||||
if (!removedAssets.hero && hasHeroPath) {
|
||||
customHeroImageUrl = `local:${assetPaths.hero}`;
|
||||
}
|
||||
const customHeroImageUrl =
|
||||
!removedAssets.hero && assetPaths.hero
|
||||
? `local:${assetPaths.hero}`
|
||||
: null;
|
||||
|
||||
return {
|
||||
customIconUrl,
|
||||
@@ -452,7 +455,6 @@ export function EditGameModal({
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to update custom game
|
||||
const updateCustomGame = async (game: LibraryGame | Game) => {
|
||||
const { iconUrl, logoImageUrl, libraryHeroImageUrl } =
|
||||
prepareCustomGameAssets(game);
|
||||
@@ -470,7 +472,6 @@ export function EditGameModal({
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to update non-custom game
|
||||
const updateNonCustomGame = async (game: LibraryGame) => {
|
||||
const { customIconUrl, customLogoImageUrl, customHeroImageUrl } =
|
||||
prepareNonCustomGameAssets();
|
||||
@@ -521,43 +522,17 @@ export function EditGameModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to reset form to initial state
|
||||
const resetFormToInitialState = useCallback(
|
||||
(game: LibraryGame | Game) => {
|
||||
setGameName(game.title || "");
|
||||
|
||||
// Reset removed assets state
|
||||
setRemovedAssets({
|
||||
icon: false,
|
||||
logo: false,
|
||||
hero: false,
|
||||
});
|
||||
|
||||
// Clear all asset paths to ensure clean state
|
||||
setAssetPaths({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
setAssetDisplayPaths({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
setOriginalAssetPaths({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
setRemovedAssets(INITIAL_REMOVED_ASSETS);
|
||||
setAssetPaths(INITIAL_ASSET_PATHS);
|
||||
setAssetDisplayPaths(INITIAL_ASSET_PATHS);
|
||||
setOriginalAssetPaths(INITIAL_ASSET_PATHS);
|
||||
|
||||
if (isCustomGame(game)) {
|
||||
setCustomGameAssets(game);
|
||||
// Clear default URLs for custom games
|
||||
setDefaultUrls({
|
||||
icon: null,
|
||||
logo: null,
|
||||
hero: null,
|
||||
});
|
||||
setDefaultUrls(INITIAL_ASSET_URLS);
|
||||
} else {
|
||||
setNonCustomGameAssets(game as LibraryGame);
|
||||
}
|
||||
@@ -575,8 +550,8 @@ export function EditGameModal({
|
||||
const isFormValid = gameName.trim();
|
||||
|
||||
const getPreviewUrl = (assetType: AssetType): string | undefined => {
|
||||
const assetPath = getAssetPath(assetType);
|
||||
const defaultUrl = getDefaultUrl(assetType);
|
||||
const assetPath = assetPaths[assetType];
|
||||
const defaultUrl = defaultUrls[assetType];
|
||||
|
||||
if (game && !isCustomGame(game)) {
|
||||
return assetPath ? `local:${assetPath}` : defaultUrl || undefined;
|
||||
@@ -585,9 +560,9 @@ export function EditGameModal({
|
||||
};
|
||||
|
||||
const renderImageSection = (assetType: AssetType) => {
|
||||
const assetPath = getAssetPath(assetType);
|
||||
const assetPath = assetPaths[assetType];
|
||||
const assetDisplayPath = getAssetDisplayPath(assetType);
|
||||
const defaultUrl = getDefaultUrl(assetType);
|
||||
const defaultUrl = defaultUrls[assetType];
|
||||
const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl);
|
||||
const isDragOver = dragOverTarget === assetType;
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
TextField,
|
||||
CheckboxField,
|
||||
} from "@renderer/components";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import type { DownloadSource } from "@types";
|
||||
import type { GameRepack } from "@types";
|
||||
|
||||
@@ -105,7 +104,7 @@ export function RepacksModal({
|
||||
}, [repacks, hashesInDebrid]);
|
||||
|
||||
useEffect(() => {
|
||||
downloadSourcesTable.toArray().then((sources) => {
|
||||
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
|
||||
@@ -129,6 +128,7 @@ export function RepacksModal({
|
||||
|
||||
return downloadSources.some(
|
||||
(src) =>
|
||||
src.fingerprint &&
|
||||
selectedFingerprints.includes(src.fingerprint) &&
|
||||
src.name === repack.repacker
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export function HowLongToBeatSection({
|
||||
return `${value} ${t(durationTranslation[unit])}`;
|
||||
};
|
||||
|
||||
if (!howLongToBeatData || !isLoading) return null;
|
||||
if (!howLongToBeatData && !isLoading) return null;
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
StarIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||
import { howLongToBeatEntriesTable } from "@renderer/dexie";
|
||||
import { SidebarSection } from "../sidebar-section/sidebar-section";
|
||||
import { buildGameAchievementPath } from "@renderer/helpers";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
@@ -80,41 +79,22 @@ export function Sidebar() {
|
||||
if (objectId) {
|
||||
setHowLongToBeat({ isLoading: true, data: null });
|
||||
|
||||
howLongToBeatEntriesTable
|
||||
.where({ shop, objectId })
|
||||
.first()
|
||||
.then(async (cachedHowLongToBeat) => {
|
||||
if (cachedHowLongToBeat) {
|
||||
setHowLongToBeat({
|
||||
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 });
|
||||
}
|
||||
// Directly fetch from API without checking cache
|
||||
window.electron.hydraApi
|
||||
.get<HowLongToBeatCategory[] | null>(
|
||||
`/games/${shop}/${objectId}/how-long-to-beat`,
|
||||
{
|
||||
needsAuth: false,
|
||||
}
|
||||
)
|
||||
.then((howLongToBeatData) => {
|
||||
setHowLongToBeat({ isLoading: false, data: howLongToBeatData });
|
||||
})
|
||||
.catch(() => {
|
||||
setHowLongToBeat({ isLoading: false, data: null });
|
||||
});
|
||||
}
|
||||
}, [objectId, shop, gameTitle]);
|
||||
}, [objectId, shop]);
|
||||
|
||||
return (
|
||||
<aside className="content-sidebar">
|
||||
@@ -240,14 +220,6 @@ export function Sidebar() {
|
||||
: (stats?.averageScore ?? null)
|
||||
}
|
||||
size={16}
|
||||
showCalculating={
|
||||
!!(
|
||||
stats &&
|
||||
(stats.averageScore === null || stats.averageScore === 0)
|
||||
)
|
||||
}
|
||||
calculatingText={t("calculating", { ns: "game_card" })}
|
||||
hideIcon={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.add-download-source-modal {
|
||||
&__container {
|
||||
display: flex;
|
||||
@@ -24,4 +33,9 @@
|
||||
&__validate-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ 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 { downloadSourcesTable } from "@renderer/dexie";
|
||||
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";
|
||||
|
||||
interface AddDownloadSourceModalProps {
|
||||
@@ -52,13 +53,15 @@ export function AddDownloadSourceModal({
|
||||
|
||||
const { sourceUrl } = useContext(settingsContext);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (values: FormValues) => {
|
||||
const existingDownloadSource = await downloadSourcesTable
|
||||
.where({ url: values.url })
|
||||
.first();
|
||||
const exists = await window.electron.checkDownloadSourceExists(
|
||||
values.url
|
||||
);
|
||||
|
||||
if (existingDownloadSource) {
|
||||
if (exists) {
|
||||
setError("url", {
|
||||
type: "server",
|
||||
message: t("source_already_exists"),
|
||||
@@ -67,22 +70,11 @@ export function AddDownloadSourceModal({
|
||||
return;
|
||||
}
|
||||
|
||||
downloadSourcesWorker.postMessage([
|
||||
"VALIDATE_DOWNLOAD_SOURCE",
|
||||
values.url,
|
||||
]);
|
||||
|
||||
const channel = new BroadcastChannel(
|
||||
`download_sources:validate:${values.url}`
|
||||
const validationResult = await window.electron.validateDownloadSource(
|
||||
values.url
|
||||
);
|
||||
|
||||
channel.onmessage = (
|
||||
event: MessageEvent<DownloadSourceValidationResult>
|
||||
) => {
|
||||
setValidationResult(event.data);
|
||||
channel.close();
|
||||
};
|
||||
|
||||
setValidationResult(validationResult);
|
||||
setUrl(values.url);
|
||||
},
|
||||
[setError, t]
|
||||
@@ -100,44 +92,44 @@ export function AddDownloadSourceModal({
|
||||
}
|
||||
}, [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 () => {
|
||||
if (validationResult) {
|
||||
setIsLoading(true);
|
||||
dispatch(setIsImportingSources(true));
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:import:${url}`);
|
||||
|
||||
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
window.electron.createDownloadSources([url]);
|
||||
setIsLoading(false);
|
||||
|
||||
putDownloadSource();
|
||||
try {
|
||||
// Single call that handles: import → API sync → fingerprint
|
||||
await window.electron.addDownloadSource(url);
|
||||
|
||||
// Close modal and update UI
|
||||
onClose();
|
||||
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 (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("add_download_source")}
|
||||
description={t("add_download_source_description")}
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
clickOutsideToClose={!isLoading}
|
||||
>
|
||||
<div className="add-download-source-modal__container">
|
||||
<TextField
|
||||
@@ -176,7 +168,10 @@ export function AddDownloadSourceModal({
|
||||
onClick={handleAddDownloadSource}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("import")}
|
||||
{isLoading && (
|
||||
<SyncIcon className="add-download-source-modal__spinner" />
|
||||
)}
|
||||
{isLoading ? t("importing") : t("import")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -19,11 +19,8 @@ import { AddDownloadSourceModal } from "./add-download-source-modal";
|
||||
import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { downloadSourcesWorker } from "@renderer/workers";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { setFilters, clearFilters } from "@renderer/features";
|
||||
import { generateUUID } from "@renderer/helpers";
|
||||
import "./settings-download-sources.scss";
|
||||
|
||||
export function SettingsDownloadSources() {
|
||||
@@ -52,11 +49,10 @@ export function SettingsDownloadSources() {
|
||||
const { updateRepacks } = useRepacks();
|
||||
|
||||
const getDownloadSources = async () => {
|
||||
await downloadSourcesTable
|
||||
.toCollection()
|
||||
.sortBy("createdAt")
|
||||
await window.electron
|
||||
.getDownloadSourcesList()
|
||||
.then((sources) => {
|
||||
setDownloadSources(sources.reverse());
|
||||
setDownloadSources(sources);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetchingSources(false);
|
||||
@@ -71,68 +67,67 @@ export function SettingsDownloadSources() {
|
||||
if (sourceUrl) setShowAddDownloadSourceModal(true);
|
||||
}, [sourceUrl]);
|
||||
|
||||
const handleRemoveSource = (downloadSource: DownloadSource) => {
|
||||
const handleRemoveSource = async (downloadSource: DownloadSource) => {
|
||||
setIsRemovingDownloadSource(true);
|
||||
const channel = new BroadcastChannel(
|
||||
`download_sources:delete:${downloadSource.id}`
|
||||
);
|
||||
|
||||
downloadSourcesWorker.postMessage([
|
||||
"DELETE_DOWNLOAD_SOURCE",
|
||||
downloadSource.id,
|
||||
]);
|
||||
try {
|
||||
await window.electron.deleteDownloadSource(downloadSource.id);
|
||||
await window.electron.removeDownloadSource(downloadSource.url);
|
||||
|
||||
channel.onmessage = () => {
|
||||
showSuccessToast(t("removed_download_source"));
|
||||
window.electron.removeDownloadSource(downloadSource.url);
|
||||
|
||||
getDownloadSources();
|
||||
setIsRemovingDownloadSource(false);
|
||||
channel.close();
|
||||
await getDownloadSources();
|
||||
updateRepacks();
|
||||
};
|
||||
} finally {
|
||||
setIsRemovingDownloadSource(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAllDownloadSources = () => {
|
||||
const handleRemoveAllDownloadSources = async () => {
|
||||
setIsRemovingDownloadSource(true);
|
||||
|
||||
const id = generateUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:delete_all:${id}`);
|
||||
try {
|
||||
await window.electron.deleteAllDownloadSources();
|
||||
await window.electron.removeDownloadSource("", true);
|
||||
|
||||
downloadSourcesWorker.postMessage(["DELETE_ALL_DOWNLOAD_SOURCES", id]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
showSuccessToast(t("removed_download_sources"));
|
||||
window.electron.removeDownloadSource("", true);
|
||||
getDownloadSources();
|
||||
setIsRemovingDownloadSource(false);
|
||||
await getDownloadSources();
|
||||
setShowConfirmationDeleteAllSourcesModal(false);
|
||||
channel.close();
|
||||
updateRepacks();
|
||||
};
|
||||
} finally {
|
||||
setIsRemovingDownloadSource(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"));
|
||||
updateRepacks();
|
||||
};
|
||||
|
||||
const syncDownloadSources = async () => {
|
||||
setIsSyncingDownloadSources(true);
|
||||
|
||||
const id = generateUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
try {
|
||||
// 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"));
|
||||
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);
|
||||
channel.close();
|
||||
updateRepacks();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusTitle = {
|
||||
@@ -145,7 +140,12 @@ export function SettingsDownloadSources() {
|
||||
setShowAddDownloadSourceModal(false);
|
||||
};
|
||||
|
||||
const navigateToCatalogue = (fingerprint: string) => {
|
||||
const navigateToCatalogue = (fingerprint?: string) => {
|
||||
if (!fingerprint) {
|
||||
console.error("Cannot navigate: fingerprint is undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(clearFilters());
|
||||
dispatch(setFilters({ downloadSourceFingerprints: [fingerprint] }));
|
||||
|
||||
@@ -222,54 +222,58 @@ export function SettingsDownloadSources() {
|
||||
</div>
|
||||
|
||||
<ul className="settings-download-sources__list">
|
||||
{downloadSources.map((downloadSource) => (
|
||||
<li
|
||||
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>
|
||||
{downloadSources.map((downloadSource) => {
|
||||
return (
|
||||
<li
|
||||
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 style={{ display: "flex" }}>
|
||||
<Badge>{statusTitle[downloadSource.status]}</Badge>
|
||||
<div style={{ display: "flex" }}>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<TextField
|
||||
label={t("download_source_url")}
|
||||
value={downloadSource.url}
|
||||
readOnly
|
||||
theme="dark"
|
||||
disabled
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleRemoveSource(downloadSource)}
|
||||
disabled={isRemovingDownloadSource}
|
||||
>
|
||||
<NoEntryIcon />
|
||||
{t("remove_download_source")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
<TextField
|
||||
label={t("download_source_url")}
|
||||
value={downloadSource.url}
|
||||
readOnly
|
||||
theme="dark"
|
||||
disabled
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleRemoveSource(downloadSource)}
|
||||
disabled={isRemovingDownloadSource}
|
||||
>
|
||||
<NoEntryIcon />
|
||||
{t("remove_download_source")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
gameRunningSlice,
|
||||
subscriptionSlice,
|
||||
repacksSlice,
|
||||
downloadSourcesSlice,
|
||||
catalogueSearchSlice,
|
||||
} from "@renderer/features";
|
||||
|
||||
@@ -23,6 +24,7 @@ export const store = configureStore({
|
||||
gameRunning: gameRunningSlice.reducer,
|
||||
subscription: subscriptionSlice.reducer,
|
||||
repacks: repacksSlice.reducer,
|
||||
downloadSources: downloadSourcesSlice.reducer,
|
||||
catalogueSearch: catalogueSearchSlice.reducer,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import DownloadSourcesWorker from "./download-sources.worker?worker";
|
||||
|
||||
export const downloadSourcesWorker = new DownloadSourcesWorker();
|
||||
@@ -35,7 +35,7 @@ export interface DownloadSource {
|
||||
status: DownloadSourceStatus;
|
||||
objectIds: string[];
|
||||
downloadCount: number;
|
||||
fingerprint: string;
|
||||
fingerprint?: string;
|
||||
etag: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
Reference in New Issue
Block a user