feat: Implemented link game importing

feat: Implemented download page for downloading roms from various sources using plugins. Added support for internet archive external plugin.
feat: Added tasks page to track running tasks/downloads
feat: Added tanstack caching
feat: Added quick play action Fixes #6
feat: Added quick emulator launch action
fix: Made task queue only support 1 task per group and task ID should now be unique
This commit is contained in:
Simeon Radivoev 2026-05-15 13:50:55 +03:00
parent 9a3e605625
commit 9141fb35d4
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
70 changed files with 1922 additions and 560 deletions

View file

@ -18,14 +18,24 @@ export class TaskQueue
});
}
public enqueue<T> (id: string, job: T, throwOnError?: boolean): T extends IJob<infer TData, infer TState extends string>
public enqueue<T> (id: string, job: T, options?: { throwOnCancel?: boolean; }): T extends IJob<infer TData, infer TState extends string>
? Promise<TData>
: never
{
this.disposeSafeguard();
if (!this.queue || !this.events) throw new Error("Queue disposed");
const context = new JobContext<any, any, any>(id, this.events, job);
if (this.activeQueue.some(j => j.id === id)) throw new Error(`Job with ID ${id} already active`);
if (this.queue.some(j => j.id === id)) throw new Error(`Job with ${id} already queued`);
const context = new JobContext<any, any, any>(id, this.events, job, options);
this.queue.push(context as any);
context.abortSignal.addEventListener('abort', () =>
{
const queueIndex = this.queue?.findIndex(c => c === context);
if (queueIndex !== undefined && queueIndex >= 0)
{
this.queue?.splice(queueIndex, 1);
}
});
this.events?.emit('queued', { id: context.id, job: context });
this.processQueue();
return context.promise.promise as any;
@ -35,7 +45,24 @@ export class TaskQueue
{
if (!this.queue) return Promise.resolve();
const next = this.queue.filter(j => !j.job.group || !this.activeQueue.some(a => a.job.group === j.job.group)).map((job, i) => ({ i, job }));
let activeGroupsSet = new Set(this.activeQueue.filter(j => j.job.group).map(j => j.job.group));
const next = this.queue.filter(j =>
{
if (j.job.group)
{
// Only take one task per group to be active
if (!activeGroupsSet.has(j.job.group))
{
activeGroupsSet.add(j.job.group);
return true;
}
} else
{
return true;
}
return false;
}).map((job, i) => ({ i, job }));
next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
@ -82,6 +109,14 @@ export class TaskQueue
return job?.promise.promise ?? Promise.resolve();
}
public cancelJob (id: string)
{
const job = this.queue?.find(j => j.id === id)
?? this.activeQueue?.find(j => j.id === id);
job?.abort('cancel');
}
public findJob<T> (
id: string,
type: new (...args: any[]) => T
@ -99,6 +134,16 @@ export class TaskQueue
return undefined as any;
}
public getActiveJobs ()
{
return this.activeQueue;
}
public getQueuedJobs ()
{
return this.queue;
}
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
{
this.events?.on(event, listener);
@ -170,6 +215,7 @@ export interface CompletedEvent extends BaseEvent
export interface IJob<TData, TState extends string>
{
/** What group does the job belong to. Grouped jobs can only have 1 active job per group */
group?: string;
start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>;
exposeData?(): TData;
@ -210,12 +256,14 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
private events: EventEmitter<EventsList>;
private abortController: AbortController;
private m_promise: PromiseWithResolvers<TData | undefined>;
private throwOnCancel: boolean;
private readonly m_job: T;
constructor(id: string, events: EventEmitter<EventsList>, job: T)
constructor(id: string, events: EventEmitter<EventsList>, job: T, options?: { throwOnCancel?: boolean; })
{
this.m_id = id;
this.m_job = job;
this.throwOnCancel = options?.throwOnCancel ?? false;
this.abortController = new AbortController();
this.abortController.signal.addEventListener('abort', () =>
{
@ -247,7 +295,13 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
{
if (error.target instanceof AbortSignal)
{
this.m_promise.resolve(undefined);
if (this.throwOnCancel)
{
this.m_promise.reject(this.abortSignal.reason);
} else
{
this.m_promise.resolve(undefined);
}
} else
{
console.error(error);