mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-22 02:13:59 +00:00
feat: improving caching
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
|
||||
import { HydraApi } from "@main/services";
|
||||
import {
|
||||
importDownloadSourceToLocal,
|
||||
invalidateDownloadSourcesCache,
|
||||
} from "./helpers";
|
||||
import { HydraApi, logger } from "@main/services";
|
||||
import { importDownloadSourceToLocal } from "./helpers";
|
||||
|
||||
const addDownloadSource = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -26,17 +23,6 @@ const addDownloadSource = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Log for debugging - helps identify if repacks are being created
|
||||
console.log(
|
||||
`✅ Download source ${result.id} (${result.name}) created with ${repackCount} repacks`
|
||||
);
|
||||
console.log(
|
||||
` Repack IDs: [${repackIds.slice(0, 5).join(", ")}${repackIds.length > 5 ? "..." : ""}]`
|
||||
);
|
||||
console.log(
|
||||
` Object IDs: [${result.objectIds.slice(0, 5).join(", ")}${result.objectIds.length > 5 ? "..." : ""}]`
|
||||
);
|
||||
|
||||
await HydraApi.post("/profile/download-sources", {
|
||||
urls: [url],
|
||||
});
|
||||
@@ -74,18 +60,15 @@ const addDownloadSource = async (
|
||||
}
|
||||
|
||||
if (finalRepackCount !== repackCount) {
|
||||
console.warn(
|
||||
`⚠️ Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}`
|
||||
logger.warn(
|
||||
`Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`✅ Final verification passed: ${finalRepackCount} repacks confirmed`
|
||||
logger.info(
|
||||
`Final verification passed: ${finalRepackCount} repacks confirmed`
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate cache to ensure fresh data on next read
|
||||
invalidateDownloadSourcesCache();
|
||||
|
||||
return {
|
||||
...result,
|
||||
fingerprint,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
|
||||
import { invalidateDownloadSourcesCache, invalidateIdCaches } from "./helpers";
|
||||
import { invalidateIdCaches } from "./helpers";
|
||||
|
||||
const deleteAllDownloadSources = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
) => {
|
||||
await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]);
|
||||
|
||||
// Invalidate caches after clearing all sources
|
||||
invalidateDownloadSourcesCache();
|
||||
invalidateIdCaches();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
|
||||
import { invalidateDownloadSourcesCache, invalidateIdCaches } from "./helpers";
|
||||
import { invalidateIdCaches } from "./helpers";
|
||||
|
||||
const deleteDownloadSource = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -22,8 +22,6 @@ const deleteDownloadSource = async (
|
||||
|
||||
await downloadSourcesSublevel.del(`${id}`);
|
||||
|
||||
// Invalidate caches after deletion
|
||||
invalidateDownloadSourcesCache();
|
||||
invalidateIdCaches();
|
||||
};
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import { z } from "zod";
|
||||
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import crypto from "crypto";
|
||||
import { logger, ResourceCache } from "@main/services";
|
||||
|
||||
const downloadSourceSchema = z.object({
|
||||
export const downloadSourceSchema = z.object({
|
||||
name: z.string().max(255),
|
||||
downloads: z.array(
|
||||
z.object({
|
||||
@@ -16,51 +17,49 @@ const downloadSourceSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
// Pre-computed title-to-Steam-ID mapping
|
||||
type TitleHashMapping = Record<string, number[]>;
|
||||
let titleHashMappingCache: TitleHashMapping | null = null;
|
||||
let titleHashMappingCacheTime = 0;
|
||||
const TITLE_HASH_MAPPING_TTL = 86400000; // 24 hours
|
||||
export type TitleHashMapping = Record<string, number[]>;
|
||||
|
||||
const getTitleHashMapping = async (): Promise<TitleHashMapping> => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
titleHashMappingCache &&
|
||||
now - titleHashMappingCacheTime < TITLE_HASH_MAPPING_TTL
|
||||
) {
|
||||
let titleHashMappingCache: TitleHashMapping | null = null;
|
||||
|
||||
export const getTitleHashMapping = async (): Promise<TitleHashMapping> => {
|
||||
if (titleHashMappingCache) {
|
||||
return titleHashMappingCache;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get<TitleHashMapping>(
|
||||
"https://cdn.losbroxas.org/results_a4c50f70c2.json",
|
||||
{
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
const cached =
|
||||
ResourceCache.getCachedData<TitleHashMapping>("sources-manifest");
|
||||
if (cached) {
|
||||
titleHashMappingCache = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
titleHashMappingCache = response.data;
|
||||
titleHashMappingCacheTime = now;
|
||||
console.log(
|
||||
`✅ Loaded title hash mapping with ${Object.keys(response.data).length} entries`
|
||||
const fetched = await ResourceCache.fetchAndCache<TitleHashMapping>(
|
||||
"sources-manifest",
|
||||
"https://cdn.losbroxas.org/sources-manifest.json",
|
||||
10000
|
||||
);
|
||||
return response.data;
|
||||
titleHashMappingCache = fetched;
|
||||
return fetched;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch title hash mapping:", error);
|
||||
// Return empty mapping on error - will fall back to fuzzy matching
|
||||
return {};
|
||||
logger.error("Failed to fetch title hash mapping:", error);
|
||||
return {} as TitleHashMapping;
|
||||
}
|
||||
};
|
||||
|
||||
const hashTitle = (title: string): string => {
|
||||
export const hashTitle = (title: string): string => {
|
||||
return crypto.createHash("sha256").update(title).digest("hex");
|
||||
};
|
||||
|
||||
type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
|
||||
type FormattedSteamGame = { id: string; name: string; formattedName: string };
|
||||
type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
|
||||
export type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
|
||||
export type FormattedSteamGame = {
|
||||
id: string;
|
||||
name: string;
|
||||
formattedName: string;
|
||||
};
|
||||
export type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
|
||||
|
||||
const formatName = (name: string) => {
|
||||
export const formatName = (name: string) => {
|
||||
return name
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
@@ -68,7 +67,7 @@ const formatName = (name: string) => {
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
};
|
||||
|
||||
const formatRepackName = (name: string) => {
|
||||
export const formatRepackName = (name: string) => {
|
||||
return formatName(name.replace("[DL]", ""));
|
||||
};
|
||||
|
||||
@@ -85,32 +84,17 @@ interface DownloadSource {
|
||||
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> => {
|
||||
const sources = await getDownloadSourcesMap();
|
||||
for (const source of sources.values()) {
|
||||
@@ -121,43 +105,49 @@ export const checkUrlExists = async (url: string): Promise<boolean> => {
|
||||
return false;
|
||||
};
|
||||
|
||||
let steamGamesCache: FormattedSteamGamesByLetter | null = null;
|
||||
let steamGamesCacheTime = 0;
|
||||
const STEAM_GAMES_CACHE_TTL = 300000;
|
||||
let steamGamesFormattedCache: FormattedSteamGamesByLetter | null = null;
|
||||
|
||||
const getSteamGames = async (): Promise<FormattedSteamGamesByLetter> => {
|
||||
const now = Date.now();
|
||||
if (steamGamesCache && now - steamGamesCacheTime < STEAM_GAMES_CACHE_TTL) {
|
||||
return steamGamesCache;
|
||||
export const getSteamGames = async (): Promise<FormattedSteamGamesByLetter> => {
|
||||
if (steamGamesFormattedCache) {
|
||||
return steamGamesFormattedCache;
|
||||
}
|
||||
|
||||
const response = await axios.get<SteamGamesByLetter>(
|
||||
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
|
||||
let steamGames: SteamGamesByLetter;
|
||||
|
||||
const cached = ResourceCache.getCachedData<SteamGamesByLetter>(
|
||||
"steam-games-by-letter"
|
||||
);
|
||||
if (cached) {
|
||||
steamGames = cached;
|
||||
} else {
|
||||
steamGames = await ResourceCache.fetchAndCache<SteamGamesByLetter>(
|
||||
"steam-games-by-letter",
|
||||
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
|
||||
);
|
||||
}
|
||||
|
||||
const formattedData: FormattedSteamGamesByLetter = {};
|
||||
for (const [letter, games] of Object.entries(response.data)) {
|
||||
for (const [letter, games] of Object.entries(steamGames)) {
|
||||
formattedData[letter] = games.map((game) => ({
|
||||
...game,
|
||||
formattedName: formatName(game.name),
|
||||
}));
|
||||
}
|
||||
|
||||
steamGamesCache = formattedData;
|
||||
steamGamesCacheTime = now;
|
||||
steamGamesFormattedCache = formattedData;
|
||||
return formattedData;
|
||||
};
|
||||
|
||||
type SublevelIterator = AsyncIterable<[string, { id: number }]>;
|
||||
export type SublevelIterator = AsyncIterable<[string, { id: number }]>;
|
||||
|
||||
interface SublevelWithId {
|
||||
export interface SublevelWithId {
|
||||
iterator: () => SublevelIterator;
|
||||
}
|
||||
|
||||
let maxRepackId: number | null = null;
|
||||
let maxDownloadSourceId: number | null = null;
|
||||
|
||||
const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
|
||||
export const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
|
||||
const isRepackSublevel = sublevel === repacksSublevel;
|
||||
const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel;
|
||||
|
||||
@@ -190,7 +180,7 @@ export const invalidateIdCaches = () => {
|
||||
maxDownloadSourceId = null;
|
||||
};
|
||||
|
||||
const addNewDownloads = async (
|
||||
export const addNewDownloads = async (
|
||||
downloadSource: { id: number; name: string },
|
||||
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
|
||||
steamGames: FormattedSteamGamesByLetter
|
||||
@@ -202,7 +192,6 @@ const addNewDownloads = async (
|
||||
|
||||
const batch = repacksSublevel.batch();
|
||||
|
||||
// Fetch the pre-computed hash mapping
|
||||
const titleHashMapping = await getTitleHashMapping();
|
||||
let hashMatchCount = 0;
|
||||
let fuzzyMatchCount = 0;
|
||||
@@ -212,20 +201,16 @@ const addNewDownloads = async (
|
||||
let objectIds: string[] = [];
|
||||
let usedHashMatch = false;
|
||||
|
||||
// FIRST: Try hash-based lookup (fast and accurate)
|
||||
const titleHash = hashTitle(download.title);
|
||||
const steamIdsFromHash = titleHashMapping[titleHash];
|
||||
|
||||
if (steamIdsFromHash && steamIdsFromHash.length > 0) {
|
||||
// Found in hash mapping - trust it completely
|
||||
hashMatchCount++;
|
||||
usedHashMatch = true;
|
||||
|
||||
// Use the Steam IDs directly as strings (trust the hash mapping)
|
||||
objectIds = steamIdsFromHash.map(String);
|
||||
}
|
||||
|
||||
// FALLBACK: Use fuzzy matching ONLY if hash lookup found nothing
|
||||
if (!usedHashMatch) {
|
||||
let gamesInSteam: FormattedSteamGame[] = [];
|
||||
const formattedTitle = formatRepackName(download.title);
|
||||
@@ -234,12 +219,10 @@ const addNewDownloads = async (
|
||||
const [firstLetter] = formattedTitle;
|
||||
const games = steamGames[firstLetter] || [];
|
||||
|
||||
// Try exact prefix match first
|
||||
gamesInSteam = games.filter((game) =>
|
||||
formattedTitle.startsWith(game.formattedName)
|
||||
);
|
||||
|
||||
// If no exact prefix match, try contains match (more lenient)
|
||||
if (gamesInSteam.length === 0) {
|
||||
gamesInSteam = games.filter(
|
||||
(game) =>
|
||||
@@ -248,7 +231,6 @@ const addNewDownloads = async (
|
||||
);
|
||||
}
|
||||
|
||||
// If still no match, try checking all letters (not just first letter)
|
||||
if (gamesInSteam.length === 0) {
|
||||
for (const letter of Object.keys(steamGames)) {
|
||||
const letterGames = steamGames[letter] || [];
|
||||
@@ -275,13 +257,10 @@ const addNewDownloads = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Add matched game IDs to source tracking
|
||||
for (const id of objectIds) {
|
||||
objectIdsOnSource.add(id);
|
||||
}
|
||||
|
||||
// Create the repack even if no games matched
|
||||
// This ensures all repacks from sources are imported
|
||||
const repack = {
|
||||
id: nextRepackId++,
|
||||
objectIds: objectIds,
|
||||
@@ -300,9 +279,8 @@ const addNewDownloads = async (
|
||||
|
||||
await batch.write();
|
||||
|
||||
// Log matching statistics
|
||||
console.log(
|
||||
`📊 Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}`
|
||||
logger.info(
|
||||
`Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}`
|
||||
);
|
||||
|
||||
const existingSource = await downloadSourcesSublevel.get(
|
||||
@@ -352,8 +330,6 @@ export const importDownloadSourceToLocal = async (
|
||||
|
||||
await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource);
|
||||
|
||||
invalidateDownloadSourcesCache();
|
||||
|
||||
const objectIds = await addNewDownloads(
|
||||
downloadSource,
|
||||
response.data.downloads,
|
||||
@@ -387,7 +363,5 @@ export const updateDownloadSourcePreservingTimestamp = async (
|
||||
|
||||
await downloadSourcesSublevel.put(`${existingSource.id}`, updatedSource);
|
||||
|
||||
invalidateDownloadSourcesCache();
|
||||
|
||||
return updatedSource;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { HydraApi, logger } from "@main/services";
|
||||
import { importDownloadSourceToLocal, checkUrlExists } from "./helpers";
|
||||
|
||||
export const syncDownloadSourcesFromApi = async () => {
|
||||
@@ -14,6 +14,6 @@ export const syncDownloadSourcesFromApi = async () => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to sync download sources from API:", error);
|
||||
logger.error("Failed to sync download sources from API:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,267 +1,13 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { z } from "zod";
|
||||
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import { invalidateDownloadSourcesCache, invalidateIdCaches } from "./helpers";
|
||||
import crypto from "crypto";
|
||||
|
||||
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),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
// Pre-computed title-to-Steam-ID mapping (shared with helpers.ts)
|
||||
type TitleHashMapping = Record<string, number[]>;
|
||||
let titleHashMappingCache: TitleHashMapping | null = null;
|
||||
let titleHashMappingCacheTime = 0;
|
||||
const TITLE_HASH_MAPPING_TTL = 86400000; // 24 hours
|
||||
|
||||
const getTitleHashMapping = async (): Promise<TitleHashMapping> => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
titleHashMappingCache &&
|
||||
now - titleHashMappingCacheTime < TITLE_HASH_MAPPING_TTL
|
||||
) {
|
||||
return titleHashMappingCache;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get<TitleHashMapping>(
|
||||
"https://cdn.losbroxas.org/results_a4c50f70c2.json",
|
||||
{
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
titleHashMappingCache = response.data;
|
||||
titleHashMappingCacheTime = now;
|
||||
console.log(
|
||||
`✅ Loaded title hash mapping with ${Object.keys(response.data).length} entries`
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch title hash mapping:", error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const hashTitle = (title: string): string => {
|
||||
return crypto.createHash("sha256").update(title).digest("hex");
|
||||
};
|
||||
|
||||
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) => {
|
||||
return name
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
};
|
||||
|
||||
const formatRepackName = (name: string) => {
|
||||
return formatName(name.replace("[DL]", ""));
|
||||
};
|
||||
|
||||
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>(
|
||||
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
|
||||
);
|
||||
|
||||
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 }]>;
|
||||
|
||||
interface SublevelWithId {
|
||||
iterator: () => SublevelIterator;
|
||||
}
|
||||
|
||||
let maxRepackId: number | null = null;
|
||||
let maxDownloadSourceId: number | null = null;
|
||||
|
||||
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;
|
||||
for await (const [, value] of sublevel.iterator()) {
|
||||
if (value.id > maxId) {
|
||||
maxId = value.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRepackSublevel) {
|
||||
maxRepackId = maxId;
|
||||
} else if (isDownloadSourceSublevel) {
|
||||
maxDownloadSourceId = maxId;
|
||||
}
|
||||
|
||||
return maxId + 1;
|
||||
};
|
||||
|
||||
const addNewDownloads = async (
|
||||
downloadSource: { id: number; name: string },
|
||||
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
|
||||
steamGames: FormattedSteamGamesByLetter
|
||||
) => {
|
||||
const now = new Date();
|
||||
const objectIdsOnSource = new Set<string>();
|
||||
|
||||
let nextRepackId = await getNextId(repacksSublevel);
|
||||
|
||||
const batch = repacksSublevel.batch();
|
||||
|
||||
// Fetch the pre-computed hash mapping
|
||||
const titleHashMapping = await getTitleHashMapping();
|
||||
let hashMatchCount = 0;
|
||||
let fuzzyMatchCount = 0;
|
||||
let noMatchCount = 0;
|
||||
|
||||
for (const download of downloads) {
|
||||
let objectIds: string[] = [];
|
||||
let usedHashMatch = false;
|
||||
|
||||
// FIRST: Try hash-based lookup (fast and accurate)
|
||||
const titleHash = hashTitle(download.title);
|
||||
const steamIdsFromHash = titleHashMapping[titleHash];
|
||||
|
||||
if (steamIdsFromHash && steamIdsFromHash.length > 0) {
|
||||
// Found in hash mapping - trust it completely
|
||||
hashMatchCount++;
|
||||
usedHashMatch = true;
|
||||
|
||||
// Use the Steam IDs directly as strings (trust the hash mapping)
|
||||
objectIds = steamIdsFromHash.map(String);
|
||||
}
|
||||
|
||||
// FALLBACK: Use fuzzy matching ONLY if hash lookup found nothing
|
||||
if (!usedHashMatch) {
|
||||
let gamesInSteam: FormattedSteamGame[] = [];
|
||||
const formattedTitle = formatRepackName(download.title);
|
||||
|
||||
if (formattedTitle && formattedTitle.length > 0) {
|
||||
const [firstLetter] = formattedTitle;
|
||||
const games = steamGames[firstLetter] || [];
|
||||
|
||||
// Try exact prefix match first
|
||||
gamesInSteam = games.filter((game) =>
|
||||
formattedTitle.startsWith(game.formattedName)
|
||||
);
|
||||
|
||||
// If no exact prefix match, try contains match (more lenient)
|
||||
if (gamesInSteam.length === 0) {
|
||||
gamesInSteam = games.filter(
|
||||
(game) =>
|
||||
formattedTitle.includes(game.formattedName) ||
|
||||
game.formattedName.includes(formattedTitle)
|
||||
);
|
||||
}
|
||||
|
||||
// If still no match, try checking all letters (not just first letter)
|
||||
if (gamesInSteam.length === 0) {
|
||||
for (const letter of Object.keys(steamGames)) {
|
||||
const letterGames = steamGames[letter] || [];
|
||||
const matches = letterGames.filter(
|
||||
(game) =>
|
||||
formattedTitle.includes(game.formattedName) ||
|
||||
game.formattedName.includes(formattedTitle)
|
||||
);
|
||||
if (matches.length > 0) {
|
||||
gamesInSteam = matches;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (gamesInSteam.length > 0) {
|
||||
fuzzyMatchCount++;
|
||||
objectIds = gamesInSteam.map((game) => String(game.id));
|
||||
} else {
|
||||
noMatchCount++;
|
||||
}
|
||||
} else {
|
||||
noMatchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add matched game IDs to source tracking
|
||||
for (const id of objectIds) {
|
||||
objectIdsOnSource.add(id);
|
||||
}
|
||||
|
||||
// Create the repack even if no games matched
|
||||
// This ensures all repacks from sources are imported
|
||||
const repack = {
|
||||
id: nextRepackId++,
|
||||
objectIds: objectIds,
|
||||
title: download.title,
|
||||
uris: download.uris,
|
||||
fileSize: download.fileSize,
|
||||
repacker: downloadSource.name,
|
||||
uploadDate: download.uploadDate,
|
||||
downloadSourceId: downloadSource.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
batch.put(`${repack.id}`, repack);
|
||||
}
|
||||
|
||||
await batch.write();
|
||||
|
||||
// Log matching statistics
|
||||
console.log(
|
||||
`📊 Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}`
|
||||
);
|
||||
|
||||
const existingSource = await downloadSourcesSublevel.get(
|
||||
`${downloadSource.id}`
|
||||
);
|
||||
if (existingSource) {
|
||||
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
|
||||
...existingSource,
|
||||
objectIds: Array.from(objectIdsOnSource),
|
||||
});
|
||||
}
|
||||
};
|
||||
import {
|
||||
invalidateIdCaches,
|
||||
downloadSourceSchema,
|
||||
getSteamGames,
|
||||
addNewDownloads,
|
||||
} from "./helpers";
|
||||
|
||||
const syncDownloadSources = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
@@ -358,8 +104,6 @@ const syncDownloadSources = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate caches after all sync operations complete
|
||||
invalidateDownloadSourcesCache();
|
||||
invalidateIdCaches();
|
||||
|
||||
return newRepacksCount;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { downloadSourcesSublevel } from "@main/level";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { HydraApi, logger } from "@main/services";
|
||||
|
||||
const updateMissingFingerprints = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
@@ -27,7 +27,7 @@ const updateMissingFingerprints = async (
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
`Updating fingerprints for ${sourcesNeedingFingerprints.length} sources`
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ const updateMissingFingerprints = async (
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
logger.error(
|
||||
`Failed to update fingerprint for source ${source.id}:`,
|
||||
error
|
||||
);
|
||||
|
||||
@@ -61,7 +61,6 @@ import "./user-preferences/auto-launch";
|
||||
import "./autoupdater/check-for-updates";
|
||||
import "./autoupdater/restart-and-install-update";
|
||||
import "./user-preferences/authenticate-real-debrid";
|
||||
import "./user-preferences/authenticate-all-debrid";
|
||||
import "./user-preferences/authenticate-torbox";
|
||||
import "./download-sources/add-download-source";
|
||||
import "./download-sources/update-missing-fingerprints";
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { AllDebridClient } from "@main/services/download/all-debrid";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const authenticateAllDebrid = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
apiKey: string
|
||||
) => {
|
||||
AllDebridClient.authorize(apiKey);
|
||||
const result = await AllDebridClient.getUser();
|
||||
if ("error_code" in result) {
|
||||
return { error_code: result.error_code };
|
||||
}
|
||||
|
||||
return result.user;
|
||||
};
|
||||
|
||||
registerEvent("authenticateAllDebrid", authenticateAllDebrid);
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
CommonRedistManager,
|
||||
TorBoxClient,
|
||||
RealDebridClient,
|
||||
AllDebridClient,
|
||||
Aria2,
|
||||
DownloadManager,
|
||||
HydraApi,
|
||||
@@ -17,11 +16,15 @@ import {
|
||||
Ludusavi,
|
||||
Lock,
|
||||
DeckyPlugin,
|
||||
ResourceCache,
|
||||
} from "@main/services";
|
||||
|
||||
export const loadState = async () => {
|
||||
await Lock.acquireLock();
|
||||
|
||||
ResourceCache.initialize();
|
||||
await ResourceCache.updateResourcesOnStartup();
|
||||
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
@@ -39,10 +42,6 @@ export const loadState = async () => {
|
||||
RealDebridClient.authorize(userPreferences.realDebridApiToken);
|
||||
}
|
||||
|
||||
if (userPreferences?.allDebridApiKey) {
|
||||
AllDebridClient.authorize(userPreferences.allDebridApiKey);
|
||||
}
|
||||
|
||||
if (userPreferences?.torBoxApiToken) {
|
||||
TorBoxClient.authorize(userPreferences.torBoxApiToken);
|
||||
}
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import type { AllDebridUser } from "@types";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
interface AllDebridMagnetStatus {
|
||||
id: number;
|
||||
filename: string;
|
||||
size: number;
|
||||
status: string;
|
||||
statusCode: number;
|
||||
downloaded: number;
|
||||
uploaded: number;
|
||||
seeders: number;
|
||||
downloadSpeed: number;
|
||||
uploadSpeed: number;
|
||||
uploadDate: number;
|
||||
completionDate: number;
|
||||
links: Array<{
|
||||
link: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface AllDebridError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AllDebridDownloadUrl {
|
||||
link: string;
|
||||
size?: number;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export class AllDebridClient {
|
||||
private static instance: AxiosInstance;
|
||||
private static readonly baseURL = "https://api.alldebrid.com/v4";
|
||||
|
||||
static authorize(apiKey: string) {
|
||||
logger.info("[AllDebrid] Authorizing with key:", apiKey ? "***" : "empty");
|
||||
this.instance = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
params: {
|
||||
agent: "hydra",
|
||||
apikey: apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async getUser() {
|
||||
try {
|
||||
const response = await this.instance.get<{
|
||||
status: string;
|
||||
data?: { user: AllDebridUser };
|
||||
error?: AllDebridError;
|
||||
}>("/user");
|
||||
|
||||
logger.info("[AllDebrid] API Response:", response.data);
|
||||
|
||||
if (response.data.status === "error") {
|
||||
const error = response.data.error;
|
||||
logger.error("[AllDebrid] API Error:", error);
|
||||
if (error?.code === "AUTH_MISSING_APIKEY") {
|
||||
return { error_code: "alldebrid_missing_key" };
|
||||
}
|
||||
if (error?.code === "AUTH_BAD_APIKEY") {
|
||||
return { error_code: "alldebrid_invalid_key" };
|
||||
}
|
||||
if (error?.code === "AUTH_BLOCKED") {
|
||||
return { error_code: "alldebrid_blocked" };
|
||||
}
|
||||
if (error?.code === "AUTH_USER_BANNED") {
|
||||
return { error_code: "alldebrid_banned" };
|
||||
}
|
||||
return { error_code: "alldebrid_unknown_error" };
|
||||
}
|
||||
|
||||
if (!response.data.data?.user) {
|
||||
logger.error("[AllDebrid] No user data in response");
|
||||
return { error_code: "alldebrid_invalid_response" };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Successfully got user:",
|
||||
response.data.data.user.username
|
||||
);
|
||||
return { user: response.data.data.user };
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Request Error:", error);
|
||||
if (error.response?.data?.error) {
|
||||
return { error_code: "alldebrid_invalid_key" };
|
||||
}
|
||||
return { error_code: "alldebrid_network_error" };
|
||||
}
|
||||
}
|
||||
|
||||
private static async uploadMagnet(magnet: string) {
|
||||
try {
|
||||
logger.info("[AllDebrid] Uploading magnet with params:", { magnet });
|
||||
|
||||
const response = await this.instance.get("/magnet/upload", {
|
||||
params: {
|
||||
magnets: [magnet],
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Upload Magnet Raw Response:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
const magnetInfo = response.data.data.magnets[0];
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet Info:",
|
||||
JSON.stringify(magnetInfo, null, 2)
|
||||
);
|
||||
|
||||
if (magnetInfo.error) {
|
||||
throw new Error(magnetInfo.error.message);
|
||||
}
|
||||
|
||||
return magnetInfo.id;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Upload Magnet Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async checkMagnetStatus(
|
||||
magnetId: number
|
||||
): Promise<AllDebridMagnetStatus> {
|
||||
try {
|
||||
logger.info("[AllDebrid] Checking magnet status for ID:", magnetId);
|
||||
|
||||
const response = await this.instance.get(`/magnet/status`, {
|
||||
params: {
|
||||
id: magnetId,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Check Magnet Status Raw Response:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("No response data received");
|
||||
}
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
// Verificăm noua structură a răspunsului
|
||||
const magnetData = response.data.data?.magnets;
|
||||
if (!magnetData || typeof magnetData !== "object") {
|
||||
logger.error(
|
||||
"[AllDebrid] Invalid response structure:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
throw new Error("Invalid magnet status response format");
|
||||
}
|
||||
|
||||
// Convertim răspunsul în formatul așteptat
|
||||
const magnetStatus: AllDebridMagnetStatus = {
|
||||
id: magnetData.id,
|
||||
filename: magnetData.filename,
|
||||
size: magnetData.size,
|
||||
status: magnetData.status,
|
||||
statusCode: magnetData.statusCode,
|
||||
downloaded: magnetData.downloaded,
|
||||
uploaded: magnetData.uploaded,
|
||||
seeders: magnetData.seeders,
|
||||
downloadSpeed: magnetData.downloadSpeed,
|
||||
uploadSpeed: magnetData.uploadSpeed,
|
||||
uploadDate: magnetData.uploadDate,
|
||||
completionDate: magnetData.completionDate,
|
||||
links: magnetData.links.map((link) => ({
|
||||
link: link.link,
|
||||
filename: link.filename,
|
||||
size: link.size,
|
||||
})),
|
||||
};
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet Status:",
|
||||
JSON.stringify(magnetStatus, null, 2)
|
||||
);
|
||||
|
||||
return magnetStatus;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Check Magnet Status Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async unlockLink(link: string) {
|
||||
try {
|
||||
const response = await this.instance.get<{
|
||||
status: string;
|
||||
data?: { link: string };
|
||||
error?: AllDebridError;
|
||||
}>("/link/unlock", {
|
||||
params: {
|
||||
link,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
const unlockedLink = response.data.data?.link;
|
||||
if (!unlockedLink) {
|
||||
throw new Error("No download link received from AllDebrid");
|
||||
}
|
||||
|
||||
return unlockedLink;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Unlock Link Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getDownloadUrls(
|
||||
uri: string
|
||||
): Promise<AllDebridDownloadUrl[]> {
|
||||
try {
|
||||
logger.info("[AllDebrid] Getting download URLs for URI:", uri);
|
||||
|
||||
if (uri.startsWith("magnet:")) {
|
||||
logger.info("[AllDebrid] Detected magnet link, uploading...");
|
||||
// 1. Upload magnet
|
||||
const magnetId = await this.uploadMagnet(uri);
|
||||
logger.info("[AllDebrid] Magnet uploaded, ID:", magnetId);
|
||||
|
||||
// 2. Verificăm statusul până când avem link-uri
|
||||
let retries = 0;
|
||||
let magnetStatus: AllDebridMagnetStatus;
|
||||
|
||||
do {
|
||||
magnetStatus = await this.checkMagnetStatus(magnetId);
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet status:",
|
||||
magnetStatus.status,
|
||||
"statusCode:",
|
||||
magnetStatus.statusCode
|
||||
);
|
||||
|
||||
if (magnetStatus.statusCode === 4) {
|
||||
// Ready
|
||||
// Deblocăm fiecare link în parte și aruncăm eroare dacă oricare eșuează
|
||||
const unlockedLinks = await Promise.all(
|
||||
magnetStatus.links.map(async (link) => {
|
||||
try {
|
||||
const unlockedLink = await this.unlockLink(link.link);
|
||||
logger.info(
|
||||
"[AllDebrid] Successfully unlocked link:",
|
||||
unlockedLink
|
||||
);
|
||||
|
||||
return {
|
||||
link: unlockedLink,
|
||||
size: link.size,
|
||||
filename: link.filename,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[AllDebrid] Failed to unlock link:",
|
||||
link.link,
|
||||
error
|
||||
);
|
||||
throw new Error("Failed to unlock all links");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Got unlocked download links:",
|
||||
unlockedLinks
|
||||
);
|
||||
console.log("[AllDebrid] FINAL LINKS →", unlockedLinks);
|
||||
return unlockedLinks;
|
||||
}
|
||||
|
||||
if (retries++ > 30) {
|
||||
// Maximum 30 de încercări
|
||||
throw new Error("Timeout waiting for magnet to be ready");
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // Așteptăm 2 secunde între verificări
|
||||
} while (magnetStatus.statusCode !== 4);
|
||||
} else {
|
||||
logger.info("[AllDebrid] Regular link, unlocking...");
|
||||
// Pentru link-uri normale, doar debridam link-ul
|
||||
const downloadUrl = await this.unlockLink(uri);
|
||||
logger.info("[AllDebrid] Got unlocked download URL:", downloadUrl);
|
||||
return [
|
||||
{
|
||||
link: downloadUrl,
|
||||
},
|
||||
];
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Get Download URLs Error:", error);
|
||||
throw error;
|
||||
}
|
||||
return []; // Add default return for TypeScript
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "./types";
|
||||
import { calculateETA, getDirSize } from "./helpers";
|
||||
import { RealDebridClient } from "./real-debrid";
|
||||
import { AllDebridClient } from "./all-debrid";
|
||||
import path from "path";
|
||||
import { logger } from "../logger";
|
||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
@@ -379,27 +378,6 @@ export class DownloadManager {
|
||||
allow_multiple_connections: true,
|
||||
};
|
||||
}
|
||||
case Downloader.AllDebrid: {
|
||||
const downloadUrls = await AllDebridClient.getDownloadUrls(
|
||||
download.uri
|
||||
);
|
||||
|
||||
if (!downloadUrls.length)
|
||||
throw new Error(DownloadError.NotCachedInAllDebrid);
|
||||
|
||||
const totalSize = downloadUrls.reduce(
|
||||
(total, url) => total + (url.size || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
url: downloadUrls.map((d) => d.link),
|
||||
save_path: download.downloadPath,
|
||||
total_size: totalSize,
|
||||
};
|
||||
}
|
||||
case Downloader.TorBox: {
|
||||
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./download-manager";
|
||||
export * from "./real-debrid";
|
||||
export * from "./all-debrid";
|
||||
export * from "./torbox";
|
||||
|
||||
@@ -18,3 +18,4 @@ export * from "./library-sync";
|
||||
export * from "./wine";
|
||||
export * from "./lock";
|
||||
export * from "./decky-plugin";
|
||||
export * from "./resource-cache";
|
||||
|
||||
157
src/main/services/resource-cache.ts
Normal file
157
src/main/services/resource-cache.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { app } from "electron";
|
||||
import axios from "axios";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "./logger";
|
||||
|
||||
interface CachedResource<T = unknown> {
|
||||
data: T;
|
||||
etag: string | null;
|
||||
}
|
||||
|
||||
export class ResourceCache {
|
||||
private static cacheDir: string;
|
||||
|
||||
static initialize() {
|
||||
this.cacheDir = path.join(app.getPath("userData"), "resource-cache");
|
||||
|
||||
if (!fs.existsSync(this.cacheDir)) {
|
||||
fs.mkdirSync(this.cacheDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private static getCacheFilePath(resourceName: string): string {
|
||||
return path.join(this.cacheDir, `${resourceName}.json`);
|
||||
}
|
||||
|
||||
private static getEtagFilePath(resourceName: string): string {
|
||||
return path.join(this.cacheDir, `${resourceName}.etag`);
|
||||
}
|
||||
|
||||
private static readCachedResource<T = unknown>(
|
||||
resourceName: string
|
||||
): CachedResource<T> | null {
|
||||
const dataPath = this.getCacheFilePath(resourceName);
|
||||
const etagPath = this.getEtagFilePath(resourceName);
|
||||
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(dataPath, "utf-8")) as T;
|
||||
const etag = fs.existsSync(etagPath)
|
||||
? fs.readFileSync(etagPath, "utf-8")
|
||||
: null;
|
||||
|
||||
return { data, etag };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read cached resource ${resourceName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static writeCachedResource<T = unknown>(
|
||||
resourceName: string,
|
||||
data: T,
|
||||
etag: string | null
|
||||
): void {
|
||||
const dataPath = this.getCacheFilePath(resourceName);
|
||||
const etagPath = this.getEtagFilePath(resourceName);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(dataPath, JSON.stringify(data), "utf-8");
|
||||
|
||||
if (etag) {
|
||||
fs.writeFileSync(etagPath, etag, "utf-8");
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Cached resource ${resourceName} with etag: ${etag || "none"}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to write cached resource ${resourceName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async fetchAndCache<T = unknown>(
|
||||
resourceName: string,
|
||||
url: string,
|
||||
timeout: number = 10000
|
||||
): Promise<T> {
|
||||
const cached = this.readCachedResource<T>(resourceName);
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (cached?.etag) {
|
||||
headers["If-None-Match"] = cached.etag;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get<T>(url, {
|
||||
headers,
|
||||
timeout,
|
||||
});
|
||||
|
||||
const newEtag = response.headers["etag"] || null;
|
||||
this.writeCachedResource(resourceName, response.data, newEtag);
|
||||
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as {
|
||||
response?: { status?: number };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (axiosError.response?.status === 304 && cached) {
|
||||
logger.info(`Resource ${resourceName} not modified, using cache`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
if (cached) {
|
||||
logger.warn(
|
||||
`Failed to fetch ${resourceName}, using cached version:`,
|
||||
axiosError.message || "Unknown error"
|
||||
);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`Failed to fetch ${resourceName} and no cache available:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static getCachedData<T = unknown>(resourceName: string): T | null {
|
||||
const cached = this.readCachedResource<T>(resourceName);
|
||||
return cached?.data || null;
|
||||
}
|
||||
|
||||
static async updateResourcesOnStartup(): Promise<void> {
|
||||
logger.info("Starting background resource cache update...");
|
||||
|
||||
const resources = [
|
||||
{
|
||||
name: "steam-games-by-letter",
|
||||
url: `${process.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`,
|
||||
},
|
||||
{
|
||||
name: "sources-manifest",
|
||||
url: "https://cdn.losbroxas.org/sources-manifest.json",
|
||||
},
|
||||
];
|
||||
|
||||
await Promise.allSettled(
|
||||
resources.map(async (resource) => {
|
||||
try {
|
||||
await this.fetchAndCache(resource.name, resource.url);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update ${resource.name} on startup:`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
logger.info("Resource cache update complete");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user