feat: using api download sources

This commit is contained in:
Chubby Granny Chaser
2025-10-21 04:18:11 +01:00
parent c2273dbf71
commit 48ce9a2476
45 changed files with 295 additions and 1625 deletions

View File

@@ -1,145 +0,0 @@
import { Worker } from "worker_threads";
import workerPath from "../workers/game-matcher-worker?modulePath";
interface WorkerMessage {
id: string;
data: unknown;
}
interface WorkerResponse {
id: string;
success: boolean;
result?: unknown;
error?: string;
}
export type TitleHashMapping = Record<string, number[]>;
export type FormattedSteamGame = {
id: string;
name: string;
formattedName: string;
};
export type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
interface DownloadToMatch {
title: string;
uris: string[];
uploadDate: string;
fileSize: string;
}
interface MatchedDownload {
title: string;
uris: string[];
uploadDate: string;
fileSize: string;
objectIds: string[];
usedHashMatch: boolean;
}
interface MatchResponse {
matchedDownloads: MatchedDownload[];
stats: {
hashMatchCount: number;
fuzzyMatchCount: number;
noMatchCount: number;
};
}
export class GameMatcherWorkerManager {
private static worker: Worker | null = null;
private static messageId = 0;
private static pendingMessages = new Map<
string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ resolve: (value: any) => void; reject: (error: Error) => void }
>();
public static initialize() {
if (this.worker) {
return;
}
try {
console.log(
"[GameMatcherWorker] Initializing worker with path:",
workerPath
);
this.worker = new Worker(workerPath);
this.worker.on("message", (response: WorkerResponse) => {
const pending = this.pendingMessages.get(response.id);
if (pending) {
if (response.success) {
pending.resolve(response.result);
} else {
pending.reject(new Error(response.error || "Unknown error"));
}
this.pendingMessages.delete(response.id);
}
});
this.worker.on("error", (error) => {
console.error("[GameMatcherWorker] Worker error:", error);
for (const [id, pending] of this.pendingMessages.entries()) {
pending.reject(error);
this.pendingMessages.delete(id);
}
});
this.worker.on("exit", (code) => {
if (code !== 0) {
console.error(
`[GameMatcherWorker] Worker stopped with exit code ${code}`
);
}
this.worker = null;
for (const [id, pending] of this.pendingMessages.entries()) {
pending.reject(new Error("Worker exited unexpectedly"));
this.pendingMessages.delete(id);
}
});
console.log("[GameMatcherWorker] Worker initialized successfully");
} catch (error) {
console.error("[GameMatcherWorker] Failed to initialize worker:", error);
throw error;
}
}
private static sendMessage<T>(data: unknown): Promise<T> {
if (!this.worker) {
return Promise.reject(new Error("Worker not initialized"));
}
const id = `msg_${++this.messageId}`;
const message: WorkerMessage = { id, data };
return new Promise<T>((resolve, reject) => {
this.pendingMessages.set(id, { resolve, reject });
this.worker!.postMessage(message);
});
}
public static async matchDownloads(
downloads: DownloadToMatch[],
steamGames: FormattedSteamGamesByLetter,
titleHashMapping: TitleHashMapping
): Promise<MatchResponse> {
return this.sendMessage<MatchResponse>({
downloads,
steamGames,
titleHashMapping,
});
}
public static terminate() {
if (this.worker) {
this.worker.terminate();
this.worker = null;
this.pendingMessages.clear();
}
}
}

View File

@@ -105,11 +105,6 @@ export class HydraApi {
// WSClient.close();
// WSClient.connect();
const { syncDownloadSourcesFromApi } = await import(
"../events/download-sources/sync-download-sources-from-api"
);
syncDownloadSourcesFromApi();
}
}

View File

@@ -18,5 +18,3 @@ export * from "./library-sync";
export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";
export * from "./resource-cache";
export * from "./game-matcher-worker-manager";

View File

@@ -1,157 +0,0 @@
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: `${import.meta.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");
}
}