From b636400cbfc7793c7fd3eaf6e6e6291f72a30433 Mon Sep 17 00:00:00 2001
From: DecDuck
Date: Sun, 1 Jun 2025 13:48:14 +1000
Subject: [PATCH 1/6] feat: start of library backends
---
package.json | 2 +-
.../migration.sql | 53 ++++++
.../migration.sql | 17 ++
.../migration.sql | 8 +
prisma/models/app.prisma | 16 +-
prisma/models/content.prisma | 10 +-
server/internal/library/filesystem.ts | 85 +++++++++
server/internal/library/index.ts | 164 +++++++-----------
server/internal/library/provider.ts | 50 ++++++
server/internal/metadata/giantbomb.ts | 1 -
server/plugins/05.library-init.ts | 3 +
yarn.lock | 140 +++++++--------
12 files changed, 367 insertions(+), 182 deletions(-)
create mode 100644 prisma/migrations/20250601022736_add_database_library/migration.sql
create mode 100644 prisma/migrations/20250601032211_add_library_relation_to_game/migration.sql
create mode 100644 prisma/migrations/20250601032938_add_unique_constraint/migration.sql
create mode 100644 server/internal/library/filesystem.ts
create mode 100644 server/internal/library/provider.ts
create mode 100644 server/plugins/05.library-init.ts
diff --git a/package.json b/package.json
index 86724d9a..db035657 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"lint:fix": "eslint . --fix && prettier --write --list-different ."
},
"dependencies": {
- "@drop-oss/droplet": "^0.7.2",
+ "@drop-oss/droplet": "^1.3.1",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@lobomfz/prismark": "0.0.3",
diff --git a/prisma/migrations/20250601022736_add_database_library/migration.sql b/prisma/migrations/20250601022736_add_database_library/migration.sql
new file mode 100644
index 00000000..e51f2fae
--- /dev/null
+++ b/prisma/migrations/20250601022736_add_database_library/migration.sql
@@ -0,0 +1,53 @@
+/*
+ Warnings:
+
+ - You are about to drop the `ClientPeerAPIConfiguration` table. If the table is not empty, all the data it contains will be lost.
+
+*/
+-- CreateEnum
+CREATE TYPE "LibraryBackend" AS ENUM ('Filesystem');
+
+-- AlterEnum
+ALTER TYPE "ClientCapabilities" ADD VALUE 'trackPlaytime';
+
+-- DropForeignKey
+ALTER TABLE "ClientPeerAPIConfiguration" DROP CONSTRAINT "ClientPeerAPIConfiguration_clientId_fkey";
+
+-- AlterTable
+ALTER TABLE "Screenshot" ALTER COLUMN "private" DROP DEFAULT;
+
+-- DropTable
+DROP TABLE "ClientPeerAPIConfiguration";
+
+-- CreateTable
+CREATE TABLE "Library" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "backend" "LibraryBackend" NOT NULL,
+ "options" JSONB NOT NULL,
+
+ CONSTRAINT "Library_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Playtime" (
+ "gameId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "seconds" INTEGER NOT NULL,
+ "updatedAt" TIMESTAMPTZ(6) NOT NULL,
+ "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Playtime_pkey" PRIMARY KEY ("gameId","userId")
+);
+
+-- CreateIndex
+CREATE INDEX "Playtime_userId_idx" ON "Playtime"("userId");
+
+-- CreateIndex
+CREATE INDEX "Screenshot_userId_idx" ON "Screenshot"("userId");
+
+-- AddForeignKey
+ALTER TABLE "Playtime" ADD CONSTRAINT "Playtime_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Playtime" ADD CONSTRAINT "Playtime_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20250601032211_add_library_relation_to_game/migration.sql b/prisma/migrations/20250601032211_add_library_relation_to_game/migration.sql
new file mode 100644
index 00000000..e50aac10
--- /dev/null
+++ b/prisma/migrations/20250601032211_add_library_relation_to_game/migration.sql
@@ -0,0 +1,17 @@
+/*
+Warnings:
+
+- You are about to drop the column `libraryBasePath` on the `Game` table. All the data in the column will be lost.
+
+*/
+-- DropIndex
+DROP INDEX "Game_libraryBasePath_key";
+
+-- AlterTable
+ALTER TABLE "Game" RENAME COLUMN "libraryBasePath" TO "libraryPath";
+
+ALTER TABLE "Game" ADD COLUMN "libraryId" TEXT;
+
+-- AddForeignKey
+ALTER TABLE "Game"
+ADD CONSTRAINT "Game_libraryId_fkey" FOREIGN KEY ("libraryId") REFERENCES "Library" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
\ No newline at end of file
diff --git a/prisma/migrations/20250601032938_add_unique_constraint/migration.sql b/prisma/migrations/20250601032938_add_unique_constraint/migration.sql
new file mode 100644
index 00000000..f4600206
--- /dev/null
+++ b/prisma/migrations/20250601032938_add_unique_constraint/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - A unique constraint covering the columns `[libraryId,libraryPath]` on the table `Game` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- CreateIndex
+CREATE UNIQUE INDEX "Game_libraryId_libraryPath_key" ON "Game"("libraryId", "libraryPath");
diff --git a/prisma/models/app.prisma b/prisma/models/app.prisma
index 492c2654..8d520256 100644
--- a/prisma/models/app.prisma
+++ b/prisma/models/app.prisma
@@ -1,7 +1,7 @@
model ApplicationSettings {
timestamp DateTime @id @default(now())
- metadataProviders String[]
+ metadataProviders String[]
saveSlotCountLimit Int @default(5)
saveSlotSizeLimit Float @default(10) // MB
@@ -13,3 +13,17 @@ enum Platform {
Linux @map("linux")
macOS @map("macos")
}
+
+enum LibraryBackend {
+ Filesystem
+}
+
+model Library {
+ id String @id
+ name String
+
+ backend LibraryBackend
+ options Json
+
+ games Game[]
+}
diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma
index f2253a1e..43e67fdc 100644
--- a/prisma/models/content.prisma
+++ b/prisma/models/content.prisma
@@ -29,8 +29,13 @@ model Game {
mImageCarouselObjectIds String[] // linked to below array
mImageLibraryObjectIds String[] // linked to objects in s3
- versions GameVersion[]
- libraryBasePath String @unique // Base dir for all the game versions
+ versions GameVersion[]
+
+ // These fields will not be optional in the next version
+ // Any game without a library ID will be assigned one at startup, based on the defaults
+ libraryId String?
+ library Library? @relation(fields: [libraryId], references: [id])
+ libraryPath String
collections CollectionEntry[]
saves SaveSlot[]
@@ -42,6 +47,7 @@ model Game {
publishers Company[] @relation(name: "publishers")
@@unique([metadataSource, metadataId], name: "metadataKey")
+ @@unique([libraryId, libraryPath], name: "libraryKey")
}
model GameRating {
diff --git a/server/internal/library/filesystem.ts b/server/internal/library/filesystem.ts
new file mode 100644
index 00000000..a34c46df
--- /dev/null
+++ b/server/internal/library/filesystem.ts
@@ -0,0 +1,85 @@
+import { ArkErrors, type } from "arktype";
+import {
+ GameNotFoundError,
+ VersionNotFoundError,
+ type LibraryProvider,
+} from "./provider";
+import { LibraryBackend } from "~/prisma/client";
+import fs from "fs";
+import path from "path";
+import droplet from "@drop-oss/droplet";
+
+const FilesystemProviderConfig = type({
+ baseDir: "string",
+});
+
+export class FilesystemProvider
+ implements LibraryProvider
+{
+ private config: typeof FilesystemProviderConfig.infer;
+ private myId: string;
+
+ constructor(rawConfig: unknown, id: string) {
+ const config = FilesystemProviderConfig(rawConfig);
+ if (config instanceof ArkErrors) {
+ throw new Error(
+ `Failed to create filesystem provider: ${config.summary}`,
+ );
+ }
+
+ this.myId = id;
+ this.config = config;
+ fs.mkdirSync(this.config.baseDir, { recursive: true });
+ }
+
+ id(): string {
+ return this.myId;
+ }
+
+ type(): LibraryBackend {
+ return LibraryBackend.Filesystem;
+ }
+
+ async listGames(): Promise {
+ const dirs = fs.readdirSync(this.config.baseDir);
+ const folderDirs = dirs.filter((e) => {
+ const fullDir = path.join(this.config.baseDir, e);
+ return fs.lstatSync(fullDir).isDirectory();
+ });
+ return folderDirs;
+ }
+
+ async listVersions(game: string): Promise {
+ const gameDir = path.join(this.config.baseDir, game);
+ if (!fs.existsSync(gameDir)) throw new GameNotFoundError();
+ const versionDirs = fs.readdirSync(gameDir);
+ const validVersionDirs = versionDirs.filter((e) => {
+ const fullDir = path.join(this.config.baseDir, game, e);
+ return droplet.hasBackendForPath(fullDir);
+ });
+ return validVersionDirs;
+ }
+
+ async versionReaddir(game: string, version: string): Promise {
+ const versionDir = path.join(this.config.baseDir, game, version);
+ if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
+ return droplet.listFiles(versionDir);
+ }
+
+ async generateDropletManifest(
+ game: string,
+ version: string,
+ progress: (err: Error | null, v: number) => void,
+ log: (err: Error | null, v: string) => void,
+ ): Promise {
+ const versionDir = path.join(this.config.baseDir, game, version);
+ if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
+ const manifest = await new Promise((r, j) =>
+ droplet.generateManifest(game, progress, log, (err, result) => {
+ if (err) return j(err);
+ r(result);
+ }),
+ );
+ return manifest;
+ }
+}
diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts
index c288174a..6076583b 100644
--- a/server/internal/library/index.ts
+++ b/server/internal/library/index.ts
@@ -15,50 +15,53 @@ import taskHandler from "../tasks";
import { parsePlatform } from "../utils/parseplatform";
import droplet from "@drop-oss/droplet";
import notificationSystem from "../notifications";
-import { systemConfig } from "../config/sys-conf";
+import type { LibraryProvider } from "./provider";
class LibraryManager {
- private basePath: string;
+ private libraries: Map> = new Map();
- constructor() {
- this.basePath = systemConfig.getLibraryFolder();
- fs.mkdirSync(this.basePath, { recursive: true });
- }
+ async fetchAllUnimportedGames() {
+ const unimportedGames: { [key: string]: string[] } = {};
+
+ for (const [id, library] of this.libraries.entries()) {
+ const games = await library.listGames();
+ const validGames = await prisma.game.findMany({
+ where: {
+ libraryId: id,
+ libraryPath: { in: games },
+ },
+ select: {
+ libraryPath: true,
+ },
+ });
+ const providerUnimportedGames = games.filter(
+ (e) => validGames.findIndex((v) => v.libraryPath == e) == -1,
+ );
+ unimportedGames[id] = providerUnimportedGames;
+ }
- fetchLibraryPath() {
- return this.basePath;
+ return unimportedGames;
}
- async fetchAllUnimportedGames() {
- const dirs = fs.readdirSync(this.basePath).filter((e) => {
- const fullDir = path.join(this.basePath, e);
- return fs.lstatSync(fullDir).isDirectory();
- });
-
- const validGames = await prisma.game.findMany({
+ async fetchUnimportedGameVersions(libraryId: string, libraryPath: string) {
+ const provider = this.libraries.get(libraryId);
+ if (!provider) return undefined;
+ const game = await prisma.game.findUnique({
where: {
- libraryBasePath: { in: dirs },
+ libraryKey: {
+ libraryId,
+ libraryPath,
+ },
},
select: {
- libraryBasePath: true,
+ versions: true,
},
});
- const validGameDirs = validGames.map((e) => e.libraryBasePath);
-
- const unregisteredGames = dirs.filter((e) => !validGameDirs.includes(e));
-
- return unregisteredGames;
- }
+ if (!game) return undefined;
- async fetchUnimportedGameVersions(
- libraryBasePath: string,
- versions: Array,
- ) {
- const gameDir = path.join(this.basePath, libraryBasePath);
- const versionsDirs = fs.readdirSync(gameDir);
- const importedVersionDirs = versions.map((e) => e.versionName);
- const unimportedVersions = versionsDirs.filter(
- (e) => !importedVersionDirs.includes(e),
+ const versions = await provider.listVersions(libraryPath);
+ const unimportedVersions = versions.filter(
+ (e) => game.versions.findIndex((v) => v.versionName == e) == -1,
);
return unimportedVersions;
@@ -73,7 +76,8 @@ class LibraryManager {
mShortDescription: true,
metadataSource: true,
mIconObjectId: true,
- libraryBasePath: true,
+ libraryId: true,
+ libraryPath: true,
},
orderBy: {
mName: "asc",
@@ -86,59 +90,23 @@ class LibraryManager {
status: {
noVersions: e.versions.length == 0,
unimportedVersions: await this.fetchUnimportedGameVersions(
- e.libraryBasePath,
- e.versions,
+ e.libraryId ?? "",
+ e.libraryPath,
),
},
})),
);
}
- async fetchUnimportedVersions(gameId: string) {
- const game = await prisma.game.findUnique({
- where: { id: gameId },
- select: {
- versions: {
- select: {
- versionName: true,
- },
- },
- libraryBasePath: true,
- },
- });
-
- if (!game) return undefined;
- const targetDir = path.join(this.basePath, game.libraryBasePath);
- if (!fs.existsSync(targetDir))
- throw new Error(
- "Game in database, but no physical directory? Something is very very wrong...",
- );
- const versions = fs.readdirSync(targetDir);
- const validVersions = versions.filter((versionDir) => {
- const versionPath = path.join(targetDir, versionDir);
- const stat = fs.statSync(versionPath);
- return stat.isDirectory();
- });
- const currentVersions = game.versions.map((e) => e.versionName);
-
- const unimportedVersions = validVersions.filter(
- (e) => !currentVersions.includes(e),
- );
- return unimportedVersions;
- }
-
async fetchUnimportedVersionInformation(gameId: string, versionName: string) {
const game = await prisma.game.findUnique({
where: { id: gameId },
- select: { libraryBasePath: true, mName: true },
+ select: { libraryPath: true, libraryId: true, mName: true },
});
- if (!game) return undefined;
- const targetDir = path.join(
- this.basePath,
- game.libraryBasePath,
- versionName,
- );
- if (!fs.existsSync(targetDir)) return undefined;
+ if (!game || !game.libraryId) return undefined;
+
+ const library = this.libraries.get(game.libraryId);
+ if (!library) return undefined;
const fileExts: { [key: string]: string[] } = {
Linux: [
@@ -165,7 +133,7 @@ class LibraryManager {
match: number;
}> = [];
- const files = recursivelyReaddir(targetDir, 2);
+ const files = await library.versionReaddir(game.libraryPath, versionName);
for (const file of files) {
const filename = path.basename(file);
const dotLocation = file.lastIndexOf(".");
@@ -174,10 +142,9 @@ class LibraryManager {
for (const checkExt of checkExts) {
if (checkExt != ext) continue;
const fuzzyValue = fuzzy(filename, game.mName);
- const relative = path.relative(targetDir, file);
options.push({
- filename: relative,
- platform: platform,
+ filename,
+ platform,
match: fuzzyValue,
});
}
@@ -224,12 +191,12 @@ class LibraryManager {
const game = await prisma.game.findUnique({
where: { id: gameId },
- select: { mName: true, libraryBasePath: true },
+ select: { mName: true, libraryId: true, libraryPath: true },
});
- if (!game) return undefined;
+ if (!game || !game.libraryId) return undefined;
- const baseDir = path.join(this.basePath, game.libraryBasePath, versionName);
- if (!fs.existsSync(baseDir)) return undefined;
+ const library = this.libraries.get(game.libraryId);
+ if (!library) return undefined;
taskHandler.create({
id: taskId,
@@ -238,23 +205,18 @@ class LibraryManager {
async run({ progress, log }) {
// First, create the manifest via droplet.
// This takes up 90% of our progress, so we wrap it in a *0.9
- const manifest = await new Promise((resolve, reject) => {
- droplet.generateManifest(
- baseDir,
- (err, value) => {
- if (err) return reject(err);
- progress(value * 0.9);
- },
- (err, line) => {
- if (err) return reject(err);
- log(line);
- },
- (err, manifest) => {
- if (err) return reject(err);
- resolve(manifest);
- },
- );
- });
+ const manifest = await library.generateDropletManifest(
+ game.libraryPath,
+ versionName,
+ (err, value) => {
+ if (err) throw err;
+ progress(value * 0.9);
+ },
+ (err, value) => {
+ if (err) throw err;
+ log(value);
+ },
+ );
log("Created manifest successfully!");
diff --git a/server/internal/library/provider.ts b/server/internal/library/provider.ts
new file mode 100644
index 00000000..3b9996e4
--- /dev/null
+++ b/server/internal/library/provider.ts
@@ -0,0 +1,50 @@
+import type { LibraryBackend } from "~/prisma/client";
+
+export abstract class LibraryProvider {
+ constructor(_config: CFG, _id: string) {
+ throw new Error("Library doesn't have a proper constructor");
+ }
+
+ /**
+ * @returns ID of the current library provider (fs, smb, s3, etc)
+ */
+ abstract type(): LibraryBackend;
+
+ /**
+ * @returns the specific ID of this current provider
+ */
+ abstract id(): string;
+
+ /**
+ * @returns list of (usually) top-level game folder names
+ */
+ abstract listGames(): Promise;
+
+ /**
+ * @param game folder name of the game to list versions for
+ * @returns list of version folder names
+ */
+ abstract listVersions(game: string): Promise;
+
+ /**
+ * @param game folder name of the game
+ * @param version folder name of the version
+ * @returns recursive list of all files in version, relative to the version folder (e.g. ./setup.exe)
+ */
+ abstract versionReaddir(game: string, version: string): Promise;
+
+ /**
+ * @param game folder name of the game
+ * @param version folder name of the version
+ * @returns string of JSON of the droplet manifest
+ */
+ abstract generateDropletManifest(
+ game: string,
+ version: string,
+ progress: (err: Error | null, v: number) => void,
+ log: (err: Error | null, v: string) => void,
+ ): Promise;
+}
+
+export class GameNotFoundError extends Error {}
+export class VersionNotFoundError extends Error {}
\ No newline at end of file
diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts
index 5935e6c9..4fbeed9b 100644
--- a/server/internal/metadata/giantbomb.ts
+++ b/server/internal/metadata/giantbomb.ts
@@ -215,7 +215,6 @@ export class GiantBombProvider implements MetadataProvider {
const reviewId = api_detail_url.split("/").at(-2);
if (!reviewId) continue;
const review = await this.request("review", reviewId, {});
- console.log(review.data);
reviews.push({
metadataSource: MetadataSource.GiantBomb,
metadataId: reviewId,
diff --git a/server/plugins/05.library-init.ts b/server/plugins/05.library-init.ts
new file mode 100644
index 00000000..783b9718
--- /dev/null
+++ b/server/plugins/05.library-init.ts
@@ -0,0 +1,3 @@
+export default defineNitroPlugin(async (h3) => {
+
+})
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 43d026ce..9f67fb92 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -305,83 +305,71 @@
dependencies:
mime "^3.0.0"
-"@drop-oss/droplet-darwin-arm64@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-0.7.2.tgz#fb714d3bf83dbf5e0ee6068ce2fdc74652a9d073"
- integrity sha512-g1IiaSWYd+NDhyRbEKxSxrKFieJV/bwijcFfzP5VLHbTohDu5zJLe6Exc/IXbIb+Ex70Rfsk8Sf9n1zfHCD+Fg==
-
-"@drop-oss/droplet-darwin-universal@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-0.7.2.tgz#237d70fab92b892e4d40855d13fd54ad55cf026e"
- integrity sha512-wVVkMi0uwOob876xNFc37/5dGusKjlsWc4Z9bTUtTGeWo9gx5BkEpHBRrwD9NBAklr0Eu7Kmin3niB7pfx9vTw==
-
-"@drop-oss/droplet-darwin-x64@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-0.7.2.tgz#ed12ced467ff38f7eb2419b0ae6b1c508d828b84"
- integrity sha512-/p53OVesFG1Q/3+kYImitduGvZFfrfyVgdW+twoy+DYTX5EE1XZKaLZs2PSnbFSnnFJTmWvfnGqN5s+Dh12AKw==
-
-"@drop-oss/droplet-linux-arm-gnueabihf@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm-gnueabihf/-/droplet-linux-arm-gnueabihf-0.7.2.tgz#2fb0bfae2cb5fd08942d4f490f25046f006123ce"
- integrity sha512-hZtkKhgMkSqhueOEBRBZlSWE6uawM9M31gPmajrYHNOEnnmt8oUtZriPvC1ffZwZnQb4LL7IMGUZmXTl6guZXQ==
-
-"@drop-oss/droplet-linux-arm-musleabihf@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm-musleabihf/-/droplet-linux-arm-musleabihf-0.7.2.tgz#cbf54f5ff271a9e4601f6f6489cb6f630c6e9cbc"
- integrity sha512-FBy8GE06mWSlv/t3d7iOF2wP9jvvPTePwPpIQyMpmEOz5MmdwF3/PFFncV4WcmxQ/RHUhIrZ3M9Dfq8WCiXPgw==
-
-"@drop-oss/droplet-linux-arm64-gnu@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-0.7.2.tgz#96062bf8a63de742995d89b782fa7e11f26d984f"
- integrity sha512-Ev+WOUwazMgzz3tcHZefCaELSQ/dUJA795eXiNp0jDFRhddeybulxabte9hM9XjP5Yg/pZ0GpenWMjcWvxVaIQ==
-
-"@drop-oss/droplet-linux-arm64-musl@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-0.7.2.tgz#cabb0305e3337dc8fabe4d46d21756fe2d93bde6"
- integrity sha512-uJ0oOjPNNsNrqc8kJhlOxetz+lYb1QUOIKyKjpmTKVHYjNXj8bvc/FSDYwQjCPRs0r9qrEszF8hW6lsibQ92/g==
-
-"@drop-oss/droplet-linux-riscv64-gnu@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-0.7.2.tgz#5ea5b99df8677def14da6099dec9577433736655"
- integrity sha512-5xdbTvEs8MiOL3ren+QyCXvcLmKWa7NSAehdunaD82qIwV19Xz+/C7OC1jN2zGgAQ0TBM/HcbkmWITNEQB7Oiw==
-
-"@drop-oss/droplet-linux-x64-gnu@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-0.7.2.tgz#244f137f6f301c307414e43b7cbd42fbd3e3247f"
- integrity sha512-xM7tEzAR/yGFpO3C3lLpyOiqCD84MqwXQS6I1aR+z7IU+tAVwX1JYmu4HYGw1pxPCHpK/9w8NtAwzgSiw5d2jQ==
-
-"@drop-oss/droplet-linux-x64-musl@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-0.7.2.tgz#37bc2d079cc63949bd5ac5194be65cb9c769feb0"
- integrity sha512-s9YbnqPQhz468py49icPO74ezXF+EGKt7DX9vMs7XIp2Uyz+pWejRkerSj70WTypy5UcSNgcIBOB6kfD/FMMAQ==
-
-"@drop-oss/droplet-win32-arm64-msvc@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-0.7.2.tgz#c0a6048b9dc89596bf230346c5bbe86fcdc27009"
- integrity sha512-E0isKXZIt/mFUAfziZ9hat84uol4hWHcEZ86xxfz4L8/wljrKU7Vbw9yaYznk4FvKRHnwoccymtOTLrSq2Ju4Q==
-
-"@drop-oss/droplet-win32-x64-msvc@0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-0.7.2.tgz#2835e05bcf9923eb23e04b94c298520ecb6299d0"
- integrity sha512-O5t2B/3Ld+17q1qDPVds3V/Ex2as2l8piVBgEKIkEL51wJYu7ucwMwWrfdMWKXRn17Fl5ueeujZLuD3iySRkLw==
-
-"@drop-oss/droplet@^0.7.2":
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-0.7.2.tgz#a914dbee85cb3b3a0c9dd90d9cebec5bb0575bce"
- integrity sha512-XxKUuRMYMdTVT4IaetNRN07iUpHJkXdS1LKfPBDrNkjszfG0SGjqCd1PVw7p6ugPWdezS8ygGODR6c/cAOQ4kw==
+"@drop-oss/droplet-darwin-arm64@1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-1.3.1.tgz#672484c15419dbc068950f2d53f130ccbea3ff17"
+ integrity sha512-rarsZtIiZhv2hb3bAZSJjxwnme+rWUFY+FY79MRrMnz7EuNBez063pFBqDhwFCz+0QqDBz7zKDUQR6v+6gnVFw==
+
+"@drop-oss/droplet-darwin-universal@1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-1.3.1.tgz#0a3e1663125349b2d443dbfaba1bb7b8d2f9c64d"
+ integrity sha512-PuN5FdotwYuZ7O2r1aAWiE8hH/gH7CrH+j33OdgS4FI4XIOeW6qq+14JECZp6JgWv0863/C7tD5Ll4yMgIRvUQ==
+
+"@drop-oss/droplet-darwin-x64@1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-1.3.1.tgz#755db12988b431eff24df9a7177453178f29ce47"
+ integrity sha512-IloUIHnEI67S38vJxADbcXk81tR8b4fFTTpyCNUlAwIXHGbhEjFrfu+sLdK94MHN/vjlafMBf0APwYF2vclCkw==
+
+"@drop-oss/droplet-linux-arm64-gnu@1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-1.3.1.tgz#26ac0b24a08e6785742a19cbf97003f23d3b152d"
+ integrity sha512-aiesHfQushi+EGmTj970bxZvhNsBh90kzKbg14vdgFTL0/mhcturJSHa0VhJ2/m4qIg10IlmJpbuEm165Q25rQ==
+
+"@drop-oss/droplet-linux-arm64-musl@1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-1.3.1.tgz#1449cd59a75363a01f2ed68c1921e6d93fbafccd"
+ integrity sha512-Oa6HvvBbflxoG1nmdYbcgMAf29aTZ6xCxC84X+Q8TjiO5Qx2PHI+nX+KKK8rrJdQszrqpdT9wZbnD4zDtLzSeQ==
+
+"@drop-oss/droplet-linux-riscv64-gnu@1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-1.3.1.tgz#6300be23128ed4fd8895ca610cc2fb9bdf9abc05"
+ integrity sha512-vAVUiMixfB/oXIZ7N6QhJB1N+lb96JLrs2EjZiPGNSgwKGMV0H+84ZI+5NJ30qoytm7WB8mm2beezoCpM8frjg==
+
+"@drop-oss/droplet-linux-x64-gnu@1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-1.3.1.tgz#5f59e8816978af444301ad7116a0377f9aa2633a"
+ integrity sha512-R3UtBIw5amY1ExaX8fZMcS1zLv0DF9Y8YoBgqk+VbQrHMVfiQKiktv/dXRp+9iWzLB/m5aG/Se5QuJazOMlwtA==
+
+"@drop-oss/droplet-linux-x64-musl@1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-1.3.1.tgz#84b136665dc713c66a3e5b2be6ce5d97514dcb25"
+ integrity sha512-rDtmTYzx39Y1xHyRvm2AW97GkHy4ZfhXsmYWSjqo0dmoM5BY/nHfmNO6kWOABg4WP6mr3NPZKJTe885JVNilcg==
+
+"@drop-oss/droplet-win32-arm64-msvc@1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-1.3.1.tgz#77948fe25f27dedda979367feadbe89622aa6b19"
+ integrity sha512-ZQVLgloMd7NIW3j1cvL7DKp9164K8luLxb692yuXRF6pQ7ok8IPWgwiWoeqQ1OE/msPkgXEC7hHupwWtfX6tHw==
+
+"@drop-oss/droplet-win32-x64-msvc@1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-1.3.1.tgz#35f2dca041af48dec6743bf5c62a35582e25715d"
+ integrity sha512-NJsZM4g40I0b/MHFTvur3t30ULiU8D3DfhZTlLzyT+btiQ/8PdjCKRM4CPJJhs7JG8Bp30cl2n54XnsnyaFtJA==
+
+"@drop-oss/droplet@^1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-1.3.1.tgz#360faadadf50dbe3133ed8aadce6a077d289a48d"
+ integrity sha512-wXQof5rUiUujAiVwJovAu4Tj2Rhlb0pE/lHSEHF3ywcOLQFcSrE1naQVz3RQ7at+ZkwmDL9fFMXmGJkEKI8jog==
optionalDependencies:
- "@drop-oss/droplet-darwin-arm64" "0.7.2"
- "@drop-oss/droplet-darwin-universal" "0.7.2"
- "@drop-oss/droplet-darwin-x64" "0.7.2"
- "@drop-oss/droplet-linux-arm-gnueabihf" "0.7.2"
- "@drop-oss/droplet-linux-arm-musleabihf" "0.7.2"
- "@drop-oss/droplet-linux-arm64-gnu" "0.7.2"
- "@drop-oss/droplet-linux-arm64-musl" "0.7.2"
- "@drop-oss/droplet-linux-riscv64-gnu" "0.7.2"
- "@drop-oss/droplet-linux-x64-gnu" "0.7.2"
- "@drop-oss/droplet-linux-x64-musl" "0.7.2"
- "@drop-oss/droplet-win32-arm64-msvc" "0.7.2"
- "@drop-oss/droplet-win32-x64-msvc" "0.7.2"
+ "@drop-oss/droplet-darwin-arm64" "1.3.1"
+ "@drop-oss/droplet-darwin-universal" "1.3.1"
+ "@drop-oss/droplet-darwin-x64" "1.3.1"
+ "@drop-oss/droplet-linux-arm64-gnu" "1.3.1"
+ "@drop-oss/droplet-linux-arm64-musl" "1.3.1"
+ "@drop-oss/droplet-linux-riscv64-gnu" "1.3.1"
+ "@drop-oss/droplet-linux-x64-gnu" "1.3.1"
+ "@drop-oss/droplet-linux-x64-musl" "1.3.1"
+ "@drop-oss/droplet-win32-arm64-msvc" "1.3.1"
+ "@drop-oss/droplet-win32-x64-msvc" "1.3.1"
"@emnapi/core@^1.4.0":
version "1.4.0"
From 6b24ac79d5200db12cbc9664509f88b4316b1d8d Mon Sep 17 00:00:00 2001
From: DecDuck
Date: Sun, 1 Jun 2025 14:24:00 +1000
Subject: [PATCH 2/6] feat: update backend routes and create initializer
---
server/api/v1/admin/game/index.get.ts | 7 +-
server/api/v1/admin/import/game/index.post.ts | 67 +++++++++++--------
.../api/v1/admin/import/version/index.get.ts | 14 +++-
.../api/v1/admin/import/version/index.post.ts | 42 ++++++------
server/api/v1/client/chunk.get.ts | 39 +++++------
server/internal/library/filesystem.ts | 15 +++++
server/internal/library/index.ts | 35 +++++++---
server/internal/library/provider.ts | 10 ++-
server/internal/metadata/index.ts | 19 +++---
server/plugins/05.library-init.ts | 37 +++++++++-
10 files changed, 185 insertions(+), 100 deletions(-)
diff --git a/server/api/v1/admin/game/index.get.ts b/server/api/v1/admin/game/index.get.ts
index 5e3979df..e52a4954 100644
--- a/server/api/v1/admin/game/index.get.ts
+++ b/server/api/v1/admin/game/index.get.ts
@@ -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 };
diff --git a/server/api/v1/admin/import/game/index.post.ts b/server/api/v1/admin/import/game/index.post.ts
index 7f39f525..61233461 100644
--- a/server/api/v1/admin/import/game/index.post.ts
+++ b/server/api/v1/admin/import/game/index.post.ts
@@ -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);
+ }
+ },
+);
diff --git a/server/api/v1/admin/import/version/index.get.ts b/server/api/v1/admin/import/version/index.get.ts
index 8687b94a..893d295c 100644
--- a/server/api/v1/admin/import/version/index.get.ts
+++ b/server/api/v1/admin/import/version/index.get.ts
@@ -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" });
diff --git a/server/api/v1/admin/import/version/index.post.ts b/server/api/v1/admin/import/version/index.post.ts
index 2c6872c9..d52042b7 100644
--- a/server/api/v1/admin/import/version/index.post.ts
+++ b/server/api/v1/admin/import/version/index.post.ts
@@ -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,
diff --git a/server/api/v1/client/chunk.get.ts b/server/api/v1/client/chunk.get.ts
index b24325a9..cbb822bb 100644
--- a/server/api/v1/client/chunk.get.ts
+++ b/server/api/v1/client/chunk.get.ts
@@ -1,6 +1,4 @@
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;
@@ -23,31 +21,15 @@ export default defineEventHandler(async (h3) => {
id: gameId,
},
select: {
- libraryBasePath: true,
+ libraryId: true,
+ libraryPath: true,
},
});
- if (!game)
+ 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))
- throw createError({
- statusCode: 400,
- statusMessage: "Invalid version name",
- });
-
- const gameFile = path.join(versionDir, filename);
- if (!fs.existsSync(gameFile))
- throw createError({ statusCode: 400, statusMessage: "Invalid game file" });
-
- const gameFileStats = fs.statSync(gameFile);
-
const start = chunkIndex * chunkSize;
- const end = Math.min((chunkIndex + 1) * chunkSize, gameFileStats.size);
+ const end = chunkIndex + 1;
const currentChunkSize = end - start;
setHeader(h3, "Content-Length", currentChunkSize);
@@ -57,7 +39,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);
});
diff --git a/server/internal/library/filesystem.ts b/server/internal/library/filesystem.ts
index a34c46df..b5f1eff7 100644
--- a/server/internal/library/filesystem.ts
+++ b/server/internal/library/filesystem.ts
@@ -8,6 +8,7 @@ import { LibraryBackend } from "~/prisma/client";
import fs from "fs";
import path from "path";
import droplet from "@drop-oss/droplet";
+import type { Readable } from "stream";
const FilesystemProviderConfig = type({
baseDir: "string",
@@ -82,4 +83,18 @@ export class FilesystemProvider
);
return manifest;
}
+
+ // TODO: move this over to the droplet.readfile function it works
+ async readFile(
+ game: string,
+ version: string,
+ filename: string,
+ options?: { start?: number; end?: number },
+ ): Promise {
+ const filepath = path.join(this.config.baseDir, game, version, filename);
+ if (!fs.existsSync(filepath)) return undefined;
+ const stream = fs.createReadStream(filepath, options);
+
+ return stream;
+ }
}
diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts
index 6076583b..9537352e 100644
--- a/server/internal/library/index.ts
+++ b/server/internal/library/index.ts
@@ -5,21 +5,21 @@
* It also provides the endpoints with information about unmatched games
*/
-import fs from "fs";
import path from "path";
import prisma from "../db/database";
-import type { GameVersion } from "~/prisma/client";
import { fuzzy } from "fast-fuzzy";
-import { recursivelyReaddir } from "../utils/recursivedirs";
import taskHandler from "../tasks";
import { parsePlatform } from "../utils/parseplatform";
-import droplet from "@drop-oss/droplet";
import notificationSystem from "../notifications";
import type { LibraryProvider } from "./provider";
class LibraryManager {
private libraries: Map> = new Map();
+ addLibrary(library: LibraryProvider) {
+ this.libraries.set(library.id(), library);
+ }
+
async fetchAllUnimportedGames() {
const unimportedGames: { [key: string]: string[] } = {};
@@ -157,17 +157,22 @@ class LibraryManager {
}
// Checks are done in least to most expensive order
- async checkUnimportedGamePath(targetPath: string) {
- const targetDir = path.join(this.basePath, targetPath);
- if (!fs.existsSync(targetDir)) return false;
-
+ async checkUnimportedGamePath(libraryId: string, libraryPath: string) {
const hasGame =
- (await prisma.game.count({ where: { libraryBasePath: targetPath } })) > 0;
+ (await prisma.game.count({ where: { libraryId, libraryPath } })) > 0;
if (hasGame) return false;
return true;
}
+ /*
+ Game creation happens in metadata, because it's primarily a metadata object
+
+ async createGame(libraryId: string, libraryPath: string, game: Omit) {
+
+ }
+ */
+
async importVersion(
gameId: string,
versionName: string,
@@ -277,6 +282,18 @@ class LibraryManager {
return taskId;
}
+
+ async readFile(
+ libraryId: string,
+ game: string,
+ version: string,
+ filename: string,
+ options?: { start?: number; end?: number },
+ ) {
+ const library = this.libraries.get(libraryId);
+ if (!library) return undefined;
+ return library.readFile(game, version, filename, options);
+ }
}
export const libraryManager = new LibraryManager();
diff --git a/server/internal/library/provider.ts b/server/internal/library/provider.ts
index 3b9996e4..ac446d9c 100644
--- a/server/internal/library/provider.ts
+++ b/server/internal/library/provider.ts
@@ -1,3 +1,4 @@
+import type { Readable } from "stream";
import type { LibraryBackend } from "~/prisma/client";
export abstract class LibraryProvider {
@@ -44,7 +45,14 @@ export abstract class LibraryProvider {
progress: (err: Error | null, v: number) => void,
log: (err: Error | null, v: string) => void,
): Promise;
+
+ abstract readFile(
+ game: string,
+ version: string,
+ filename: string,
+ options?: { start?: number; end?: number },
+ ): Promise;
}
export class GameNotFoundError extends Error {}
-export class VersionNotFoundError extends Error {}
\ No newline at end of file
+export class VersionNotFoundError extends Error {}
diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts
index cd3bd6a9..6551bb34 100644
--- a/server/internal/metadata/index.ts
+++ b/server/internal/metadata/index.ts
@@ -97,18 +97,15 @@ export class MetadataHandler {
return successfulResults;
}
- async createGameWithoutMetadata(libraryBasePath: string) {
+ async createGameWithoutMetadata(libraryId: string, libraryPath: string) {
return await this.createGame(
{
id: "",
- name: libraryBasePath,
- icon: "",
- description: "",
- year: 0,
+ name: libraryPath,
sourceId: "manual",
- sourceName: "Manual",
},
- libraryBasePath,
+ libraryId,
+ libraryPath,
);
}
@@ -165,8 +162,9 @@ export class MetadataHandler {
}
async createGame(
- result: InternalGameMetadataResult,
- libraryBasePath: string,
+ result: { sourceId: string; id: string; name: string},
+ libraryId: string,
+ libraryPath: string,
) {
const provider = this.providers.get(result.sourceId);
if (!provider)
@@ -231,7 +229,8 @@ export class MetadataHandler {
connectOrCreate: this.parseTags(metadata.tags),
},
- libraryBasePath,
+ libraryId,
+ libraryPath,
},
});
diff --git a/server/plugins/05.library-init.ts b/server/plugins/05.library-init.ts
index 783b9718..46bc7176 100644
--- a/server/plugins/05.library-init.ts
+++ b/server/plugins/05.library-init.ts
@@ -1,3 +1,36 @@
-export default defineNitroPlugin(async (h3) => {
+import type { LibraryBackend } from "~/prisma/client";
+import prisma from "../internal/db/database";
+import type { JsonValue } from "@prisma/client/runtime/library";
+import type { LibraryProvider } from "../internal/library/provider";
+import { FilesystemProvider } from "../internal/library/filesystem";
+import libraryManager from "../internal/library";
-})
\ No newline at end of file
+const libraryConstructors: {
+ [key in LibraryBackend]: (
+ value: JsonValue,
+ id: string,
+ ) => LibraryProvider;
+} = {
+ Filesystem: function (
+ value: JsonValue,
+ id: string,
+ ): LibraryProvider {
+ return new FilesystemProvider(value, id);
+ },
+};
+
+export default defineNitroPlugin(async () => {
+ const libraries = await prisma.library.findMany({});
+
+ for (const library of libraries) {
+ const constructor = libraryConstructors[library.backend];
+ try {
+ const provider = constructor(library.options, library.id);
+ libraryManager.addLibrary(provider);
+ } catch (e) {
+ console.warn(
+ `Failed to create library (${library.id}) of type ${library.backend}:\n ${e}`,
+ );
+ }
+ }
+});
From 79b3505280f630d723814a656b8227dcd65d64d1 Mon Sep 17 00:00:00 2001
From: DecDuck
Date: Sun, 1 Jun 2025 14:29:22 +1000
Subject: [PATCH 3/6] feat: add legacy library creation
---
prisma/models/app.prisma | 4 ++--
server/internal/library/filesystem.ts | 2 +-
server/plugins/05.library-init.ts | 22 +++++++++++++++++++++-
3 files changed, 24 insertions(+), 4 deletions(-)
diff --git a/prisma/models/app.prisma b/prisma/models/app.prisma
index 8d520256..a85f9ae8 100644
--- a/prisma/models/app.prisma
+++ b/prisma/models/app.prisma
@@ -19,11 +19,11 @@ enum LibraryBackend {
}
model Library {
- id String @id
+ id String @id @default(uuid())
name String
backend LibraryBackend
options Json
-
+
games Game[]
}
diff --git a/server/internal/library/filesystem.ts b/server/internal/library/filesystem.ts
index b5f1eff7..c6314d56 100644
--- a/server/internal/library/filesystem.ts
+++ b/server/internal/library/filesystem.ts
@@ -10,7 +10,7 @@ import path from "path";
import droplet from "@drop-oss/droplet";
import type { Readable } from "stream";
-const FilesystemProviderConfig = type({
+export const FilesystemProviderConfig = type({
baseDir: "string",
});
diff --git a/server/plugins/05.library-init.ts b/server/plugins/05.library-init.ts
index 46bc7176..7fa86e0c 100644
--- a/server/plugins/05.library-init.ts
+++ b/server/plugins/05.library-init.ts
@@ -1,9 +1,11 @@
-import type { LibraryBackend } from "~/prisma/client";
+import { LibraryBackend } from "~/prisma/client";
import prisma from "../internal/db/database";
import type { JsonValue } from "@prisma/client/runtime/library";
import type { LibraryProvider } from "../internal/library/provider";
+import type { FilesystemProviderConfig } from "../internal/library/filesystem";
import { FilesystemProvider } from "../internal/library/filesystem";
import libraryManager from "../internal/library";
+import path from "path";
const libraryConstructors: {
[key in LibraryBackend]: (
@@ -22,6 +24,24 @@ const libraryConstructors: {
export default defineNitroPlugin(async () => {
const libraries = await prisma.library.findMany({});
+ // Add migration handler
+ const legacyPath = process.env.LIBRARY;
+ if (legacyPath && libraries.length == 0) {
+ const options: typeof FilesystemProviderConfig.infer = {
+ baseDir: path.resolve(legacyPath),
+ };
+
+ const library = await prisma.library.create({
+ data: {
+ name: "Auto-created",
+ backend: LibraryBackend.Filesystem,
+ options,
+ },
+ });
+
+ libraries.push(library);
+ }
+
for (const library of libraries) {
const constructor = libraryConstructors[library.backend];
try {
From 38d25fffebd966d969165c76a6b8da3b6a469b78 Mon Sep 17 00:00:00 2001
From: DecDuck
Date: Sun, 1 Jun 2025 14:40:19 +1000
Subject: [PATCH 4/6] fix: resolve frontend type errors
---
pages/admin/index.vue | 3 ++-
pages/admin/library/import.vue | 27 +++++++++++---------
pages/admin/library/index.vue | 6 ++++-
server/api/v1/admin/import/game/index.get.ts | 7 ++++-
server/internal/library/index.ts | 4 +--
5 files changed, 30 insertions(+), 17 deletions(-)
diff --git a/pages/admin/index.vue b/pages/admin/index.vue
index acb7470b..b40cc0c3 100644
--- a/pages/admin/index.vue
+++ b/pages/admin/index.vue
@@ -28,7 +28,7 @@
@@ -177,4 +177,5 @@ useHead({
});
const libraryState = await $dropFetch("/api/v1/admin/library");
+const toImport = Object.entries(libraryState.unimportedGames).length > 0;
diff --git a/pages/admin/library/import.vue b/pages/admin/library/import.vue
index 9bdff841..74054ab9 100644
--- a/pages/admin/library/import.vue
+++ b/pages/admin/library/import.vue
@@ -37,7 +37,7 @@
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
>
();
@@ -286,12 +285,12 @@ async function updateSelectedGame(value: number) {
if (currentlySelectedGame.value == value) return;
currentlySelectedGame.value = value;
if (currentlySelectedGame.value == -1) return;
- const game = games.unimportedGames[currentlySelectedGame.value];
- if (!game) return;
+ const option = games.unimportedGames[currentlySelectedGame.value];
+ if (!option) return;
metadataResults.value = undefined;
currentlySelectedMetadata.value = -1;
- gameSearchTerm.value = game;
+ gameSearchTerm.value = option.game;
await searchGame();
}
@@ -324,17 +323,21 @@ const router = useRouter();
const importLoading = ref(false);
const importError = ref();
-async function importGame(metadata: boolean) {
- if (!metadataResults.value && metadata) return;
+async function importGame(useMetadata: boolean) {
+ if (!metadataResults.value && useMetadata) return;
+
+ const metadata =
+ useMetadata && metadataResults.value
+ ? metadataResults.value[currentlySelectedMetadata.value]
+ : undefined;
+ const option = games.unimportedGames[currentlySelectedGame.value];
const game = await $dropFetch("/api/v1/admin/import/game", {
method: "POST",
body: {
- path: games.unimportedGames[currentlySelectedGame.value],
- metadata:
- metadata && metadataResults.value
- ? metadataResults.value[currentlySelectedMetadata.value]
- : undefined,
+ path: option.game,
+ library: option.library,
+ metadata,
},
});
diff --git a/pages/admin/library/index.vue b/pages/admin/library/index.vue
index 4d27b2f0..5802287e 100644
--- a/pages/admin/library/index.vue
+++ b/pages/admin/library/index.vue
@@ -15,7 +15,7 @@
@@ -186,6 +186,9 @@ useHead({
const searchQuery = ref("");
const libraryState = await $dropFetch("/api/v1/admin/library");
+
+const toImport = ref(Object.entries(libraryState.unimportedGames).length > 0);
+
const libraryGames = ref(
libraryState.games.map((e) => {
const noVersions = e.status.noVersions;
@@ -219,5 +222,6 @@ async function deleteGame(id: string) {
await $dropFetch(`/api/v1/admin/game?id=${id}`, { method: "DELETE" });
const index = libraryGames.value.findIndex((e) => e.id === id);
libraryGames.value.splice(index, 1);
+ toImport.value = true;
}
diff --git a/server/api/v1/admin/import/game/index.get.ts b/server/api/v1/admin/import/game/index.get.ts
index c913bdc9..95373ef2 100644
--- a/server/api/v1/admin/import/game/index.get.ts
+++ b/server/api/v1/admin/import/game/index.get.ts
@@ -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 };
});
diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts
index 9537352e..fdaf6cb1 100644
--- a/server/internal/library/index.ts
+++ b/server/internal/library/index.ts
@@ -89,10 +89,10 @@ class LibraryManager {
game: e,
status: {
noVersions: e.versions.length == 0,
- unimportedVersions: await this.fetchUnimportedGameVersions(
+ unimportedVersions: (await this.fetchUnimportedGameVersions(
e.libraryId ?? "",
e.libraryPath,
- ),
+ ))!,
},
})),
);
From e91bb0a6872d3209bfa6ae970489ee4439b7ed61 Mon Sep 17 00:00:00 2001
From: DecDuck
Date: Sun, 1 Jun 2025 15:00:33 +1000
Subject: [PATCH 5/6] fix: runtime errors
---
pages/admin/library/import.vue | 2 +-
server/api/v1/client/chunk.get.ts | 50 ++++++++++++++++++++-------
server/internal/library/filesystem.ts | 9 ++++-
server/internal/library/index.ts | 11 ++++++
server/internal/library/provider.ts | 6 ++++
server/plugins/05.library-init.ts | 18 ++++++++++
6 files changed, 82 insertions(+), 14 deletions(-)
diff --git a/pages/admin/library/import.vue b/pages/admin/library/import.vue
index 74054ab9..559cebc6 100644
--- a/pages/admin/library/import.vue
+++ b/pages/admin/library/import.vue
@@ -13,7 +13,7 @@
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
{{
- games.unimportedGames[currentlySelectedGame]
+ games.unimportedGames[currentlySelectedGame].game
}}
Please select a directory...("downloadGameLookupCache");
+
export default defineEventHandler(async (h3) => {
const query = getQuery(h3);
const gameId = query.id?.toString();
@@ -16,20 +22,40 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid chunk arguments",
});
- const 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" });
+ 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" });
+
+ await gameLookupCache.setItem(gameId, game);
+ }
+
+ if (!game.libraryId)
+ throw createError({
+ statusCode: 500,
+ statusMessage: "Somehow, we got here.",
+ });
+
+ 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 = chunkIndex + 1;
+ const end = Math.min((chunkIndex + 1) * chunkSize, peek.size);
const currentChunkSize = end - start;
setHeader(h3, "Content-Length", currentChunkSize);
diff --git a/server/internal/library/filesystem.ts b/server/internal/library/filesystem.ts
index c6314d56..73a4a329 100644
--- a/server/internal/library/filesystem.ts
+++ b/server/internal/library/filesystem.ts
@@ -76,7 +76,7 @@ export class FilesystemProvider
const versionDir = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
const manifest = await new Promise((r, j) =>
- droplet.generateManifest(game, progress, log, (err, result) => {
+ droplet.generateManifest(versionDir, progress, log, (err, result) => {
if (err) return j(err);
r(result);
}),
@@ -97,4 +97,11 @@ export class FilesystemProvider
return stream;
}
+
+ async peekFile(game: string, version: string, filename: string) {
+ const filepath = path.join(this.config.baseDir, game, version, filename);
+ if (!fs.existsSync(filepath)) return undefined;
+ const stat = fs.statSync(filepath);
+ return { size: stat.size };
+ }
}
diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts
index fdaf6cb1..873c0dfa 100644
--- a/server/internal/library/index.ts
+++ b/server/internal/library/index.ts
@@ -283,6 +283,17 @@ class LibraryManager {
return taskId;
}
+ async peekFile(
+ libraryId: string,
+ game: string,
+ version: string,
+ filename: string,
+ ) {
+ const library = this.libraries.get(libraryId);
+ if (!library) return undefined;
+ return library.peekFile(game, version, filename);
+ }
+
async readFile(
libraryId: string,
game: string,
diff --git a/server/internal/library/provider.ts b/server/internal/library/provider.ts
index ac446d9c..b7d9ea91 100644
--- a/server/internal/library/provider.ts
+++ b/server/internal/library/provider.ts
@@ -46,6 +46,12 @@ export abstract class LibraryProvider {
log: (err: Error | null, v: string) => void,
): Promise;
+ abstract peekFile(
+ game: string,
+ version: string,
+ filename: string,
+ ): Promise<{ size: number } | undefined>;
+
abstract readFile(
game: string,
version: string,
diff --git a/server/plugins/05.library-init.ts b/server/plugins/05.library-init.ts
index 7fa86e0c..7c40105c 100644
--- a/server/plugins/05.library-init.ts
+++ b/server/plugins/05.library-init.ts
@@ -22,6 +22,7 @@ const libraryConstructors: {
};
export default defineNitroPlugin(async () => {
+ let successes = 0;
const libraries = await prisma.library.findMany({});
// Add migration handler
@@ -40,6 +41,16 @@ export default defineNitroPlugin(async () => {
});
libraries.push(library);
+
+ // Update all existing games
+ await prisma.game.updateMany({
+ where: {
+ libraryId: null,
+ },
+ data: {
+ libraryId: library.id,
+ },
+ });
}
for (const library of libraries) {
@@ -47,10 +58,17 @@ export default defineNitroPlugin(async () => {
try {
const provider = constructor(library.options, library.id);
libraryManager.addLibrary(provider);
+ successes++;
} catch (e) {
console.warn(
`Failed to create library (${library.id}) of type ${library.backend}:\n ${e}`,
);
}
}
+
+ if (successes == 0) {
+ console.warn(
+ "No library was successfully initialised. Please check for errors. If you have just set up an instance, this is normal.",
+ );
+ }
});
From 62e722cc7b522561cac8a5c9e267220288d88c1c Mon Sep 17 00:00:00 2001
From: DecDuck
Date: Sun, 1 Jun 2025 15:00:58 +1000
Subject: [PATCH 6/6] fix: lint
---
pages/admin/index.vue | 5 +----
pages/admin/library/index.vue | 5 +----
server/api/v1/admin/game/metadata.post.ts | 1 -
server/arktype.ts | 2 +-
server/internal/consts.ts | 2 +-
server/internal/metadata/igdb.ts | 2 +-
server/internal/metadata/index.ts | 2 +-
7 files changed, 6 insertions(+), 13 deletions(-)
diff --git a/pages/admin/index.vue b/pages/admin/index.vue
index b40cc0c3..60dac719 100644
--- a/pages/admin/index.vue
+++ b/pages/admin/index.vue
@@ -27,10 +27,7 @@
-
+
-
+
{
const allowed = await aclManager.allowSystemACL(h3, ["game:update"]);
if (!allowed) throw createError({ statusCode: 403 });
diff --git a/server/arktype.ts b/server/arktype.ts
index 8acffd95..667c3d64 100644
--- a/server/arktype.ts
+++ b/server/arktype.ts
@@ -2,7 +2,7 @@ import { configure } from "arktype/config";
export const throwingArktype = configure({
onFail: (errors) => errors.throw(),
- actual: () => ""
+ actual: () => "",
});
// be sure to specify both the runtime and static configs
diff --git a/server/internal/consts.ts b/server/internal/consts.ts
index d44c4b7d..63e6284d 100644
--- a/server/internal/consts.ts
+++ b/server/internal/consts.ts
@@ -1 +1 @@
-export const DROP_VERSION = "0.3.0";
\ No newline at end of file
+export const DROP_VERSION = "0.3.0";
diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts
index c9de6e9c..505b09b9 100644
--- a/server/internal/metadata/igdb.ts
+++ b/server/internal/metadata/igdb.ts
@@ -356,7 +356,7 @@ export class IGDBProvider implements MetadataProvider {
for (let i = 0; i < response.length; i++) {
const currentGame = response[i];
- if(!currentGame) continue;
+ if (!currentGame) continue;
let iconRaw;
const cover = currentGame.cover;
diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts
index 6551bb34..8c0e33ec 100644
--- a/server/internal/metadata/index.ts
+++ b/server/internal/metadata/index.ts
@@ -162,7 +162,7 @@ export class MetadataHandler {
}
async createGame(
- result: { sourceId: string; id: string; name: string},
+ result: { sourceId: string; id: string; name: string },
libraryId: string,
libraryPath: string,
) {