feat: Implemented emulator installation

feat: Updated romm API version
feat: Updated es-de rules
feat: Added tabs to game details
refactor: returned to global query definitions to help with typescript performance
This commit is contained in:
Simeon Radivoev 2026-03-22 01:11:21 +02:00
parent cf6fff6fac
commit 3750e9ed8f
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
103 changed files with 4888 additions and 1632 deletions

222
src/bun/utils/downloader.ts Normal file
View file

@ -0,0 +1,222 @@
import { ensureDir, move } from "fs-extra";
import path from 'node:path';
import fs from 'node:fs/promises';
import { createWriteStream } from "node:fs";
import { config, jar } from "../api/app";
import { file } from "bun";
export interface FileEntry
{
url: URL;
file_path: string;
file_name: string;
size?: number;
}
export interface ProgressStats
{
progress: number;
}
interface TmpDownloadMetadata
{
files: FileEntry[];
}
export class Downloader
{
files: FileEntry[];
headers?: Record<string, string>;
onProgress?: (stats: ProgressStats) => void;
signal?: AbortSignal;
activeFile?: FileEntry;
downloadPath: string;
id: string;
tmpPath: string;
tmpPathMeta: string;
constructor(
id: string,
files: FileEntry[],
downloadPath: string, init?: {
headers?: Record<string, string>,
onProgress?: (stats: ProgressStats) => void;
signal?: AbortSignal;
})
{
this.files = files;
this.headers = init?.headers;
this.onProgress = init?.onProgress;
this.signal = init?.signal;
this.downloadPath = downloadPath;
this.id = id;
this.tmpPath = path.join(config.get('downloadPath'), 'downloads', this.id);
this.tmpPathMeta = path.join(config.get('downloadPath'), 'downloads', `${this.id}.json`);
}
async updateTmpDownload ()
{
const meta: TmpDownloadMetadata = {
files: this.files
};
await ensureDir(path.join(config.get('downloadPath'), 'downloads'));
await fs.writeFile(this.tmpPathMeta, JSON.stringify(meta));
}
async start ()
{
const totalSize = this.files.reduce((accum, current) => accum += current.size ?? 0, 0);
let bytesReceived = 0;
if (this.files.some(f => path.isAbsolute(f.file_path)))
{
throw new Error("Only Relative Paths Supported");
}
await this.updateTmpDownload();
for (let i = 0; i < this.files.length; i++)
{
const file = this.files[i];
this.activeFile = file;
const cookie = await jar.getCookieString(file.url.href);
await ensureDir(path.join(this.tmpPath, file.file_path));
const filePath = path.join(this.tmpPath, file.file_path, file.file_name);
let start = 0;
// 1. Check existing file
if (await fs.exists(filePath))
{
start = ((await fs.stat(filePath)).size);
}
// 2. Request remaining bytes
let res = await fetch(file.url, {
headers: {
...this.headers,
...(start > 0
? { Range: `bytes=${start}-` }
: undefined),
cookie
}
});
const resSize = Number(res.headers.get("content-length") ?? 0);
if (start > 0)
{
if (res.status === 206)
{
console.log("Resume supported, continuing download");
} else if (res.status === 200)
{
console.log("Server ignored Range, restarting download from beginning");
start = 0;
// Must make a new request from the beginning
res = await fetch(file.url, { headers: { ...this.headers, cookie } });
if (!res.ok)
{
throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
}
} else if (res.status === 416)
{
const localSize = (await fs.stat(filePath)).size;
if (resSize && localSize === resSize)
{
console.log("File already fully downloaded, skipping");
break;
} else
{
console.log("Partial file corrupt or changed, redownloading");
start = 0;
res = await fetch(file.url, { headers: { ...this.headers, cookie } }); // full download
if (!res.ok)
{
throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
}
}
}
else
{
throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
}
} else
{
if (!res.ok) throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
}
// 3. Append or overwrite
const stream = createWriteStream(filePath, {
flags: start > 0 ? "a" : "w",
highWaterMark: 64 * 1024
});
const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0;
if (totalSize <= 0)
bytesReceived = 0;
else
bytesReceived += start;
const reader = res.body!.getReader();
let lastUpdate = 0;
while (true)
{
const { done, value } = await reader.read();
if (done) break;
bytesReceived += value.length;
if (totalBytes > 0 && this.onProgress)
{
const percent = (bytesReceived / totalBytes) * 100;
if (Date.now() - lastUpdate > 100)
{
this.onProgress({ progress: percent });
lastUpdate = Date.now();
}
}
if (this.signal?.aborted)
{
if (this.signal.reason === 'cancel')
{
console.log("Canceling Download and cleaning up files");
await fs.rm(this.tmpPath, { recursive: true });
await fs.rm(this.tmpPathMeta);
return;
}
console.log("Aborting Download: ", this.signal.reason);
break;
}
if (!stream.write(value))
{
await new Promise((resolve) => stream.once("drain", () => resolve(true)));
}
}
await new Promise((resolve, reject) =>
{
stream.end(() => resolve(undefined));
stream.on("error", reject);
});
}
await move(this.tmpPath, this.downloadPath, { overwrite: true });
if (await fs.exists(this.tmpPath))
await fs.rm(this.tmpPath, { recursive: true });
await fs.rm(this.tmpPathMeta);
return this.files.map(f => path.join(this.downloadPath, f.file_path, f.file_name));
}
}