Merge branch 'main' into feature/game-achievements

# Conflicts:
#	src/renderer/src/context/game-details/game-details.context.tsx
#	src/renderer/src/main.tsx
This commit is contained in:
Zamitto 2024-09-27 20:52:40 -03:00
commit eda47fc6af
60 changed files with 900 additions and 641 deletions

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useContext, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
@ -26,6 +26,8 @@ import {
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { downloadSourcesWorker } from "./workers";
import { repacksContext } from "./context";
export interface AppProps {
children: React.ReactNode;
@ -37,8 +39,12 @@ export function App() {
const { t } = useTranslation("app");
const downloadSourceMigrationLock = useRef(false);
const { clearDownload, setLastPacket } = useDownload();
const { indexRepacks } = useContext(repacksContext);
const {
isFriendsModalVisible,
friendRequetsModalTab,
@ -197,7 +203,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, {
@ -206,6 +212,49 @@ export function App() {
});
}, [dispatch, draggingDisabled]);
useEffect(() => {
if (downloadSourceMigrationLock.current) return;
downloadSourceMigrationLock.current = true;
window.electron.getDownloadSources().then(async (downloadSources) => {
if (!downloadSources.length) {
const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
channel.onmessage = (event: MessageEvent<number>) => {
const newRepacksCount = event.data;
window.electron.publishNewRepacksNotification(newRepacksCount);
};
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}
for (const downloadSource of downloadSources) {
const channel = new BroadcastChannel(
`download_sources:import:${downloadSource.url}`
);
await new Promise((resolve) => {
downloadSourcesWorker.postMessage([
"IMPORT_DOWNLOAD_SOURCE",
downloadSource.url,
]);
channel.onmessage = () => {
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
resolve(true);
});
indexRepacks();
channel.close();
};
}).catch(() => channel.close());
}
downloadSourceMigrationLock.current = false;
});
}, [indexRepacks]);
const handleToastClose = useCallback(() => {
dispatch(closeToast());
}, [dispatch]);

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";
@ -17,6 +23,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,
@ -54,7 +61,6 @@ export function GameDetailsContextProvider({
const { objectID, shop } = useParams();
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
@ -67,10 +73,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();
@ -94,42 +112,41 @@ 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),
window.electron.getGameAchievements(objectID!, shop as GameShop),
])
.then(([appDetailsResult, repacksResult, statsResult, achievements]) => {
if (appDetailsResult.status === "fulfilled") {
setShopDetails(appDetailsResult.value);
)
.then((result) => {
setShopDetails(result);
if (
appDetailsResult.value?.content_descriptors.ids.includes(
SteamContentDescriptor.AdultOnlySexualContent
)
) {
setHasNSFWContentBlocked(true);
}
}
if (repacksResult.status === "fulfilled")
setRepacks(repacksResult.value);
if (statsResult.status === "fulfilled") setStats(statsResult.value);
if (achievements.status === "fulfilled") {
setAchievements(achievements.value);
if (
result?.content_descriptors.ids.includes(
SteamContentDescriptor.AdultOnlySexualContent
)
) {
setHasNSFWContentBlocked(true);
}
})
.finally(() => {
setIsLoading(false);
});
window.electron
.getGameStats(objectID!, shop as GameShop)
.then((result) => {
setStats(result);
})
.catch(() => {});
window.electron
.getGameAchievements(objectID!, shop as GameShop)
.then((achievements) => {
setAchievements(achievements);
})
.catch(() => {});
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,67 @@
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[]>;
indexRepacks: () => void;
isIndexingRepacks: boolean;
}
export const repacksContext = createContext<RepacksContext>({
searchRepacks: async () => [] as GameRepack[],
indexRepacks: () => {},
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);
channel.close();
};
return [];
});
}, []);
const indexRepacks = useCallback(() => {
setIsIndexingRepacks(true);
repacksWorker.postMessage("INDEX_REPACKS");
repacksWorker.onmessage = () => {
setIsIndexingRepacks(false);
};
}, []);
useEffect(() => {
indexRepacks();
}, [indexRepacks]);
return (
<Provider
value={{
searchRepacks,
indexRepacks,
isIndexingRepacks,
}}
>
{children}
</Provider>
);
}

View file

@ -115,12 +115,7 @@ declare global {
/* Download sources */
getDownloadSources: () => Promise<DownloadSource[]>;
validateDownloadSource: (
url: string
) => Promise<{ name: string; downloadCount: number }>;
addDownloadSource: (url: string) => Promise<DownloadSource>;
removeDownloadSource: (id: number) => Promise<void>;
syncDownloadSources: () => Promise<void>;
deleteDownloadSource: (id: number) => Promise<void>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
@ -184,6 +179,9 @@ declare global {
action: FriendRequestAction
) => Promise<void>;
sendFriendRequest: (userId: string) => Promise<void>;
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
}
interface Window {

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, uris, 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

@ -90,19 +90,9 @@ export function useUserDetails() {
username: userDetails?.username || "",
});
},
[updateUserDetails]
[updateUserDetails, userDetails?.username]
);
const fetchFriendRequests = useCallback(async () => {
return window.electron
.getFriendRequests()
.then((friendRequests) => {
syncFriendRequests();
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch]);
const syncFriendRequests = useCallback(async () => {
return window.electron
.syncFriendRequests()
@ -112,6 +102,16 @@ export function useUserDetails() {
.catch(() => {});
}, [dispatch]);
const fetchFriendRequests = useCallback(async () => {
return window.electron
.getFriendRequests()
.then((friendRequests) => {
syncFriendRequests();
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch, syncFriendRequests]);
const showFriendsModal = useCallback(
(initialTab: UserFriendModalTab, userId: string) => {
dispatch(setFriendsModalVisible({ initialTab, userId }));

View file

@ -30,6 +30,9 @@ import { store } from "./store";
import resources from "@locales";
import { Achievemnt } from "./pages/achievement/achievement";
import "./workers";
import { RepacksContextProvider } from "./context";
Sentry.init({});
i18n
@ -55,20 +58,22 @@ i18n
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<HashRouter>
<Routes>
<Route element={<App />}>
<Route path="/" Component={Home} />
<Route path="/catalogue" Component={Catalogue} />
<Route path="/downloads" Component={Downloads} />
<Route path="/game/:shop/:objectID" Component={GameDetails} />
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
<Route path="/profile/:userId" Component={Profile} />
</Route>
<Route path="/achievement-notification" Component={Achievemnt} />
</Routes>
</HashRouter>
<RepacksContextProvider>
<HashRouter>
<Routes>
<Route element={<App />}>
<Route path="/" Component={Home} />
<Route path="/catalogue" Component={Catalogue} />
<Route path="/downloads" Component={Downloads} />
<Route path="/game/:shop/:objectID" Component={GameDetails} />
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
<Route path="/profile/:userId" Component={Profile} />
</Route>
<Route path="/achievement-notification" Component={Achievemnt} />
</Routes>
</HashRouter>
</RepacksContextProvider>
</Provider>
</React.StrictMode>
);

View file

@ -1,6 +1,5 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import parseTorrent from "parse-torrent";
import { Badge, Button, Modal, TextField } from "@renderer/components";
import type { GameRepack } from "@types";
@ -33,8 +32,6 @@ export function RepacksModal({
const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const [infoHash, setInfoHash] = useState<string | null>(null);
const { repacks, game } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@ -43,18 +40,9 @@ export function RepacksModal({
return orderBy(repacks, (repack) => repack.uploadDate, "desc");
}, [repacks]);
const getInfoHash = useCallback(async () => {
if (game?.uri?.startsWith("magnet:")) {
const torrent = await parseTorrent(game?.uri ?? "");
if (torrent.infoHash) setInfoHash(torrent.infoHash);
}
}, [game]);
useEffect(() => {
setFilteredRepacks(sortedRepacks);
if (game?.uri) getInfoHash();
}, [sortedRepacks, visible, game, getInfoHash]);
}, [sortedRepacks, visible, game]);
const handleRepackClick = (repack: GameRepack) => {
setRepack(repack);
@ -77,9 +65,6 @@ export function RepacksModal({
};
const checkIfLastDownloadedOption = (repack: GameRepack) => {
if (infoHash) return repack.uris.some((uri) => uri.includes(infoHash));
if (!game?.uri) return false;
return repack.uris.some((uri) => uri.includes(game?.uri ?? ""));
};

View file

@ -8,6 +8,9 @@ import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { downloadSourcesTable } from "@renderer/dexie";
import { DownloadSourceValidationResult } from "@types";
import { downloadSourcesWorker } from "@renderer/workers";
interface AddDownloadSourceModalProps {
visible: boolean;
@ -39,41 +42,48 @@ export function AddDownloadSourceModal({
setValue,
setError,
clearErrors,
formState: { errors },
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: yupResolver(schema),
});
const [validationResult, setValidationResult] = useState<{
name: string;
downloadCount: number;
} | null>(null);
const [validationResult, setValidationResult] =
useState<DownloadSourceValidationResult | null>(null);
const { sourceUrl } = useContext(settingsContext);
const onSubmit = useCallback(
async (values: FormValues) => {
setIsLoading(true);
const existingDownloadSource = await downloadSourcesTable
.where({ url: values.url })
.first();
try {
const result = await window.electron.validateDownloadSource(values.url);
setValidationResult(result);
if (existingDownloadSource) {
setError("url", {
type: "server",
message: t("source_already_exists"),
});
setUrl(values.url);
} catch (error: unknown) {
if (error instanceof Error) {
if (
error.message.endsWith("Source with the same url already exists")
) {
setError("url", {
type: "server",
message: t("source_already_exists"),
});
}
}
} finally {
setIsLoading(false);
return;
}
downloadSourcesWorker.postMessage([
"VALIDATE_DOWNLOAD_SOURCE",
values.url,
]);
const channel = new BroadcastChannel(
`download_sources:validate:${values.url}`
);
channel.onmessage = (
event: MessageEvent<DownloadSourceValidationResult>
) => {
setValidationResult(event.data);
channel.close();
};
setUrl(values.url);
},
[setError, t]
);
@ -91,9 +101,21 @@ export function AddDownloadSourceModal({
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const handleAddDownloadSource = async () => {
await window.electron.addDownloadSource(url);
onClose();
onAddDownloadSource();
setIsLoading(true);
if (validationResult) {
const channel = new BroadcastChannel(`download_sources:import:${url}`);
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
channel.onmessage = () => {
setIsLoading(false);
onClose();
onAddDownloadSource();
channel.close();
};
}
};
return (
@ -122,7 +144,7 @@ export function AddDownloadSourceModal({
theme="outline"
style={{ alignSelf: "flex-end" }}
onClick={handleSubmit(onSubmit)}
disabled={isLoading}
disabled={isSubmitting || isLoading}
>
{t("validate_download_source")}
</Button>

View file

@ -10,7 +10,9 @@ import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
import { repacksContext, settingsContext } from "@renderer/context";
import { downloadSourcesTable } from "@renderer/dexie";
import { downloadSourcesWorker } from "@renderer/workers";
export function SettingsDownloadSources() {
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
@ -18,16 +20,23 @@ export function SettingsDownloadSources() {
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isSyncingDownloadSources, setIsSyncingDownloadSources] =
useState(false);
const [isRemovingDownloadSource, setIsRemovingDownloadSource] =
useState(false);
const { sourceUrl, clearSourceUrl } = useContext(settingsContext);
const { t } = useTranslation("settings");
const { showSuccessToast } = useToast();
const { indexRepacks } = useContext(repacksContext);
const getDownloadSources = async () => {
return window.electron.getDownloadSources().then((sources) => {
setDownloadSources(sources);
});
await downloadSourcesTable
.toCollection()
.sortBy("createdAt")
.then((sources) => {
setDownloadSources(sources.reverse());
});
};
useEffect(() => {
@ -38,14 +47,24 @@ export function SettingsDownloadSources() {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
const handleRemoveSource = async (id: number) => {
await window.electron.removeDownloadSource(id);
showSuccessToast(t("removed_download_source"));
const handleRemoveSource = (id: number) => {
setIsRemovingDownloadSource(true);
const channel = new BroadcastChannel(`download_sources:delete:${id}`);
getDownloadSources();
downloadSourcesWorker.postMessage(["DELETE_DOWNLOAD_SOURCE", id]);
channel.onmessage = () => {
showSuccessToast(t("removed_download_source"));
getDownloadSources();
indexRepacks();
setIsRemovingDownloadSource(false);
channel.close();
};
};
const handleAddDownloadSource = async () => {
indexRepacks();
await getDownloadSources();
showSuccessToast(t("added_download_source"));
};
@ -53,15 +72,17 @@ export function SettingsDownloadSources() {
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
window.electron
.syncDownloadSources()
.then(() => {
showSuccessToast(t("download_sources_synced"));
getDownloadSources();
})
.finally(() => {
setIsSyncingDownloadSources(false);
});
const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
channel.onmessage = () => {
showSuccessToast(t("download_sources_synced"));
getDownloadSources();
setIsSyncingDownloadSources(false);
channel.close();
};
};
const statusTitle = {
@ -88,7 +109,11 @@ export function SettingsDownloadSources() {
<Button
type="button"
theme="outline"
disabled={!downloadSources.length || isSyncingDownloadSources}
disabled={
!downloadSources.length ||
isSyncingDownloadSources ||
isRemovingDownloadSource
}
onClick={syncDownloadSources}
>
<SyncIcon />
@ -99,6 +124,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => setShowAddDownloadSourceModal(true)}
disabled={isSyncingDownloadSources}
>
<PlusCircleIcon />
{t("add_download_source")}
@ -148,6 +174,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => handleRemoveSource(downloadSource.id)}
disabled={isRemovingDownloadSource}
>
<NoEntryIcon />
{t("remove_download_source")}

View file

@ -0,0 +1,165 @@
import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
import { z } from "zod";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { DownloadSourceStatus } from "@shared";
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];
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_DOWNLOAD_SOURCE") {
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
await repacksTable.where({ downloadSourceId: data }).delete();
await downloadSourcesTable.where({ id: data }).delete();
});
const channel = new BroadcastChannel(`download_sources:delete:${data}`);
channel.postMessage(true);
}
if (type === "IMPORT_DOWNLOAD_SOURCE") {
const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
const now = new Date();
const id = await downloadSourcesTable.add({
url: data,
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);
const repacks = response.data.downloads.map((download) => ({
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: response.data.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource!.id,
createdAt: now,
updatedAt: now,
}));
await repacksTable.bulkAdd(repacks);
});
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();
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);
await db.transaction(
"rw",
repacksTable,
downloadSourcesTable,
async () => {
await downloadSourcesTable.update(downloadSource.id, {
etag: response.headers["etag"],
downloadCount: source.downloads.length,
status: DownloadSourceStatus.UpToDate,
});
const now = new Date();
const repacks = source.downloads
.filter(
(download) =>
!existingRepacks.some(
(repack) => repack.title === download.title
)
)
.map((download) => ({
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: source.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
}));
newRepacksCount += repacks.length;
await repacksTable.bulkAdd(repacks);
}
);
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
await downloadSourcesTable.update(downloadSource.id, {
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
channel.postMessage(newRepacksCount);
} catch (err) {
channel.postMessage(-1);
}
}
};

View file

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

View file

@ -0,0 +1,50 @@
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;
return {
...repack,
uris: [...repack.uris, repack.magnet].filter(Boolean),
};
});
const channel = new BroadcastChannel(`repacks:search:${requestId}`);
channel.postMessage(results);
}
};