feat: beginnings of platform & redist management

This commit is contained in:
DecDuck
2025-08-27 19:52:36 +10:00
parent d323816b9e
commit ca7a89bbcf
17 changed files with 286 additions and 125 deletions

View File

@ -129,7 +129,7 @@
<LoadingButton <LoadingButton
class="w-fit" class="w-fit"
:loading="props.loading" :loading="props.loading"
:disabled="!(name && description && currentFileObjectUrl)" :disabled="buttonDisabled"
@click="() => importRedist()" @click="() => importRedist()"
> >
{{ $t("library.admin.import.import") }} {{ $t("library.admin.import.import") }}
@ -178,6 +178,8 @@ const platform = ref<{ name: string; icon: string; fileExts: string[] }>({
fileExts: [], fileExts: [],
}); });
const buttonDisabled = computed<boolean>(() => !(name.value && description.value && currentFileObjectUrl.value && (!isPlatform.value || (platform.value.name && platform.value.icon))))
function addFile(event: Event) { function addFile(event: Event) {
const file = (event.target as HTMLInputElement)?.files?.[0]; const file = (event.target as HTMLInputElement)?.files?.[0];
if (!file) return; if (!file) return;

View File

@ -8,6 +8,8 @@ const props = defineProps({
}, },
}); });
await updateUser();
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const user = useUser(); const user = useUser();

View File

@ -281,8 +281,8 @@
"addGames": "All Games", "addGames": "All Games",
"addToLib": "Add to Library", "addToLib": "Add to Library",
"admin": { "admin": {
"detectedGame": "Drop has detected you have new games to import.", "detectedGame": "Drop has detected you have new items to import.",
"detectedVersion": "Drop has detected you have new versions of this game to import.", "detectedVersion": "Drop has detected you have new versions to import.",
"game": { "game": {
"addCarouselNoImages": "No images to add.", "addCarouselNoImages": "No images to add.",
"addDescriptionNoImages": "No images to add.", "addDescriptionNoImages": "No images to add.",
@ -429,7 +429,7 @@
"title": "Libraries", "title": "Libraries",
"version": { "version": {
"delta": "Upgrade mode", "delta": "Upgrade mode",
"noVersions": "You have no versions of this game available.", "noVersions": "No versions available.",
"noVersionsAdded": "no versions added" "noVersionsAdded": "no versions added"
}, },
"versionPriority": "Version priority" "versionPriority": "Version priority"

View File

@ -37,7 +37,6 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"cookie-es": "^2.0.0", "cookie-es": "^2.0.0",
"dompurify": "^3.2.6",
"fast-fuzzy": "^1.12.0", "fast-fuzzy": "^1.12.0",
"file-type-mime": "^0.4.3", "file-type-mime": "^0.4.3",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",

View File

@ -318,12 +318,15 @@ async function importRedist(data: object, platform: object | undefined) {
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
formData.append( formData.append(
key, key,
typeof value === "object" ? JSON.stringify(value) : value, value,
); );
} }
if (platform) { if (platform) {
for (const [key, value] of Object.entries(platform)) { for (const [key, value] of Object.entries(platform)) {
// Because we know there will be no file, and we need to handle more complex objects for
// the platform, we do this.
// Maybe we shouldn't.
formData.append( formData.append(
`platform.${key}`, `platform.${key}`,
typeof value === "object" ? JSON.stringify(value) : value, typeof value === "object" ? JSON.stringify(value) : value,

View File

@ -71,41 +71,59 @@
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>
<div class="flex gap-x-4 text-zinc-300 font-bold uppercase font-display text-sm">
<span class="inline-flex items-center gap-x-1"
><div class="size-2 rounded-full bg-blue-600" />
Game</span
>
<span class="inline-flex items-center gap-x-1"
><div class="size-2 rounded-full bg-emerald-600" />
Redistributable</span
>
</div>
<ul <ul
role="list" role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4" class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
> >
<li <li
v-for="game in filteredLibraryGames" v-for="entry in filteredLibrary"
:key="game.id" :key="entry.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group" class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
> >
<div class="flex flex-1 flex-row p-4 gap-x-4"> <div
v-if="entry.type === 'game'"
class="relative flex flex-1 flex-row p-4 gap-x-4"
>
<div
class="absolute top-0 right-0 w-10 bg-blue-600 h-4 rotate-[45deg] translate-x-1/2"
/>
<img <img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800" class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(game.mIconObjectId)" :src="useObject(entry.mIconObjectId)"
alt="" alt=""
/> />
<div class="flex flex-col"> <div class="flex flex-col">
<h3 <h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display" class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
> >
{{ game.mName }} {{ entry.mName }}
<button <button
type="button" type="button"
:class="[ :class="[
'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2', 'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2',
game.featured entry.featured
? 'bg-yellow-400 hover:bg-yellow-300 focus-visible:outline-yellow-400 text-zinc-900' ? 'bg-yellow-400 hover:bg-yellow-300 focus-visible:outline-yellow-400 text-zinc-900'
: 'bg-zinc-800 hover:bg-zinc-700 focus-visible:outline-zinc-400 text-white', : 'bg-zinc-800 hover:bg-zinc-700 focus-visible:outline-zinc-400 text-white',
]" ]"
@click="() => featureGame(game.id)" @click="() => featureGame(entry.id)"
> >
<svg <svg
v-if="gameFeatureLoading[game.id]" v-if="gameFeatureLoading[entry.id]"
aria-hidden="true" aria-hidden="true"
:class="[ :class="[
game.featured ? ' fill-zinc-900' : 'fill-zinc-100', entry.featured ? ' fill-zinc-900' : 'fill-zinc-100',
'size-3 text-transparent animate-spin', 'size-3 text-transparent animate-spin',
]" ]"
viewBox="0 0 100 101" viewBox="0 0 100 101"
@ -126,13 +144,13 @@
</button> </button>
<span <span
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20" class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
>{{ game.library!.name }}</span >{{ entry.library.name }}</span
> >
</h3> </h3>
<dl class="mt-1 flex flex-col justify-between"> <dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt> <dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400"> <dd class="text-sm text-zinc-400">
{{ game.mShortDescription }} {{ entry.mShortDescription }}
</dd> </dd>
<dt class="sr-only"> <dt class="sr-only">
{{ $t("library.admin.metadataProvider") }} {{ $t("library.admin.metadataProvider") }}
@ -140,7 +158,7 @@
</dl> </dl>
<div class="mt-4 flex flex-col gap-y-1"> <div class="mt-4 flex flex-col gap-y-1">
<NuxtLink <NuxtLink
:href="`/admin/library/g/${game.id}`" :href="`/admin/library/g/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
> >
<i18n-t <i18n-t
@ -155,16 +173,79 @@
</NuxtLink> </NuxtLink>
<button <button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteGame(game.id)" @click="() => deleteGame(entry.id)"
> >
{{ $t("delete") }} {{ $t("delete") }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div v-if="game.hasNotifications" class="flex flex-col gap-y-2 p-2"> <div
v-else-if="entry.type === 'redist'"
class="relative flex flex-1 flex-row p-4 gap-x-4"
>
<div <div
v-if="game.notifications.toImport" class="absolute top-0 right-0 w-10 bg-emerald-600 h-4 rotate-[45deg] translate-x-1/2"
/>
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(entry.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ entry.mName }}
<span
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
>{{ entry.library.name }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ entry.mShortDescription }}
</dd>
</dl>
<dl
v-if="entry.platform"
class="mt-2 flex items-center text-zinc-200 font-semibold text-sm gap-x-1 p-2 bg-zinc-900 rounded-xl"
>
<IconsPlatform
:platform="entry.platform.id"
:fallback="entry.platform.iconSvg"
class="size-6 text-blue-600"
/>
<span>{{ entry.platform.platformName }}</span>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/r/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteRedist(entry.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
<div v-if="entry.hasNotifications" class="flex flex-col gap-y-2 p-2">
<div
v-if="entry.notifications.toImport"
class="rounded-md bg-blue-600/10 p-4" class="rounded-md bg-blue-600/10 p-4"
> >
<div class="flex"> <div class="flex">
@ -180,7 +261,7 @@
</p> </p>
<p class="mt-3 text-sm md:ml-6 md:mt-0"> <p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink <NuxtLink
:href="`/admin/library/g/${game.id}/import`" :href="`/admin/library/g/${entry.id}/import`"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500" class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
> >
<i18n-t <i18n-t
@ -198,7 +279,7 @@
</div> </div>
</div> </div>
<div <div
v-if="game.notifications.noVersions" v-if="entry.notifications.noVersions"
class="rounded-md bg-yellow-600/10 p-4" class="rounded-md bg-yellow-600/10 p-4"
> >
<div class="flex"> <div class="flex">
@ -216,7 +297,7 @@
</div> </div>
</div> </div>
<div <div
v-if="game.notifications.offline" v-if="entry.notifications.offline"
class="rounded-md bg-red-600/10 p-4" class="rounded-md bg-red-600/10 p-4"
> >
<div class="flex"> <div class="flex">
@ -236,14 +317,14 @@
</div> </div>
</li> </li>
<p <p
v-if="filteredLibraryGames.length == 0 && libraryGames.length != 0" v-if="filteredLibrary.length == 0 && libraryGames.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4" class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
> >
{{ $t("common.noResults") }} {{ $t("common.noResults") }}
</p> </p>
<p <p
v-if=" v-if="
filteredLibraryGames.length == 0 && filteredLibrary.length == 0 &&
libraryGames.length == 0 && libraryGames.length == 0 &&
libraryState.hasLibraries libraryState.hasLibraries
" "
@ -305,29 +386,33 @@ useHead({
const searchQuery = ref(""); const searchQuery = ref("");
const libraryState = await $dropFetch("/api/v1/admin/library"); const libraryState = await $dropFetch("/api/v1/admin/library");
type LibraryStateGame = (typeof libraryState.games)[number]["game"];
const toImport = ref( const toImport = ref(
Object.values(libraryState.unimportedGames).flat().length > 0, Object.values(libraryState.unimportedGames).flat().length > 0,
); );
const libraryGames = ref< // Potentially make a server-side transformation to make the client lighter
Array< function clientSideTransformation<T, V extends keyof T, K extends string>(
LibraryStateGame & { values: Array<T & { status: (typeof libraryState.games)[number]["status"] }>,
status: "online" | "offline"; expand: V,
hasNotifications?: boolean; type: K,
notifications: { ): Array<
noVersions?: boolean; T[V] & {
toImport?: boolean; status: "online" | "offline";
offline?: boolean; type: K;
}; hasNotifications?: boolean;
} notifications: {
> noVersions?: boolean;
>( toImport?: boolean;
libraryState.games.map((e) => { offline?: boolean;
};
}
> {
return values.map((e) => {
if (e.status == "offline") { if (e.status == "offline") {
return { return {
...e.game, ...e[expand],
type: type,
status: "offline" as const, status: "offline" as const,
hasNotifications: true, hasNotifications: true,
notifications: { notifications: {
@ -340,7 +425,8 @@ const libraryGames = ref<
const toImport = e.status.unimportedVersions.length > 0; const toImport = e.status.unimportedVersions.length > 0;
return { return {
...e.game, ...e[expand],
type: type,
notifications: { notifications: {
noVersions, noVersions,
toImport, toImport,
@ -348,13 +434,18 @@ const libraryGames = ref<
hasNotifications: noVersions || toImport, hasNotifications: noVersions || toImport,
status: "online" as const, status: "online" as const,
}; };
}), });
}
const libraryGames = ref(
clientSideTransformation(libraryState.games, "value", "game"),
);
const libraryRedists = ref(
clientSideTransformation(libraryState.redists, "value", "redist"),
); );
const filteredLibraryGames = computed(() => const filteredLibrary = computed(() =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment [...libraryGames.value, ...libraryRedists.value].filter((e) => {
// @ts-ignore excessively deep ts
libraryGames.value.filter((e) => {
if (!searchQuery.value) return true; if (!searchQuery.value) return true;
const searchQueryLower = searchQuery.value.toLowerCase(); const searchQueryLower = searchQuery.value.toLowerCase();
if (e.mName.toLowerCase().includes(searchQueryLower)) return true; if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
@ -374,6 +465,16 @@ async function deleteGame(id: string) {
toImport.value = true; toImport.value = true;
} }
async function deleteRedist(id: string) {
await $dropFetch(`/api/v1/admin/redist/${id}`, {
method: "DELETE",
failTitle: "Failed to delete game",
});
const index = libraryRedists.value.findIndex((e) => e.id === id);
libraryRedists.value.splice(index, 1);
toImport.value = true;
}
const gameFeatureLoading = ref<{ [key: string]: boolean }>({}); const gameFeatureLoading = ref<{ [key: string]: boolean }>({});
async function featureGame(id: string) { async function featureGame(id: string) {
const gameIndex = libraryGames.value.findIndex((e) => e.id === id); const gameIndex = libraryGames.value.findIndex((e) => e.id === id);

View File

@ -0,0 +1,14 @@
/*
Warnings:
- Made the column `libraryId` on table `Game` required. This step will fail if there are existing NULL values in that column.
*/
-- DropIndex
DROP INDEX "public"."GameTag_name_idx";
-- AlterTable
ALTER TABLE "public"."Game" ALTER COLUMN "libraryId" SET NOT NULL;
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "public"."GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -34,10 +34,8 @@ model Game {
versions Version[] versions Version[]
mods Mod[] mods Mod[]
// These fields will not be optional in the next version libraryId String
// Any game without a library ID will be assigned one at startup, based on the defaults library Library @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade)
libraryId String?
library Library? @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade)
libraryPath String libraryPath String
collections CollectionEntry[] collections CollectionEntry[]

View File

@ -5,7 +5,6 @@ import * as jdenticon from "jdenticon";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library"; import libraryManager from "~/server/internal/library";
import jsdom from "jsdom"; import jsdom from "jsdom";
import DOMPurify from 'dompurify';
export const ImportRedist = type({ export const ImportRedist = type({
library: "string", library: "string",
@ -28,7 +27,8 @@ export default defineEventHandler(async (h3) => {
const body = await handleFileUpload(h3, {}, ["internal:read"], 1); const body = await handleFileUpload(h3, {}, ["internal:read"], 1);
if (!body) throw createError({ statusCode: 400, message: "Body required." }); if (!body) throw createError({ statusCode: 400, message: "Body required." });
const [[id], rawOptions, pull, , add] = body; const [ids, rawOptions, pull, , add] = body;
const id = ids.at(0);
const options = ImportRedist(rawOptions); const options = ImportRedist(rawOptions);
if (options instanceof ArkErrors) if (options instanceof ArkErrors)
@ -60,7 +60,7 @@ export default defineEventHandler(async (h3) => {
}); });
svg.removeAttribute("width"); svg.removeAttribute("width");
svg.removeAttribute("height"); svg.removeAttribute("height");
svgContent = DOMPurify.sanitize(svg.outerHTML, {USE_PROFILES: {svg: true, svgFilters: true}}); svgContent = svg.outerHTML;
} }
const redist = await prisma.redist.create({ const redist = await prisma.redist.create({

View File

@ -7,9 +7,15 @@ export default defineEventHandler(async (h3) => {
const unimportedGames = await libraryManager.fetchUnimportedGames(); const unimportedGames = await libraryManager.fetchUnimportedGames();
const games = await libraryManager.fetchGamesWithStatus(); const games = await libraryManager.fetchGamesWithStatus();
const redists = await libraryManager.fetchRedistsWithStatus();
const libraries = await libraryManager.fetchLibraries(); const libraries = await libraryManager.fetchLibraries();
// Fetch other library data here // Fetch other library data here
return { unimportedGames, games, hasLibraries: libraries.length > 0 }; return {
unimportedGames,
games,
redists,
hasLibraries: libraries.length > 0,
};
}); });

View File

@ -0,0 +1,19 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["redist:delete"]);
if (!allowed) throw createError({ statusCode: 403 });
const id = getRouterParam(h3, "id")!;
const { count } = await prisma.redist.deleteMany({
where: {
id,
},
});
if (count == 0) throw createError({ statusCode: 404 });
return;
});

View File

@ -0,0 +1,16 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["redist:read"]);
if (!allowed) throw createError({ statusCode: 403 });
return await prisma.redist.findMany({
select: {
id: true,
mName: true,
mShortDescription: true,
mIconObjectId: true,
},
});
});

View File

@ -73,6 +73,10 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"game:image:new": "Upload an image for a game.", "game:image:new": "Upload an image for a game.",
"game:image:delete": "Delete an image for a game.", "game:image:delete": "Delete an image for a game.",
"redist:read": "Fetch redistributables on this instance.",
"redist:update": "Update redistributables on this instance.",
"redist:delete": "Delete redistributables on this instance.",
"company:read": "Fetch companies.", "company:read": "Fetch companies.",
"company:create": "Create a new company.", "company:create": "Create a new company.",
"company:update": "Update existing companies.", "company:update": "Update existing companies.",

View File

@ -67,6 +67,10 @@ export const systemACLs = [
"game:image:new", "game:image:new",
"game:image:delete", "game:image:delete",
"redist:read",
"redist:update",
"redist:delete",
"company:read", "company:read",
"company:update", "company:update",
"company:create", "company:create",

View File

@ -128,29 +128,23 @@ class LibraryManager {
} }
} }
async fetchGamesWithStatus() { async fetchLibraryObjectWithStatus<T>(
const games = await prisma.game.findMany({ objects: Array<
include: { {
versions: { libraryId: string;
select: { libraryPath: string;
versionName: true, versions: Array<unknown>;
}, } & T
}, >,
library: true, ) {
},
orderBy: {
mName: "asc",
},
});
return await Promise.all( return await Promise.all(
games.map(async (e) => { objects.map(async (e) => {
const versions = await this.fetchUnimportedGameVersions( const versions = await this.fetchUnimportedGameVersions(
e.libraryId ?? "", e.libraryId ?? "",
e.libraryPath, e.libraryPath,
); );
return { return {
game: e, value: e,
status: versions status: versions
? { ? {
noVersions: e.versions.length == 0, noVersions: e.versions.length == 0,
@ -162,6 +156,55 @@ class LibraryManager {
); );
} }
async fetchGamesWithStatus() {
const games = await prisma.game.findMany({
include: {
versions: {
select: {
versionId: true,
versionName: true,
},
},
library: {
select: {
id: true,
name: true,
},
},
},
orderBy: {
mName: "asc",
},
});
return await this.fetchLibraryObjectWithStatus(games);
}
async fetchRedistsWithStatus() {
const redists = await prisma.redist.findMany({
include: {
versions: {
select: {
versionId: true,
versionName: true,
},
},
library: {
select: {
id: true,
name: true,
},
},
platform: true,
},
orderBy: {
mName: "asc",
},
});
return await this.fetchLibraryObjectWithStatus(redists);
}
/** /**
* Fetches recommendations and extra data about the version. Doesn't actually check if it's been imported. * Fetches recommendations and extra data about the version. Doesn't actually check if it's been imported.
* @param gameId * @param gameId

View File

@ -1,11 +1,9 @@
import { LibraryBackend } from "~/prisma/client/enums"; import type { LibraryBackend } from "~/prisma/client/enums";
import prisma from "../internal/db/database"; import prisma from "../internal/db/database";
import type { JsonValue } from "@prisma/client/runtime/library"; import type { JsonValue } from "@prisma/client/runtime/library";
import type { LibraryProvider } from "../internal/library/provider"; import type { LibraryProvider } from "../internal/library/provider";
import type { FilesystemProviderConfig } from "../internal/library/providers/filesystem";
import { FilesystemProvider } from "../internal/library/providers/filesystem"; import { FilesystemProvider } from "../internal/library/providers/filesystem";
import libraryManager from "../internal/library"; import libraryManager from "../internal/library";
import path from "path";
import { FlatFilesystemProvider } from "../internal/library/providers/flat"; import { FlatFilesystemProvider } from "../internal/library/providers/flat";
import { logger } from "~/server/internal/logging"; import { logger } from "~/server/internal/logging";
@ -33,42 +31,6 @@ export default defineNitroPlugin(async () => {
let successes = 0; let successes = 0;
const libraries = await prisma.library.findMany({}); 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);
// Update all existing games
await prisma.game.updateMany({
where: {
libraryId: null,
},
data: {
libraryId: library.id,
},
});
}
// Delete all games that don't have a library provider after the legacy handler
// (leftover from a bug)
await prisma.game.deleteMany({
where: {
libraryId: null,
},
});
for (const library of libraries) { for (const library of libraries) {
const constructor = libraryConstructors[library.backend]; const constructor = libraryConstructors[library.backend];
try { try {

View File

@ -2497,11 +2497,6 @@
resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c"
integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==
"@types/trusted-types@^2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
"@types/turndown@^5.0.5": "@types/turndown@^5.0.5":
version "5.0.5" version "5.0.5"
resolved "https://registry.yarnpkg.com/@types/turndown/-/turndown-5.0.5.tgz#614de24fc9ace4d8c0d9483ba81dc8c1976dd26f" resolved "https://registry.yarnpkg.com/@types/turndown/-/turndown-5.0.5.tgz#614de24fc9ace4d8c0d9483ba81dc8c1976dd26f"
@ -4272,13 +4267,6 @@ domhandler@^5.0.2, domhandler@^5.0.3:
dependencies: dependencies:
domelementtype "^2.3.0" domelementtype "^2.3.0"
dompurify@^3.2.6:
version "3.2.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad"
integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==
optionalDependencies:
"@types/trusted-types" "^2.0.7"
domutils@^3.0.1, domutils@^3.2.1, domutils@^3.2.2: domutils@^3.0.1, domutils@^3.2.1, domutils@^3.2.2:
version "3.2.2" version "3.2.2"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"