feat: adding dexie

This commit is contained in:
Chubby Granny Chaser 2024-09-21 21:19:00 +01:00
parent 30aa3f5470
commit 849b6de6bc
No known key found for this signature in database
31 changed files with 338 additions and 142 deletions

View File

@ -51,6 +51,7 @@
"color.js": "^1.2.0",
"create-desktop-shortcuts": "^1.11.0",
"date-fns": "^3.6.0",
"dexie": "^4.0.8",
"electron-log": "^5.1.4",
"electron-updater": "^6.1.8",
"fetch-cookie": "^3.0.1",

View File

@ -1,8 +1,8 @@
import type { GameShop } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi, RepacksManager } from "@main/services";
import { CatalogueCategory, formatName, steamUrlBuilder } from "@shared";
import { HydraApi } from "@main/services";
import { CatalogueCategory, steamUrlBuilder } from "@shared";
import { steamGamesWorker } from "@main/workers";
const getCatalogue = async (
@ -26,14 +26,9 @@ const getCatalogue = async (
name: "getById",
});
const repacks = RepacksManager.search({
query: formatName(steamGame.name),
});
return {
title: steamGame.name,
shop: game.shop,
repacks,
cover: steamUrlBuilder.library(game.objectId),
objectID: game.objectId,
};

View File

@ -45,15 +45,17 @@ const getGameShopDetails = async (
const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
(result) => {
gameShopCacheRepository.upsert(
{
objectID,
shop: "steam",
language,
serializedData: JSON.stringify(result),
},
["objectID"]
);
if (result) {
gameShopCacheRepository.upsert(
{
objectID,
shop: "steam",
language,
serializedData: JSON.stringify(result),
},
["objectID"]
);
}
return result;
}

View File

@ -2,8 +2,6 @@ import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { RepacksManager } from "@main/services";
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
@ -15,13 +13,9 @@ const getGames = async (
{ name: "list" }
);
const entries = RepacksManager.findRepacksForCatalogueEntries(
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
);
return {
results: entries,
cursor: cursor + entries.length,
results: steamGames,
cursor: cursor + steamGames.length,
};
};

View File

@ -0,0 +1,7 @@
import { registerEvent } from "../register-event";
import { knexClient } from "@main/knex-client";
const getRepacks = (_event: Electron.IpcMainInvokeEvent) =>
knexClient.select("*").from("repack");
registerEvent("getRepacks", getRepacks);

View File

@ -1,9 +0,0 @@
import { RepacksManager } from "@main/services";
import { registerEvent } from "../register-event";
const searchGameRepacks = (
_event: Electron.IpcMainInvokeEvent,
query: string
) => RepacksManager.search({ query });
registerEvent("searchGameRepacks", searchGameRepacks);

View File

@ -4,7 +4,6 @@ import { DownloadSource } from "@main/entity";
import axios from "axios";
import { downloadSourceSchema } from "../helpers/validators";
import { insertDownloadsFromSource } from "@main/helpers";
import { RepacksManager } from "@main/services";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
@ -34,8 +33,6 @@ const addDownloadSource = async (
}
);
await RepacksManager.updateRepacks();
return downloadSource;
};

View File

@ -1,11 +1,7 @@
import { downloadSourceRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { knexClient } from "@main/knex-client";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
downloadSourceRepository.find({
order: {
createdAt: "DESC",
},
});
knexClient.select("*").from("download_source");
registerEvent("getDownloadSources", getDownloadSources);

View File

@ -5,9 +5,6 @@ import { RepacksManager } from "@main/services";
const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => {
await downloadSourceRepository.delete(id);
await RepacksManager.updateRepacks();
};
) => downloadSourceRepository.delete(id);
registerEvent("removeDownloadSource", removeDownloadSource);

View File

@ -17,7 +17,6 @@ export const convertSteamGameToCatalogueEntry = (
title: game.name,
shop: "steam" as GameShop,
cover: steamUrlBuilder.library(String(game.id)),
repacks: [],
});
export const getSteamGameById = async (

View File

@ -7,9 +7,9 @@ import "./catalogue/get-games";
import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game";
import "./catalogue/search-games";
import "./catalogue/search-game-repacks";
import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games";
import "./catalogue/get-repacks";
import "./hardware/get-disk-free-space";
import "./library/add-game-to-library";
import "./library/create-game-shortcut";

View File

@ -9,36 +9,25 @@ import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, Repack } from "@main/entity";
import { DownloadQueue, Game } from "@main/entity";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
payload: StartGameDownloadPayload
) => {
const { repackId, objectID, title, shop, downloadPath, downloader, uri } =
payload;
const { objectID, title, shop, downloadPath, downloader, uri } = payload;
return dataSource.transaction(async (transactionalEntityManager) => {
const gameRepository = transactionalEntityManager.getRepository(Game);
const repackRepository = transactionalEntityManager.getRepository(Repack);
const downloadQueueRepository =
transactionalEntityManager.getRepository(DownloadQueue);
const [game, repack] = await Promise.all([
gameRepository.findOne({
where: {
objectID,
shop,
},
}),
repackRepository.findOne({
where: {
id: repackId,
},
}),
]);
if (!repack) return;
const game = await gameRepository.findOne({
where: {
objectID,
shop,
},
});
await DownloadManager.pauseDownload();

View File

@ -68,7 +68,6 @@ const runMigrations = async () => {
});
await knexClient.migrate.latest(migrationConfig);
await knexClient.destroy();
};
// This method will be called when Electron has finished

View File

@ -1,9 +1,4 @@
import {
DownloadManager,
RepacksManager,
PythonInstance,
startMainLoop,
} from "./services";
import { DownloadManager, PythonInstance, startMainLoop } from "./services";
import {
downloadQueueRepository,
repackRepository,
@ -18,8 +13,6 @@ import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync";
const loadState = async (userPreferences: UserPreferences | null) => {
RepacksManager.updateRepacks();
import("./events");
if (userPreferences?.realDebridApiToken) {

View File

@ -49,6 +49,8 @@ contextBridge.exposeInMainWorld("electron", {
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
/* Meant for Dexie migration */
getRepacks: () => ipcRenderer.invoke("getRepacks"),
/* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),

View File

@ -26,6 +26,10 @@ import {
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { RepacksContextProvider } from "./context";
import { downloadSourcesWorker } from "./workers";
downloadSourcesWorker.postMessage("OK");
export interface AppProps {
children: React.ReactNode;
@ -197,7 +201,7 @@ export function App() {
useEffect(() => {
new MutationObserver(() => {
const modal = document.body.querySelector("[role=modal]");
const modal = document.body.querySelector("[role=dialog]");
dispatch(toggleDraggingDisabled(Boolean(modal)));
}).observe(document.body, {
@ -211,46 +215,48 @@ export function App() {
}, [dispatch]);
return (
<>
{window.electron.platform === "win32" && (
<div className={styles.titleBar}>
<h4>Hydra</h4>
</div>
)}
<RepacksContextProvider>
<>
{window.electron.platform === "win32" && (
<div className={styles.titleBar}>
<h4>Hydra</h4>
</div>
)}
<Toast
visible={toast.visible}
message={toast.message}
type={toast.type}
onClose={handleToastClose}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
userId={friendModalUserId}
<Toast
visible={toast.visible}
message={toast.message}
type={toast.type}
onClose={handleToastClose}
/>
)}
<main>
<Sidebar />
<article className={styles.container}>
<Header
onSearch={handleSearch}
search={search}
onClear={handleClear}
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
userId={friendModalUserId}
/>
)}
<section ref={contentRef} className={styles.content}>
<Outlet />
</section>
</article>
</main>
<main>
<Sidebar />
<BottomPanel />
</>
<article className={styles.container}>
<Header
onSearch={handleSearch}
search={search}
onClear={handleClear}
/>
<section ref={contentRef} className={styles.content}>
<Outlet />
</section>
</article>
</main>
<BottomPanel />
</>
</RepacksContextProvider>
);
}

View File

@ -1,13 +1,14 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import type { CatalogueEntry, GameStats } from "@types";
import type { CatalogueEntry, GameRepack, GameStats } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { useCallback, useState } from "react";
import { useCallback, useContext, useEffect, useState } from "react";
import { useFormat } from "@renderer/hooks";
import { repacksContext } from "@renderer/context";
export interface GameCardProps
extends React.DetailedHTMLProps<
@ -25,9 +26,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
const { t } = useTranslation("game_card");
const [stats, setStats] = useState<GameStats | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
useEffect(() => {
if (!isIndexingRepacks) {
searchRepacks(game.title).then((repacks) => {
setRepacks(repacks);
});
}
}, [game, isIndexingRepacks, searchRepacks]);
const uniqueRepackers = Array.from(
new Set(game.repacks.map(({ repacker }) => repacker))
new Set(repacks.map(({ repacker }) => repacker))
);
const handleHover = useCallback(() => {

View File

@ -1,4 +1,10 @@
import { createContext, useCallback, useEffect, useState } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
@ -16,6 +22,7 @@ import type {
import { useTranslation } from "react-i18next";
import { GameDetailsContext } from "./game-details.context.types";
import { SteamContentDescriptor } from "@shared";
import { repacksContext } from "../repacks/repacks.context";
export const gameDetailsContext = createContext<GameDetailsContext>({
game: null,
@ -52,7 +59,6 @@ export function GameDetailsContextProvider({
const { objectID, shop } = useParams();
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
@ -64,10 +70,22 @@ export function GameDetailsContextProvider({
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [searchParams] = useSearchParams();
const gameTitle = searchParams.get("title")!;
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
useEffect(() => {
if (!isIndexingRepacks) {
searchRepacks(gameTitle).then((repacks) => {
setRepacks(repacks);
});
}
}, [game, gameTitle, isIndexingRepacks, searchRepacks]);
const { i18n } = useTranslation("game_details");
const dispatch = useAppDispatch();
@ -91,37 +109,31 @@ export function GameDetailsContextProvider({
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
useEffect(() => {
Promise.allSettled([
window.electron.getGameShopDetails(
window.electron
.getGameShopDetails(
objectID!,
shop as GameShop,
getSteamLanguage(i18n.language)
),
window.electron.searchGameRepacks(gameTitle),
window.electron.getGameStats(objectID!, shop as GameShop),
])
.then(([appDetailsResult, repacksResult, statsResult]) => {
if (appDetailsResult.status === "fulfilled") {
setShopDetails(appDetailsResult.value);
)
.then((result) => {
setShopDetails(result);
if (
appDetailsResult.value?.content_descriptors.ids.includes(
SteamContentDescriptor.AdultOnlySexualContent
)
) {
setHasNSFWContentBlocked(true);
}
if (
result?.content_descriptors.ids.includes(
SteamContentDescriptor.AdultOnlySexualContent
)
) {
setHasNSFWContentBlocked(true);
}
if (repacksResult.status === "fulfilled")
setRepacks(repacksResult.value);
if (statsResult.status === "fulfilled") setStats(statsResult.value);
})
.finally(() => {
setIsLoading(false);
});
window.electron.getGameStats(objectID!, shop as GameShop).then((result) => {
setStats(result);
});
updateGame();
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);

View File

@ -1,3 +1,4 @@
export * from "./game-details/game-details.context";
export * from "./settings/settings.context";
export * from "./user-profile/user-profile.context";
export * from "./repacks/repacks.context";

View File

@ -0,0 +1,58 @@
import type { GameRepack } from "@types";
import { createContext, useCallback, useEffect, useState } from "react";
import { repacksWorker } from "@renderer/workers";
export interface RepacksContext {
searchRepacks: (query: string) => Promise<GameRepack[]>;
isIndexingRepacks: boolean;
}
export const repacksContext = createContext<RepacksContext>({
searchRepacks: async () => [] as GameRepack[],
isIndexingRepacks: false,
});
const { Provider } = repacksContext;
export const { Consumer: RepacksContextConsumer } = repacksContext;
export interface RepacksContextProps {
children: React.ReactNode;
}
export function RepacksContextProvider({ children }: RepacksContextProps) {
const [isIndexingRepacks, setIsIndexingRepacks] = useState(true);
const searchRepacks = useCallback(async (query: string) => {
return new Promise<GameRepack[]>((resolve) => {
const channelId = crypto.randomUUID();
repacksWorker.postMessage([channelId, query]);
const channel = new BroadcastChannel(`repacks:search:${channelId}`);
channel.onmessage = (event: MessageEvent<GameRepack[]>) => {
resolve(event.data);
};
return [];
});
}, []);
useEffect(() => {
repacksWorker.postMessage("INDEX_REPACKS");
repacksWorker.onmessage = () => {
setIsIndexingRepacks(false);
};
}, []);
return (
<Provider
value={{
searchRepacks,
isIndexingRepacks,
}}
>
{children}
</Provider>
);
}

View File

@ -65,6 +65,8 @@ declare global {
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getTrendingGames: () => Promise<TrendingGame[]>;
/* Meant for Dexie migration */
getRepacks: () => Promise<GameRepack[]>;
/* Library */
addGameToLibrary: (

13
src/renderer/src/dexie.ts Normal file
View File

@ -0,0 +1,13 @@
import { Dexie } from "dexie";
export const db = new Dexie("Hydra");
db.version(1).stores({
repacks: `++id, title, uri, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
});
export const downloadSourcesTable = db.table("downloadSources");
export const repacksTable = db.table("repacks");
db.open();

View File

@ -29,6 +29,8 @@ import { store } from "./store";
import resources from "@locales";
import "./workers";
Sentry.init({});
i18n

View File

@ -8,6 +8,7 @@ import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { downloadSourcesTable } from "@renderer/dexie";
interface AddDownloadSourceModalProps {
visible: boolean;
@ -91,6 +92,9 @@ export function AddDownloadSourceModal({
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const handleAddDownloadSource = async () => {
await downloadSourcesTable.add({
url,
});
await window.electron.addDownloadSource(url);
onClose();
onAddDownloadSource();

View File

@ -11,6 +11,7 @@ import { useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
export function SettingsDownloadSources() {
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
@ -25,7 +26,7 @@ export function SettingsDownloadSources() {
const { showSuccessToast } = useToast();
const getDownloadSources = async () => {
return window.electron.getDownloadSources().then((sources) => {
downloadSourcesTable.toArray().then((sources) => {
setDownloadSources(sources);
});
};
@ -39,7 +40,11 @@ export function SettingsDownloadSources() {
}, [sourceUrl]);
const handleRemoveSource = async (id: number) => {
await window.electron.removeDownloadSource(id);
await db.transaction("rw", downloadSourcesTable, repacksTable, async () => {
await downloadSourcesTable.where({ id }).delete();
await repacksTable.where({ downloadSourceId: id }).delete();
});
showSuccessToast(t("removed_download_source"));
getDownloadSources();

View File

@ -0,0 +1,8 @@
import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
self.onmessage = () => {
db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
await repacksTable.where({ downloadSourceId: 10 }).delete();
await downloadSourcesTable.where({ id: 10 }).delete();
});
};

View File

@ -0,0 +1,24 @@
import MigrationWorker from "./migration.worker?worker";
import RepacksWorker from "./repacks.worker?worker";
import DownloadSourcesWorker from "./download-sources.worker?worker";
// const migrationWorker = new MigrationWorker();
export const repacksWorker = new RepacksWorker();
export const downloadSourcesWorker = new DownloadSourcesWorker();
// window.electron.getRepacks().then((repacks) => {
// console.log(repacks);
// migrationWorker.postMessage(["MIGRATE_REPACKS", repacks]);
// });
// window.electron.getDownloadSources().then((downloadSources) => {
// migrationWorker.postMessage(["MIGRATE_DOWNLOAD_SOURCES", downloadSources]);
// });
// migrationWorker.onmessage = (event) => {
// console.log(event.data);
// };
// setTimeout(() => {
// repacksWorker.postMessage("god");
// }, 500);

View File

@ -0,0 +1,32 @@
import { downloadSourcesTable, repacksTable } from "@renderer/dexie";
import { DownloadSource, GameRepack } from "@types";
export type Payload =
| ["MIGRATE_REPACKS", GameRepack[]]
| ["MIGRATE_DOWNLOAD_SOURCES", DownloadSource[]];
self.onmessage = async (event: MessageEvent<Payload>) => {
const [type, data] = event.data;
if (type === "MIGRATE_DOWNLOAD_SOURCES") {
const dexieDownloadSources = await downloadSourcesTable.count();
if (data.length !== dexieDownloadSources) {
await downloadSourcesTable.clear();
await downloadSourcesTable.bulkAdd(data);
}
self.postMessage("MIGRATE_DOWNLOAD_SOURCES_COMPLETE");
}
if (type === "MIGRATE_REPACKS") {
const dexieRepacks = await repacksTable.count();
if (data.length !== dexieRepacks) {
await repacksTable.clear();
await repacksTable.bulkAdd(data);
}
self.postMessage("MIGRATE_REPACKS_COMPLETE");
}
};

View File

@ -0,0 +1,52 @@
import { repacksTable } from "@renderer/dexie";
import { formatName } from "@shared";
import { GameRepack } from "@types";
import flexSearch from "flexsearch";
const index = new flexSearch.Index();
const state = {
repacks: [] as any[],
};
interface SerializedGameRepack extends Omit<GameRepack, "uris"> {
uris: string;
}
self.onmessage = async (
event: MessageEvent<[string, string] | "INDEX_REPACKS">
) => {
if (event.data === "INDEX_REPACKS") {
repacksTable
.toCollection()
.sortBy("uploadDate")
.then((results) => {
state.repacks = results.reverse();
for (let i = 0; i < state.repacks.length; i++) {
const repack = state.repacks[i];
const formattedTitle = formatName(repack.title);
index.add(i, formattedTitle);
}
self.postMessage("INDEXING_COMPLETE");
});
} else {
const [requestId, query] = event.data;
const results = index.search(formatName(query)).map((index) => {
const repack = state.repacks.at(index as number) as SerializedGameRepack;
const uris = JSON.parse(repack.uris);
return {
...repack,
uris: [...uris, repack.magnet].filter(Boolean),
};
});
const channel = new BroadcastChannel(`repacks:search:${requestId}`);
channel.postMessage(results);
}
};

View File

@ -44,7 +44,6 @@ export interface CatalogueEntry {
title: string;
/* Epic Games covers cannot be guessed with objectID */
cover: string;
repacks: GameRepack[];
}
export interface UserGame {
@ -71,7 +70,6 @@ export interface Game {
status: GameStatus | null;
folderName: string;
downloadPath: string | null;
repacks: GameRepack[];
progress: number;
bytesDownloaded: number;
playTimeInMilliseconds: number;

View File

@ -3638,6 +3638,11 @@ detect-node@^2.0.4:
resolved "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz"
integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
dexie@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/dexie/-/dexie-4.0.8.tgz#21fca70686bdaa1d86fad45b6b19316f6a084a1d"
integrity sha512-1G6cJevS17KMDK847V3OHvK2zei899GwpDiqfEXHP1ASvme6eWJmAp9AU4s1son2TeGkWmC0g3y8ezOBPnalgQ==
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"