mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-23 19:01:02 +00:00
feat: using api download sources
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,11 +105,6 @@ export class HydraApi {
|
||||
|
||||
// WSClient.close();
|
||||
// WSClient.connect();
|
||||
|
||||
const { syncDownloadSourcesFromApi } = await import(
|
||||
"../events/download-sources/sync-download-sources-from-api"
|
||||
);
|
||||
syncDownloadSourcesFromApi();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user