Paginated admin library & upgrade manifests (#351)

* feat: new page layout + endpoint

* feat: non-parallel mass import

* feat: paginated admin library

* feat: lint and performance improvement

* feat: library filter util

* feat: link frontend features to backend

* fix: lint

* fix: small fixes

* feat: bump torrential

* fix: lint
This commit is contained in:
DecDuck
2026-02-25 02:17:33 +11:00
committed by GitHub
parent 1ad881721e
commit dbe34684d8
35 changed files with 1823 additions and 416 deletions
-16
View File
@@ -1,16 +0,0 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:read"]);
if (!allowed) throw createError({ statusCode: 403 });
return await prisma.game.findMany({
select: {
id: true,
mName: true,
mShortDescription: true,
mIconObjectId: true,
},
});
});
@@ -0,0 +1,54 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import { libraryManager } from "~/server/internal/library";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const games = await prisma.game.findMany({
select: {
id: true,
mName: true,
mIconObjectId: true,
versions: {
select: {
versionPath: true,
},
},
unimportedGameVersions: {
select: {
id: true,
versionName: true,
},
},
libraryId: true,
libraryPath: true,
},
});
const unimportedVersions = await Promise.all(
games.map(async (v) => ({
id: v.id,
name: v.mName,
icon: v.mIconObjectId,
versions: await libraryManager.fetchUnimportedGameVersions(
v.libraryId,
v.libraryPath,
{
gameId: v.id,
versions: v.versions
.map((v) => v.versionPath)
.filter((v) => v !== null),
depotVersions: v.unimportedGameVersions,
},
),
})),
);
const onlyUnimported = unimportedVersions.filter(
(v) => v.versions && v.versions.length > 0,
);
return onlyUnimported;
});
@@ -0,0 +1,115 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import { aclManager } from "~/server/internal/acls";
import { libraryManager } from "~/server/internal/library";
import { taskHandler, wrapTaskContext } from "~/server/internal/tasks";
import type { Platform } from "~/prisma/client/client";
const MassImport = type({
versions: type({
id: "string",
version: type({
type: "'depot' | 'local'",
identifier: "string",
name: "string",
}),
displayName: "string?",
setupMode: "boolean = false",
}).array(),
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, MassImport);
const taskId = await taskHandler.create({
key: "mass-import",
taskGroup: "import:version",
acls: ["system:import:version:read"],
name: `Mass-importing for ${body.versions.length} versions`,
async run({ progress, logger, addAction }) {
for (
let versionIndex = 0;
versionIndex < body.versions.length;
versionIndex++
) {
const version = body.versions[versionIndex];
const preload = await libraryManager.fetchUnimportedVersionInformation(
version.id,
version.version,
);
if (!preload) {
logger.warn(
`failed to fetch preload information for: ${version.version.name} (${version.version.type})`,
);
continue;
}
const chosenPreload = preload.at(0);
if (!chosenPreload) {
logger.warn(
`failed to find preload information for: ${version.version.name} (${version.version.type}), there were no auto-discovered executables`,
);
continue;
}
const launches: Array<{
platform: Platform;
launch: string;
name: string;
}> = [];
const setups: Array<{ platform: Platform; launch: string }> = [];
if (version.setupMode) {
setups.push({
platform: chosenPreload.platform,
launch: chosenPreload.filename,
});
} else {
launches.push({
platform: chosenPreload.platform,
launch: chosenPreload.filename,
name: "Play",
});
}
logger.info(`importing ${version.version.name}`);
const min = versionIndex / body.versions.length;
const max = (versionIndex + 1) / body.versions.length;
await libraryManager.importVersion(
version.id,
version.version,
{
id: version.id,
version: version.version,
launches,
setups,
onlySetup: version.setupMode,
delta: false,
requiredContent: [],
},
wrapTaskContext(
{
logger,
progress,
addAction,
},
{
min: min * 100,
max: max * 100,
prefix: `${version.version.name}`,
},
),
);
logger.info(`finished import for ${version.version.name}`);
progress(max * 100);
}
},
});
return { taskId };
});
@@ -2,7 +2,6 @@ import { type } from "arktype";
import { Platform } from "~/prisma/client/enums";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export const ImportVersion = type({
@@ -42,45 +41,6 @@ export default defineEventHandler(async (h3) => {
const body = await readDropValidatedBody(h3, ImportVersion);
if (body.delta) {
for (const platformObject of [...body.launches, ...body.setups].filter(
(v, i, a) => a.findIndex((k) => k.platform === v.platform) == i,
)) {
const validOverlayVersions = await prisma.gameVersion.count({
where: {
gameId: body.id,
delta: false,
OR: [
{ launches: { some: { platform: platformObject.platform } } },
{
setups: { some: { platform: platformObject.platform } },
},
],
},
});
if (validOverlayVersions == 0)
throw createError({
statusCode: 400,
statusMessage: `Update mode requires a pre-existing version for platform: ${platformObject.platform}`,
});
}
}
if (body.onlySetup) {
if (body.setups.length == 0)
throw createError({
statusCode: 400,
statusMessage: 'Setup required in "setup mode".',
});
} else {
if (body.launches.length == 0)
throw createError({
statusCode: 400,
statusMessage: "Launch executable is required.",
});
}
// startup & delta require more complex checking logic
const taskId = await libraryManager.importVersion(
body.id,
body.version,
+118 -5
View File
@@ -1,15 +1,128 @@
import { ArkErrors, type } from "arktype";
import type { SerializeObject } from "nitropack";
import type { Prisma } from "~/prisma/client/client";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
import deepmerge from "deepmerge";
const Query = type({
query: "string?",
skip: "string.numeric.parse?",
limit: "string.numeric.parse?",
sort: "'default' | 'newest' | 'recent' | 'name' = 'default'",
order: "'asc' | 'desc' = 'desc'",
"filters?": type("string").pipe((s) => s.split(",")),
});
type FetchArg = Parameters<typeof libraryManager.fetchGamesWithStatus>[0];
export type AdminLibraryGame = SerializeObject<
Awaited<ReturnType<typeof libraryManager.fetchGamesWithStatus>>[number]
>;
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["library:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const unimportedGames = await libraryManager.fetchUnimportedGames();
const games = await libraryManager.fetchGamesWithStatus();
const libraries = await libraryManager.fetchLibraries();
const query = Query(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, message: query.summary });
// Fetch other library data here
const skip = query.skip
? ({
skip: query.skip,
} satisfies FetchArg)
: undefined;
return { unimportedGames, games, hasLibraries: libraries.length > 0 };
const limit = Math.min(query.limit ?? 24, 50);
const sort: Prisma.GameOrderByWithRelationInput = {};
switch (query.sort) {
case "default":
case "newest":
sort.mReleased = query.order;
break;
case "recent":
sort.created = query.order;
break;
case "name":
sort.mName = query.order;
break;
}
const rawFilters: Array<Prisma.GameFindManyArgs & Prisma.GameCountArgs> = [];
if (query.filters && query.filters.length > 0) {
const filterSet = new Set(query.filters);
if (filterSet.has("version.none")) {
rawFilters.push({
where: {
versions: {
none: {},
},
},
});
}
if (filterSet.has("metadata.featured")) {
rawFilters.push({
where: {
featured: true,
},
});
}
if (filterSet.has("metadata.noCarousel")) {
rawFilters.push({
where: {
OR: [
{
mImageCarouselObjectIds: {
isEmpty: true,
},
},
],
},
});
}
if (filterSet.has("metadata.emptyDescription")) {
rawFilters.push({
where: {
mDescription: "",
},
});
}
}
if (query.query) {
rawFilters.push({
where: {
mName: {
contains: query.query,
mode: "insensitive",
},
},
});
}
const filters =
rawFilters.length > 0
? rawFilters.reduce((a, b) => deepmerge(a, b))
: undefined;
const results = await libraryManager.fetchGamesWithStatus({
...skip,
take: limit,
orderBy: sort,
...filters,
});
// Safety: the type is defined as a union between the where and count args
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const count = await prisma.game.count({ ...(filters as any) });
return { results, count };
});
@@ -0,0 +1,12 @@
import aclManager from "~/server/internal/acls";
import libraryManager from "~/server/internal/library";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["library:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const unimportedGames = await libraryManager.fetchUnimportedGames();
const libraries = await libraryManager.fetchLibraries();
return { unimportedGames, hasLibraries: libraries.length > 0 };
});
+10 -4
View File
@@ -1,6 +1,5 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import type { TaskMessage } from "~/server/internal/tasks";
import taskHandler from "~/server/internal/tasks";
export default defineEventHandler(async (h3) => {
@@ -14,7 +13,7 @@ export default defineEventHandler(async (h3) => {
});
const runningTasks = (await taskHandler.runningTasks()).map((e) => e.id);
const historicalTasks = (await prisma.task.findMany({
const historicalTasks = await prisma.task.findMany({
where: {
OR: [
{
@@ -28,8 +27,15 @@ export default defineEventHandler(async (h3) => {
orderBy: {
ended: "desc",
},
take: 10,
})) as Array<TaskMessage>;
select: {
id: true,
name: true,
actions: true,
error: true,
success: true,
},
take: 32,
});
const dailyTasks = await taskHandler.dailyTasks();
const weeklyTasks = await taskHandler.weeklyTasks();
+13 -1
View File
@@ -1,3 +1,4 @@
import { ArkErrors, type } from "arktype";
import type { Platform } from "~/prisma/client/enums";
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import prisma from "~/server/internal/db/database";
@@ -21,6 +22,10 @@ type VersionDownloadOption = {
}>;
};
const Query = type({
previous: "string?",
});
export default defineClientEventHandler(async (h3) => {
const id = getRouterParam(h3, "id")!;
if (!id)
@@ -29,6 +34,10 @@ export default defineClientEventHandler(async (h3) => {
statusMessage: "No ID in router params",
});
const query = Query(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, message: query.summary });
const rawVersions = await prisma.gameVersion.findMany({
where: {
gameId: id,
@@ -93,7 +102,10 @@ export default defineClientEventHandler(async (h3) => {
}
}
const size = await gameSizeManager.getVersionSize(v.versionId);
const size = await gameSizeManager.getVersionSize(
v.versionId,
query.previous,
);
return platformOptions
.entries()
+16 -9
View File
@@ -1,15 +1,22 @@
import { ArkErrors, type } from "arktype";
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import { createDownloadManifestDetails } from "~/server/internal/library/manifest/index";
export default defineClientEventHandler(async (h3) => {
const query = getQuery(h3);
const version = query.version?.toString();
if (!version)
throw createError({
statusCode: 400,
statusMessage: "Missing version ID in query",
});
const Query = type({
version: "string",
previous: "string?",
refresh: "string?",
});
const result = await createDownloadManifestDetails(version);
export default defineClientEventHandler(async (h3) => {
const query = Query(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, message: query.summary });
const result = await createDownloadManifestDetails(
query.version,
query.previous,
query.refresh == "true",
);
return result;
});