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",
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Только для друзей",
|
||||||
|
|||||||
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-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";
|
||||||
|
|||||||
@@ -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:", ""));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 "./game-achievements";
|
||||||
export * from "./keys";
|
export * from "./keys";
|
||||||
export * from "./themes";
|
export * from "./themes";
|
||||||
|
export * from "./download-sources";
|
||||||
|
export * from "./repacks";
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ export const levelKeys = {
|
|||||||
language: "language",
|
language: "language",
|
||||||
screenState: "screenState",
|
screenState: "screenState",
|
||||||
rpcPassword: "rpcPassword",
|
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");
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
18
src/renderer/src/declaration.d.ts
vendored
18
src/renderer/src/declaration.d.ts
vendored
@@ -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>;
|
||||||
|
|||||||
@@ -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 "./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";
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user