feat: database redist support

This commit is contained in:
DecDuck
2025-08-20 11:50:59 +10:00
parent 6d89b7e510
commit 6853383e86
8 changed files with 286 additions and 212 deletions

View File

@ -0,0 +1,35 @@
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- CreateTable
CREATE TABLE "RedistVersion" (
"redistId" TEXT NOT NULL,
"versionName" TEXT NOT NULL,
"created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"platform" "Platform" NOT NULL,
"dropletManifest" JSONB NOT NULL,
CONSTRAINT "RedistVersion_pkey" PRIMARY KEY ("redistId","versionName")
);
-- CreateTable
CREATE TABLE "Redist" (
"id" TEXT NOT NULL,
"created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"iconObjectId" TEXT NOT NULL,
"libraryId" TEXT NOT NULL,
"libraryPath" TEXT NOT NULL,
CONSTRAINT "Redist_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
-- AddForeignKey
ALTER TABLE "RedistVersion" ADD CONSTRAINT "RedistVersion_redistId_fkey" FOREIGN KEY ("redistId") REFERENCES "Redist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Redist" ADD CONSTRAINT "Redist_libraryId_fkey" FOREIGN KEY ("libraryId") REFERENCES "Library"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,14 @@
/*
Warnings:
- A unique constraint covering the columns `[libraryId,libraryPath]` on the table `Redist` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
-- CreateIndex
CREATE UNIQUE INDEX "Redist_libraryId_libraryPath_key" ON "Redist"("libraryId", "libraryPath");

View File

@ -29,4 +29,5 @@ model Library {
options Json options Json
games Game[] games Game[]
redists Redist[]
} }

View File

@ -1,85 +1,3 @@
enum MetadataSource {
Manual
GiantBomb
PCGamingWiki
IGDB
Metacritic
OpenCritic
}
model Game {
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
created DateTime @default(now())
// Any field prefixed with m is filled in from metadata
// Acts as a cache so we can search and filter it
mName String // Name of game
mShortDescription String // Short description
mDescription String // Supports markdown
mReleased DateTime // When the game was released
ratings GameRating[]
featured Boolean @default(false)
mIconObjectId String // linked to objects in s3
mBannerObjectId String // linked to objects in s3
mCoverObjectId String
mImageCarouselObjectIds String[] // linked to below array
mImageLibraryObjectIds String[] // linked to objects in s3
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], onDelete: Cascade, onUpdate: Cascade)
libraryPath String
collections CollectionEntry[]
saves SaveSlot[]
screenshots Screenshot[]
tags GameTag[]
playtime Playtime[]
developers Company[] @relation(name: "developers")
publishers Company[] @relation(name: "publishers")
@@unique([metadataSource, metadataId], name: "metadataKey")
@@unique([libraryId, libraryPath], name: "libraryKey")
}
model GameTag {
id String @id @default(uuid())
name String @unique
games Game[]
@@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist)
}
model GameRating {
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
created DateTime @default(now())
mReviewCount Int
mReviewRating Float // 0 to 1
mReviewHref String?
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
gameId String
@@unique([metadataSource, metadataId], name: "metadataKey")
}
// A particular set of files that relate to the version // A particular set of files that relate to the version
model GameVersion { model GameVersion {
gameId String gameId String
@ -106,6 +24,20 @@ model GameVersion {
@@id([gameId, versionName]) @@id([gameId, versionName])
} }
model RedistVersion {
redistId String
redist Redist @relation(fields: [redistId], references: [id], onDelete: Cascade)
versionName String
created DateTime @default(now())
platform Platform
dropletManifest Json // Results from droplet
@@id([redistId, versionName])
}
// A save slot for a game // A save slot for a game
model SaveSlot { model SaveSlot {
gameId String gameId String
@ -158,26 +90,6 @@ model Playtime {
@@index([userId]) @@index([userId])
} }
model Company {
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
metadataOriginalQuery String
mName String
mShortDescription String
mDescription String
mLogoObjectId String
mBannerObjectId String
mWebsite String
developed Game[] @relation(name: "developers")
published Game[] @relation(name: "publishers")
@@unique([metadataSource, metadataId], name: "metadataKey")
}
model ObjectHash { model ObjectHash {
id String @id id String @id
hash String hash String

View File

@ -0,0 +1,117 @@
enum MetadataSource {
Manual
GiantBomb
PCGamingWiki
IGDB
Metacritic
OpenCritic
}
model Game {
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
created DateTime @default(now())
// Any field prefixed with m is filled in from metadata
// Acts as a cache so we can search and filter it
mName String // Name of game
mShortDescription String // Short description
mDescription String // Supports markdown
mReleased DateTime // When the game was released
ratings GameRating[]
featured Boolean @default(false)
mIconObjectId String // linked to objects in s3
mBannerObjectId String // linked to objects in s3
mCoverObjectId String
mImageCarouselObjectIds String[] // linked to below array
mImageLibraryObjectIds String[] // linked to objects in s3
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], onDelete: Cascade, onUpdate: Cascade)
libraryPath String
collections CollectionEntry[]
saves SaveSlot[]
screenshots Screenshot[]
tags GameTag[]
playtime Playtime[]
developers Company[] @relation(name: "developers")
publishers Company[] @relation(name: "publishers")
@@unique([metadataSource, metadataId], name: "metadataKey")
@@unique([libraryId, libraryPath], name: "libraryKey")
}
model Redist {
id String @id @default(uuid())
created DateTime @default(now())
name String
description String
iconObjectId String
libraryId String
library Library @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade)
libraryPath String
versions RedistVersion[]
@@unique([libraryId, libraryPath], name: "libraryKey")
}
model GameTag {
id String @id @default(uuid())
name String @unique
games Game[]
@@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist)
}
model GameRating {
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
created DateTime @default(now())
mReviewCount Int
mReviewRating Float // 0 to 1
mReviewHref String?
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
gameId String
@@unique([metadataSource, metadataId], name: "metadataKey")
}
model Company {
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
metadataOriginalQuery String
mName String
mShortDescription String
mDescription String
mLogoObjectId String
mBannerObjectId String
mWebsite String
developed Game[] @relation(name: "developers")
published Game[] @relation(name: "publishers")
@@unique([metadataSource, metadataId], name: "metadataKey")
}

View File

@ -0,0 +1,3 @@
# Redistributables
They are called 'redist' in the codebase/database models for brevity. Additionally, because they intentionally are drop-ins for games, they are added to all the functions that deal with games, without changing the names.

View File

@ -13,7 +13,6 @@ import { parsePlatform } from "../utils/parseplatform";
import notificationSystem from "../notifications"; import notificationSystem from "../notifications";
import { GameNotFoundError, type LibraryProvider } from "./provider"; import { GameNotFoundError, type LibraryProvider } from "./provider";
import { logger } from "../logging"; import { logger } from "../logging";
import type { GameModel } from "~/prisma/client/models";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
export function createGameImportTaskId(libraryId: string, libraryPath: string) { export function createGameImportTaskId(libraryId: string, libraryPath: string) {
@ -49,14 +48,15 @@ class LibraryManager {
} }
async fetchGamesByLibrary() { async fetchGamesByLibrary() {
const results: { [key: string]: { [key: string]: GameModel } } = {}; const results: { [key: string]: { [key: string]: boolean } } = {};
const games = await prisma.game.findMany({}); const games = await prisma.game.findMany({});
for (const game of games) { const redist = await prisma.redist.findMany({});
const libraryId = game.libraryId!; for (const item of [...games, ...redist]) {
const libraryPath = game.libraryPath!; const libraryId = item.libraryId!;
const libraryPath = item.libraryPath!;
results[libraryId] ??= {}; results[libraryId] ??= {};
results[libraryId][libraryPath] = game; results[libraryId][libraryPath] = true;
} }
return results; return results;
@ -82,7 +82,8 @@ class LibraryManager {
async fetchUnimportedGameVersions(libraryId: string, libraryPath: string) { async fetchUnimportedGameVersions(libraryId: string, libraryPath: string) {
const provider = this.libraries.get(libraryId); const provider = this.libraries.get(libraryId);
if (!provider) return undefined; if (!provider) return undefined;
const game = await prisma.game.findUnique({ const game =
(await prisma.game.findUnique({
where: { where: {
libraryKey: { libraryKey: {
libraryId, libraryId,
@ -93,7 +94,19 @@ class LibraryManager {
id: true, id: true,
versions: true, versions: true,
}, },
}); })) ??
(await prisma.redist.findUnique({
where: {
libraryKey: {
libraryId,
libraryPath,
},
},
select: {
id: true,
versions: true,
},
}));
if (!game) return undefined; if (!game) return undefined;
try { try {
@ -217,6 +230,10 @@ class LibraryManager {
})) > 0; })) > 0;
if (hasGame) return false; if (hasGame) return false;
const hasRedist =
(await prisma.redist.count({ where: { libraryId, libraryPath } })) > 0;
if (hasRedist) return false;
return true; return true;
} }
@ -286,7 +303,6 @@ class LibraryManager {
}); });
// Then, create the database object // Then, create the database object
if (metadata.onlySetup) {
await prisma.gameVersion.create({ await prisma.gameVersion.create({
data: { data: {
gameId: gameId, gameId: gameId,
@ -297,30 +313,13 @@ class LibraryManager {
umuIdOverride: metadata.umuId, umuIdOverride: metadata.umuId,
platform: platform, platform: platform,
onlySetup: true, onlySetup: metadata.onlySetup,
setupCommand: metadata.setup,
setupArgs: metadata.setupArgs.split(" "),
},
});
} else {
await prisma.gameVersion.create({
data: {
gameId: gameId,
versionName: versionName,
dropletManifest: manifest,
versionIndex: currentIndex,
delta: metadata.delta,
umuIdOverride: metadata.umuId,
platform: platform,
onlySetup: false,
setupCommand: metadata.setup, setupCommand: metadata.setup,
setupArgs: metadata.setupArgs.split(" "), setupArgs: metadata.setupArgs.split(" "),
launchCommand: metadata.launch, launchCommand: metadata.launch,
launchArgs: metadata.launchArgs.split(" "), launchArgs: metadata.launchArgs.split(" "),
}, },
}); });
}
logger.info("Successfully created version!"); logger.info("Successfully created version!");

View File

@ -18,7 +18,7 @@ import taskHandler, { wrapTaskContext } from "../tasks";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { fuzzy } from "fast-fuzzy"; import { fuzzy } from "fast-fuzzy";
import { logger } from "~/server/internal/logging"; import { logger } from "~/server/internal/logging";
import { createGameImportTaskId } from "../library"; import libraryManager, { createGameImportTaskId } from "../library";
import type { GameTagModel } from "~/prisma/client/models"; import type { GameTagModel } from "~/prisma/client/models";
export class MissingMetadataProviderConfig extends Error { export class MissingMetadataProviderConfig extends Error {
@ -175,15 +175,8 @@ export class MetadataHandler {
if (!provider) if (!provider)
throw new Error(`Invalid metadata provider for ID "${result.sourceId}"`); throw new Error(`Invalid metadata provider for ID "${result.sourceId}"`);
const existing = await prisma.game.findUnique({ const okay = await libraryManager.checkUnimportedGamePath(libraryId, libraryPath);
where: { if (!okay) return undefined;
metadataKey: {
metadataSource: provider.source(),
metadataId: result.id,
},
},
});
if (existing) return undefined;
const gameId = randomUUID(); const gameId = randomUUID();