Database-level multi-library support #48 (#58)

* feat: start of library backends

* feat: update backend routes and create initializer

* feat: add legacy library creation

* fix: resolve frontend type errors

* fix: runtime errors

* fix: lint
This commit is contained in:
DecDuck
2025-06-01 16:05:05 +10:00
committed by GitHub
parent 490afd0bb7
commit 3e5c3678d5
21 changed files with 664 additions and 298 deletions
+4 -3
View File
@@ -33,11 +33,12 @@ export default defineEventHandler(async (h3) => {
},
});
if (!game)
if (!game || !game.libraryId)
throw createError({ statusCode: 404, statusMessage: "Game ID not found" });
const unimportedVersions = await libraryManager.fetchUnimportedVersions(
game.id,
const unimportedVersions = await libraryManager.fetchUnimportedGameVersions(
game.libraryId,
game.libraryPath,
);
return { game, unimportedVersions };
+6 -1
View File
@@ -6,5 +6,10 @@ export default defineEventHandler(async (h3) => {
if (!allowed) throw createError({ statusCode: 403 });
const unimportedGames = await libraryManager.fetchAllUnimportedGames();
return { unimportedGames };
const iterableUnimportedGames = Object.entries(unimportedGames)
.map(([libraryId, gameArray]) =>
gameArray.map((e) => ({ game: e, library: libraryId })),
)
.flat();
return { unimportedGames: iterableUnimportedGames };
});
+38 -29
View File
@@ -1,37 +1,46 @@
import { type } from "arktype";
import { throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import libraryManager from "~/server/internal/library";
import metadataHandler from "~/server/internal/metadata";
import type {
GameMetadataSearchResult,
GameMetadataSource,
} from "~/server/internal/metadata/types";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:game:new"]);
if (!allowed) throw createError({ statusCode: 403 });
const ImportGameBody = type({
library: "string",
path: "string",
["metadata?"]: {
id: "string",
sourceId: "string",
name: "string",
},
}).configure(throwingArktype);
const body = await readBody(h3);
export default defineEventHandler<{ body: typeof ImportGameBody.infer }>(
async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:game:new"]);
if (!allowed) throw createError({ statusCode: 403 });
const path = body.path;
const metadata = body.metadata as GameMetadataSearchResult &
GameMetadataSource;
if (!path)
throw createError({
statusCode: 400,
statusMessage: "Path missing from body",
});
const { library, path, metadata } = await readValidatedBody(
h3,
ImportGameBody,
);
const validPath = await libraryManager.checkUnimportedGamePath(path);
if (!validPath)
throw createError({
statusCode: 400,
statusMessage: "Invalid unimported game path",
});
if (!path)
throw createError({
statusCode: 400,
statusMessage: "Path missing from body",
});
if (!metadata || !metadata.id || !metadata.sourceId) {
console.log(metadata);
return await metadataHandler.createGameWithoutMetadata(path);
} else {
return await metadataHandler.createGame(metadata, path);
}
});
const valid = await libraryManager.checkUnimportedGamePath(library, path);
if (!valid)
throw createError({
statusCode: 400,
statusMessage: "Invalid library or game.",
});
if (!metadata) {
return await metadataHandler.createGameWithoutMetadata(library, path);
} else {
return await metadataHandler.createGame(metadata, library, path);
}
},
);
@@ -1,4 +1,5 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineEventHandler(async (h3) => {
@@ -13,8 +14,17 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Missing id in request params",
});
const unimportedVersions =
await libraryManager.fetchUnimportedVersions(gameId);
const game = await prisma.game.findUnique({
where: { id: gameId },
select: { libraryId: true, libraryPath: true },
});
if (!game || !game.libraryId)
throw createError({ statusCode: 404, statusMessage: "Game not found" });
const unimportedVersions = await libraryManager.fetchUnimportedGameVersions(
game.libraryId,
game.libraryPath,
);
if (!unimportedVersions)
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
@@ -9,31 +9,31 @@ const ImportVersion = type({
version: "string",
platform: "string",
launch: "string?",
launchArgs: "string?",
setup: "string?",
setupArgs: "string?",
onlySetup: "boolean?",
delta: "boolean?",
umuId: "string?",
launch: "string = ''",
launchArgs: "string = ''",
setup: "string = ''",
setupArgs: "string = ''",
onlySetup: "boolean = false",
delta: "boolean = false",
umuId: "string = ''",
});
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readValidatedBody(h3, ImportVersion);
const gameId = body.id;
const versionName = body.version;
const platform = body.platform;
const launch = body.launch ?? "";
const launchArgs = body.launchArgs ?? "";
const setup = body.setup ?? "";
const setupArgs = body.setupArgs ?? "";
const onlySetup = body.onlySetup ?? false;
const delta = body.delta ?? false;
const umuId = body.umuId ?? "";
const {
id,
version,
platform,
launch,
launchArgs,
setup,
setupArgs,
onlySetup,
delta,
umuId,
} = await readValidatedBody(h3, ImportVersion);
const platformParsed = parsePlatform(platform);
if (!platformParsed)
@@ -41,7 +41,7 @@ export default defineEventHandler(async (h3) => {
if (delta) {
const validOverlayVersions = await prisma.gameVersion.count({
where: { gameId: gameId, platform: platformParsed, delta: false },
where: { gameId: id, platform: platformParsed, delta: false },
});
if (validOverlayVersions == 0)
throw createError({
@@ -66,7 +66,7 @@ export default defineEventHandler(async (h3) => {
}
// startup & delta require more complex checking logic
const taskId = await libraryManager.importVersion(gameId, versionName, {
const taskId = await libraryManager.importVersion(id, version, {
platform,
onlySetup,
+46 -27
View File
@@ -1,10 +1,14 @@
import cacheHandler from "~/server/internal/cache";
import prisma from "~/server/internal/db/database";
import fs from "fs";
import path from "path";
import libraryManager from "~/server/internal/library";
const chunkSize = 1024 * 1024 * 64;
const gameLookupCache = cacheHandler.createCache<{
libraryId: string | null;
libraryPath: string;
}>("downloadGameLookupCache");
export default defineEventHandler(async (h3) => {
const query = getQuery(h3);
const gameId = query.id?.toString();
@@ -18,36 +22,40 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid chunk arguments",
});
const game = await prisma.game.findUnique({
where: {
id: gameId,
},
select: {
libraryBasePath: true,
},
});
if (!game)
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
let game = await gameLookupCache.getItem(gameId);
if (!game) {
game = await prisma.game.findUnique({
where: {
id: gameId,
},
select: {
libraryId: true,
libraryPath: true,
},
});
if (!game || !game.libraryId)
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
const versionDir = path.join(
libraryManager.fetchLibraryPath(),
game.libraryBasePath,
versionName,
);
if (!fs.existsSync(versionDir))
await gameLookupCache.setItem(gameId, game);
}
if (!game.libraryId)
throw createError({
statusCode: 400,
statusMessage: "Invalid version name",
statusCode: 500,
statusMessage: "Somehow, we got here.",
});
const gameFile = path.join(versionDir, filename);
if (!fs.existsSync(gameFile))
throw createError({ statusCode: 400, statusMessage: "Invalid game file" });
const gameFileStats = fs.statSync(gameFile);
const peek = await libraryManager.peekFile(
game.libraryId,
game.libraryPath,
versionName,
filename,
);
if (!peek)
throw createError({ status: 400, statusMessage: "Failed to peek file" });
const start = chunkIndex * chunkSize;
const end = Math.min((chunkIndex + 1) * chunkSize, gameFileStats.size);
const end = Math.min((chunkIndex + 1) * chunkSize, peek.size);
const currentChunkSize = end - start;
setHeader(h3, "Content-Length", currentChunkSize);
@@ -57,7 +65,18 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid chunk index",
});
const gameReadStream = fs.createReadStream(gameFile, { start, end: end - 1 }); // end needs to be offset by 1
const gameReadStream = await libraryManager.readFile(
game.libraryId,
game.libraryPath,
versionName,
filename,
{ start, end: end - 1 },
); // end needs to be offset by 1
if (!gameReadStream)
throw createError({
statusCode: 400,
statusMessage: "Failed to create stream",
});
return sendStream(h3, gameReadStream);
});