mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-13 00:02:37 +10:00
feat: database redist support
This commit is contained in:
@ -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;
|
||||
@ -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");
|
||||
@ -28,5 +28,6 @@ model Library {
|
||||
backend LibraryBackend
|
||||
options Json
|
||||
|
||||
games Game[]
|
||||
games Game[]
|
||||
redists Redist[]
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
117
prisma/models/metadata.prisma
Normal file
117
prisma/models/metadata.prisma
Normal 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")
|
||||
}
|
||||
3
server/internal/library/REDIST-README.md
Normal file
3
server/internal/library/REDIST-README.md
Normal 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.
|
||||
@ -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!");
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user