feat: improving download source import code

This commit is contained in:
Chubby Granny Chaser
2025-10-14 14:52:47 +01:00
parent c60753547c
commit bfa2fd6166
3 changed files with 162 additions and 42 deletions

View File

@@ -16,6 +16,8 @@ const downloadSourceSchema = z.object({
}); });
type SteamGamesByLetter = Record<string, { id: string; name: string }[]>; type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
type FormattedSteamGame = { id: string; name: string; formattedName: string };
type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
const formatName = (name: string) => { const formatName = (name: string) => {
return name return name
@@ -29,8 +31,48 @@ const formatRepackName = (name: string) => {
return formatName(name.replace("[DL]", "")); return formatName(name.replace("[DL]", ""));
}; };
interface DownloadSource {
id: number;
url: string;
name: string;
etag: string | null;
status: number;
downloadCount: number;
objectIds: string[];
fingerprint?: string;
createdAt: Date;
updatedAt: Date;
}
let downloadSourcesCache: Map<string, DownloadSource> | null = null;
let downloadSourcesCacheTime = 0;
const CACHE_TTL = 5000;
const getDownloadSourcesMap = async (): Promise<
Map<string, DownloadSource>
> => {
const now = Date.now();
if (downloadSourcesCache && now - downloadSourcesCacheTime < CACHE_TTL) {
return downloadSourcesCache;
}
const map = new Map();
for await (const [key, source] of downloadSourcesSublevel.iterator()) {
map.set(key, source);
}
downloadSourcesCache = map;
downloadSourcesCacheTime = now;
return map;
};
export const invalidateDownloadSourcesCache = () => {
downloadSourcesCache = null;
};
export const checkUrlExists = async (url: string): Promise<boolean> => { export const checkUrlExists = async (url: string): Promise<boolean> => {
for await (const [, source] of downloadSourcesSublevel.iterator()) { const sources = await getDownloadSourcesMap();
for (const source of sources.values()) {
if (source.url === url) { if (source.url === url) {
return true; return true;
} }
@@ -38,12 +80,31 @@ export const checkUrlExists = async (url: string): Promise<boolean> => {
return false; return false;
}; };
const getSteamGames = async () => { let steamGamesCache: FormattedSteamGamesByLetter | null = null;
let steamGamesCacheTime = 0;
const STEAM_GAMES_CACHE_TTL = 300000;
const getSteamGames = async (): Promise<FormattedSteamGamesByLetter> => {
const now = Date.now();
if (steamGamesCache && now - steamGamesCacheTime < STEAM_GAMES_CACHE_TTL) {
return steamGamesCache;
}
const response = await axios.get<SteamGamesByLetter>( const response = await axios.get<SteamGamesByLetter>(
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json` `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
); );
return response.data; const formattedData: FormattedSteamGamesByLetter = {};
for (const [letter, games] of Object.entries(response.data)) {
formattedData[letter] = games.map((game) => ({
...game,
formattedName: formatName(game.name),
}));
}
steamGamesCache = formattedData;
steamGamesCacheTime = now;
return formattedData;
}; };
type SublevelIterator = AsyncIterable<[string, { id: number }]>; type SublevelIterator = AsyncIterable<[string, { id: number }]>;
@@ -52,33 +113,61 @@ interface SublevelWithId {
iterator: () => SublevelIterator; iterator: () => SublevelIterator;
} }
let maxRepackId: number | null = null;
let maxDownloadSourceId: number | null = null;
const getNextId = async (sublevel: SublevelWithId): Promise<number> => { const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
const isRepackSublevel = sublevel === repacksSublevel;
const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel;
if (isRepackSublevel && maxRepackId !== null) {
return ++maxRepackId;
}
if (isDownloadSourceSublevel && maxDownloadSourceId !== null) {
return ++maxDownloadSourceId;
}
let maxId = 0; let maxId = 0;
for await (const [, value] of sublevel.iterator()) { for await (const [, value] of sublevel.iterator()) {
if (value.id > maxId) { if (value.id > maxId) {
maxId = value.id; maxId = value.id;
} }
} }
if (isRepackSublevel) {
maxRepackId = maxId;
} else if (isDownloadSourceSublevel) {
maxDownloadSourceId = maxId;
}
return maxId + 1; return maxId + 1;
}; };
export const invalidateIdCaches = () => {
maxRepackId = null;
maxDownloadSourceId = null;
};
const addNewDownloads = async ( const addNewDownloads = async (
downloadSource: { id: number; name: string }, downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"], downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: SteamGamesByLetter steamGames: FormattedSteamGamesByLetter
) => { ) => {
const now = new Date(); const now = new Date();
const objectIdsOnSource = new Set<string>(); const objectIdsOnSource = new Set<string>();
let nextRepackId = await getNextId(repacksSublevel); let nextRepackId = await getNextId(repacksSublevel);
const batch = repacksSublevel.batch();
for (const download of downloads) { for (const download of downloads) {
const formattedTitle = formatRepackName(download.title); const formattedTitle = formatRepackName(download.title);
const [firstLetter] = formattedTitle; const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || []; const games = steamGames[firstLetter] || [];
const gamesInSteam = games.filter((game) => const gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(formatName(game.name)) formattedTitle.startsWith(game.formattedName)
); );
if (gamesInSteam.length === 0) continue; if (gamesInSteam.length === 0) continue;
@@ -100,9 +189,11 @@ const addNewDownloads = async (
updatedAt: now, updatedAt: now,
}; };
await repacksSublevel.put(`${repack.id}`, repack); batch.put(`${repack.id}`, repack);
} }
await batch.write();
const existingSource = await downloadSourcesSublevel.get( const existingSource = await downloadSourcesSublevel.get(
`${downloadSource.id}` `${downloadSource.id}`
); );
@@ -134,14 +225,6 @@ export const importDownloadSourceToLocal = async (
const now = new Date(); 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 nextId = await getNextId(downloadSourcesSublevel);
const downloadSource = { const downloadSource = {
@@ -158,6 +241,8 @@ export const importDownloadSourceToLocal = async (
await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource); await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource);
invalidateDownloadSourcesCache();
const objectIds = await addNewDownloads( const objectIds = await addNewDownloads(
downloadSource, downloadSource,
response.data.downloads, response.data.downloads,

View File

@@ -1,6 +1,5 @@
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { downloadSourcesSublevel } from "@main/level"; import { importDownloadSourceToLocal, checkUrlExists } from "./helpers";
import { importDownloadSourceToLocal } from "./helpers";
export const syncDownloadSourcesFromApi = async () => { export const syncDownloadSourcesFromApi = async () => {
try { try {
@@ -8,15 +7,9 @@ export const syncDownloadSourcesFromApi = async () => {
{ url: string; createdAt: string; updatedAt: string }[] { url: string; createdAt: string; updatedAt: string }[]
>("/profile/download-sources"); >("/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) { for (const apiSource of apiSources) {
if (!localUrls.has(apiSource.url)) { const exists = await checkUrlExists(apiSource.url);
if (!exists) {
await importDownloadSourceToLocal(apiSource.url, false); await importDownloadSourceToLocal(apiSource.url, false);
} }
} }

View File

@@ -3,6 +3,11 @@ import axios, { AxiosError } from "axios";
import { z } from "zod"; import { z } from "zod";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { DownloadSourceStatus } from "@shared"; import { DownloadSourceStatus } from "@shared";
import {
checkUrlExists,
invalidateDownloadSourcesCache,
invalidateIdCaches,
} from "./helpers";
const downloadSourceSchema = z.object({ const downloadSourceSchema = z.object({
name: z.string().max(255), name: z.string().max(255),
@@ -17,6 +22,8 @@ const downloadSourceSchema = z.object({
}); });
type SteamGamesByLetter = Record<string, { id: string; name: string }[]>; type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
type FormattedSteamGame = { id: string; name: string; formattedName: string };
type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
const formatName = (name: string) => { const formatName = (name: string) => {
return name return name
@@ -30,21 +37,31 @@ const formatRepackName = (name: string) => {
return formatName(name.replace("[DL]", "")); return formatName(name.replace("[DL]", ""));
}; };
const checkUrlExists = async (url: string): Promise<boolean> => { let steamGamesCache: FormattedSteamGamesByLetter | null = null;
for await (const [, source] of downloadSourcesSublevel.iterator()) { let steamGamesCacheTime = 0;
if (source.url === url) { const STEAM_GAMES_CACHE_TTL = 300000;
return true;
} const getSteamGames = async (): Promise<FormattedSteamGamesByLetter> => {
} const now = Date.now();
return false; if (steamGamesCache && now - steamGamesCacheTime < STEAM_GAMES_CACHE_TTL) {
}; return steamGamesCache;
}
const getSteamGames = async () => {
const response = await axios.get<SteamGamesByLetter>( const response = await axios.get<SteamGamesByLetter>(
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json` `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
); );
return response.data; const formattedData: FormattedSteamGamesByLetter = {};
for (const [letter, games] of Object.entries(response.data)) {
formattedData[letter] = games.map((game) => ({
...game,
formattedName: formatName(game.name),
}));
}
steamGamesCache = formattedData;
steamGamesCacheTime = now;
return formattedData;
}; };
type SublevelIterator = AsyncIterable<[string, { id: number }]>; type SublevelIterator = AsyncIterable<[string, { id: number }]>;
@@ -53,33 +70,56 @@ interface SublevelWithId {
iterator: () => SublevelIterator; iterator: () => SublevelIterator;
} }
let maxRepackId: number | null = null;
let maxDownloadSourceId: number | null = null;
const getNextId = async (sublevel: SublevelWithId): Promise<number> => { const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
const isRepackSublevel = sublevel === repacksSublevel;
const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel;
if (isRepackSublevel && maxRepackId !== null) {
return ++maxRepackId;
}
if (isDownloadSourceSublevel && maxDownloadSourceId !== null) {
return ++maxDownloadSourceId;
}
let maxId = 0; let maxId = 0;
for await (const [, value] of sublevel.iterator()) { for await (const [, value] of sublevel.iterator()) {
if (value.id > maxId) { if (value.id > maxId) {
maxId = value.id; maxId = value.id;
} }
} }
if (isRepackSublevel) {
maxRepackId = maxId;
} else if (isDownloadSourceSublevel) {
maxDownloadSourceId = maxId;
}
return maxId + 1; return maxId + 1;
}; };
const addNewDownloads = async ( const addNewDownloads = async (
downloadSource: { id: number; name: string }, downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"], downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: SteamGamesByLetter steamGames: FormattedSteamGamesByLetter
) => { ) => {
const now = new Date(); const now = new Date();
const objectIdsOnSource = new Set<string>(); const objectIdsOnSource = new Set<string>();
let nextRepackId = await getNextId(repacksSublevel); let nextRepackId = await getNextId(repacksSublevel);
const batch = repacksSublevel.batch();
for (const download of downloads) { for (const download of downloads) {
const formattedTitle = formatRepackName(download.title); const formattedTitle = formatRepackName(download.title);
const [firstLetter] = formattedTitle; const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || []; const games = steamGames[firstLetter] || [];
const gamesInSteam = games.filter((game) => const gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(formatName(game.name)) formattedTitle.startsWith(game.formattedName)
); );
if (gamesInSteam.length === 0) continue; if (gamesInSteam.length === 0) continue;
@@ -101,9 +141,11 @@ const addNewDownloads = async (
updatedAt: now, updatedAt: now,
}; };
await repacksSublevel.put(`${repack.id}`, repack); batch.put(`${repack.id}`, repack);
} }
await batch.write();
const existingSource = await downloadSourcesSublevel.get( const existingSource = await downloadSourcesSublevel.get(
`${downloadSource.id}` `${downloadSource.id}`
); );
@@ -131,6 +173,9 @@ const deleteDownloadSource = async (id: number) => {
await batch.write(); await batch.write();
await downloadSourcesSublevel.del(`${id}`); await downloadSourcesSublevel.del(`${id}`);
invalidateDownloadSourcesCache();
invalidateIdCaches();
}; };
const importDownloadSource = async (url: string) => { const importDownloadSource = async (url: string) => {
@@ -145,11 +190,6 @@ const importDownloadSource = async (url: string) => {
const now = new Date(); const now = new Date();
const urlExistsBeforeInsert = await checkUrlExists(url);
if (urlExistsBeforeInsert) {
return;
}
const nextId = await getNextId(downloadSourcesSublevel); const nextId = await getNextId(downloadSourcesSublevel);
const downloadSource = { const downloadSource = {
@@ -166,6 +206,8 @@ const importDownloadSource = async (url: string) => {
await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource); await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource);
invalidateDownloadSourcesCache();
await addNewDownloads(downloadSource, response.data.downloads, steamGames); await addNewDownloads(downloadSource, response.data.downloads, steamGames);
}; };