diff --git a/prisma/migrations/20250819230647_add_redist_objects/migration.sql b/prisma/migrations/20250819230647_add_redist_objects/migration.sql new file mode 100644 index 0000000..1284692 --- /dev/null +++ b/prisma/migrations/20250819230647_add_redist_objects/migration.sql @@ -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; diff --git a/prisma/migrations/20250820000344_add_library_key/migration.sql b/prisma/migrations/20250820000344_add_library_key/migration.sql new file mode 100644 index 0000000..b1027c6 --- /dev/null +++ b/prisma/migrations/20250820000344_add_library_key/migration.sql @@ -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"); diff --git a/prisma/models/app.prisma b/prisma/models/app.prisma index 338af50..49f5925 100644 --- a/prisma/models/app.prisma +++ b/prisma/models/app.prisma @@ -28,5 +28,6 @@ model Library { backend LibraryBackend options Json - games Game[] + games Game[] + redists Redist[] } diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index 3ab28be..e1830b5 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -1,184 +1,96 @@ -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 model GameVersion { - gameId String - game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) - versionName String // Sub directory for the game files + gameId String + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + versionName String // Sub directory for the game files - created DateTime @default(now()) + created DateTime @default(now()) - platform Platform + platform Platform - launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine - launchArgs String[] - setupCommand String @default("") // Command to setup game (dependencies and such) - setupArgs String[] - onlySetup Boolean @default(false) + launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine + launchArgs String[] + setupCommand String @default("") // Command to setup game (dependencies and such) + setupArgs String[] + onlySetup Boolean @default(false) - umuIdOverride String? + umuIdOverride String? - dropletManifest Json // Results from droplet + dropletManifest Json // Results from droplet - versionIndex Int - delta Boolean @default(false) + versionIndex Int + delta Boolean @default(false) - @@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 model SaveSlot { - gameId String - game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - index Int + gameId String + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + index Int - createdAt DateTime @default(now()) - playtime Float @default(0) // hours + createdAt DateTime @default(now()) + playtime Float @default(0) // hours - lastUsedClientId String? - lastUsedClient Client? @relation(fields: [lastUsedClientId], references: [id]) + lastUsedClientId String? + lastUsedClient Client? @relation(fields: [lastUsedClientId], references: [id]) - historyObjectIds String[] // list of objects - historyChecksums String[] // list of hashes + historyObjectIds String[] // list of objects + historyChecksums String[] // list of hashes - @@id([gameId, userId, index], name: "id") + @@id([gameId, userId, index], name: "id") } model Screenshot { - id String @id @default(uuid()) + id String @id @default(uuid()) - gameId String - game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + gameId String + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - objectId String - private Boolean // if other users can see + objectId String + private Boolean // if other users can see - createdAt DateTime @default(now()) @db.Timestamptz(0) + createdAt DateTime @default(now()) @db.Timestamptz(0) - @@index([gameId, userId]) - @@index([userId]) + @@index([gameId, userId]) + @@index([userId]) } model Playtime { - gameId String - game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + gameId String + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - seconds Int // seconds user has spent playing the game + seconds Int // seconds user has spent playing the game - updatedAt DateTime @updatedAt @db.Timestamptz(6) - createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) - @@id([gameId, 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") + @@id([gameId, userId]) + @@index([userId]) } model ObjectHash { - id String @id - hash String + id String @id + hash String } diff --git a/prisma/models/metadata.prisma b/prisma/models/metadata.prisma new file mode 100644 index 0000000..9153558 --- /dev/null +++ b/prisma/models/metadata.prisma @@ -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") +} diff --git a/server/internal/library/REDIST-README.md b/server/internal/library/REDIST-README.md new file mode 100644 index 0000000..739a66d --- /dev/null +++ b/server/internal/library/REDIST-README.md @@ -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. \ No newline at end of file diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 0a74dfe..236a913 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -13,7 +13,6 @@ import { parsePlatform } from "../utils/parseplatform"; import notificationSystem from "../notifications"; import { GameNotFoundError, type LibraryProvider } from "./provider"; import { logger } from "../logging"; -import type { GameModel } from "~/prisma/client/models"; import { createHash } from "node:crypto"; export function createGameImportTaskId(libraryId: string, libraryPath: string) { @@ -49,14 +48,15 @@ class LibraryManager { } async fetchGamesByLibrary() { - const results: { [key: string]: { [key: string]: GameModel } } = {}; + const results: { [key: string]: { [key: string]: boolean } } = {}; const games = await prisma.game.findMany({}); - for (const game of games) { - const libraryId = game.libraryId!; - const libraryPath = game.libraryPath!; + const redist = await prisma.redist.findMany({}); + for (const item of [...games, ...redist]) { + const libraryId = item.libraryId!; + const libraryPath = item.libraryPath!; results[libraryId] ??= {}; - results[libraryId][libraryPath] = game; + results[libraryId][libraryPath] = true; } return results; @@ -82,18 +82,31 @@ class LibraryManager { async fetchUnimportedGameVersions(libraryId: string, libraryPath: string) { const provider = this.libraries.get(libraryId); if (!provider) return undefined; - const game = await prisma.game.findUnique({ - where: { - libraryKey: { - libraryId, - libraryPath, + const game = + (await prisma.game.findUnique({ + where: { + libraryKey: { + libraryId, + libraryPath, + }, }, - }, - select: { - id: true, - versions: true, - }, - }); + select: { + id: true, + versions: true, + }, + })) ?? + (await prisma.redist.findUnique({ + where: { + libraryKey: { + libraryId, + libraryPath, + }, + }, + select: { + id: true, + versions: true, + }, + })); if (!game) return undefined; try { @@ -217,6 +230,10 @@ class LibraryManager { })) > 0; if (hasGame) return false; + const hasRedist = + (await prisma.redist.count({ where: { libraryId, libraryPath } })) > 0; + if (hasRedist) return false; + return true; } @@ -286,41 +303,23 @@ class LibraryManager { }); // Then, create the database object - if (metadata.onlySetup) { - await prisma.gameVersion.create({ - data: { - gameId: gameId, - versionName: versionName, - dropletManifest: manifest, - versionIndex: currentIndex, - delta: metadata.delta, - umuIdOverride: metadata.umuId, - platform: platform, + await prisma.gameVersion.create({ + data: { + gameId: gameId, + versionName: versionName, + dropletManifest: manifest, + versionIndex: currentIndex, + delta: metadata.delta, + umuIdOverride: metadata.umuId, + platform: platform, - onlySetup: true, - 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, - setupArgs: metadata.setupArgs.split(" "), - launchCommand: metadata.launch, - launchArgs: metadata.launchArgs.split(" "), - }, - }); - } + onlySetup: metadata.onlySetup, + setupCommand: metadata.setup, + setupArgs: metadata.setupArgs.split(" "), + launchCommand: metadata.launch, + launchArgs: metadata.launchArgs.split(" "), + }, + }); logger.info("Successfully created version!"); diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 88025e8..ac0381d 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -18,7 +18,7 @@ import taskHandler, { wrapTaskContext } from "../tasks"; import { randomUUID } from "crypto"; import { fuzzy } from "fast-fuzzy"; import { logger } from "~/server/internal/logging"; -import { createGameImportTaskId } from "../library"; +import libraryManager, { createGameImportTaskId } from "../library"; import type { GameTagModel } from "~/prisma/client/models"; export class MissingMetadataProviderConfig extends Error { @@ -175,15 +175,8 @@ export class MetadataHandler { if (!provider) throw new Error(`Invalid metadata provider for ID "${result.sourceId}"`); - const existing = await prisma.game.findUnique({ - where: { - metadataKey: { - metadataSource: provider.source(), - metadataId: result.id, - }, - }, - }); - if (existing) return undefined; + const okay = await libraryManager.checkUnimportedGamePath(libraryId, libraryPath); + if (!okay) return undefined; const gameId = randomUUID();