mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-08-19 13:01:29 +10:00
Add age ratings (#451)
* Base age rating system * Cleanup enum leftovers * Missed files * GB importer * Linter settings * Translations * Prettier and linting --------- Co-authored-by: Robert Clabough <robert@clabough.tech>
This commit is contained in:
co-authored by
Robert Clabough
parent
8e75a0bf56
commit
97c6f2c8a6
@@ -10,6 +10,8 @@ services:
|
||||
|
||||
db:
|
||||
image: postgres:14-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: drop
|
||||
POSTGRES_PASSWORD: drop
|
||||
|
||||
@@ -2,6 +2,10 @@ DATABASE_URL="postgres://drop:drop@127.0.0.1:5432/drop"
|
||||
|
||||
GIANT_BOMB_API_KEY=""
|
||||
|
||||
# Optional, IGDB client information
|
||||
IGDB_CLIENT_ID=""
|
||||
IGDB_CLIENT_SECRET=""
|
||||
|
||||
EXTERNAL_URL="http://localhost:3000"
|
||||
|
||||
NUXT_PORT=4000
|
||||
|
||||
@@ -52,6 +52,92 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label class="text-sm/6 font-medium text-zinc-100">
|
||||
{{ $t("library.admin.game.ageRatings") }}
|
||||
</label>
|
||||
<div class="mt-2 space-y-2">
|
||||
<div
|
||||
v-for="(ar, idx) in ageRatings"
|
||||
:key="ar.organization"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-zinc-800 px-2.5 py-0.5 text-sm font-medium text-zinc-100"
|
||||
>
|
||||
{{
|
||||
$t("library.admin.game.ageRatingLabel", {
|
||||
organization: ar.organization,
|
||||
rating: ar.rating,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-400 hover:text-red-300 text-sm"
|
||||
@click="() => removeAgeRating(idx)"
|
||||
>
|
||||
{{ $t("library.admin.game.removeAgeRating") }}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="ageRatings.length === 0 && !showAddAgeRating"
|
||||
class="text-sm text-zinc-400"
|
||||
>
|
||||
{{ $t("library.admin.game.ageRatingsEmpty") }}
|
||||
</p>
|
||||
<div v-if="showAddAgeRating" class="flex items-center gap-2">
|
||||
<select
|
||||
v-model="newAgeRatingOrg"
|
||||
class="rounded-md bg-zinc-800 px-2 py-1 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 focus:outline-blue-600"
|
||||
>
|
||||
<option
|
||||
v-for="org in availableOrganizations"
|
||||
:key="org"
|
||||
:value="org"
|
||||
>
|
||||
{{ org }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="newAgeRatingValue"
|
||||
:disabled="!newAgeRatingOrg"
|
||||
class="rounded-md bg-zinc-800 px-2 py-1 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 focus:outline-blue-600"
|
||||
>
|
||||
<option
|
||||
v-for="r in newAgeRatingOrgValues"
|
||||
:key="r"
|
||||
:value="r"
|
||||
>
|
||||
{{ r }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-blue-600 px-2 py-1 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
:disabled="!newAgeRatingOrg || !newAgeRatingValue"
|
||||
@click="addAgeRating"
|
||||
>
|
||||
{{ $t("add") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-zinc-400 hover:text-zinc-300 text-sm"
|
||||
@click="showAddAgeRating = false"
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="!showAddAgeRating && availableOrganizations.length > 0"
|
||||
type="button"
|
||||
class="text-sm text-blue-400 hover:text-blue-300"
|
||||
@click="showAddAgeRating = true"
|
||||
>
|
||||
{{ $t("library.admin.game.addAgeRating") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- image carousel pick -->
|
||||
@@ -468,6 +554,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
import { AgeRatingOrganization } from "~/prisma/client/enums";
|
||||
import { getAvailableRatings } from "~/utils/ageRatings";
|
||||
import { micromark } from "micromark";
|
||||
import {
|
||||
CheckIcon,
|
||||
@@ -521,6 +609,59 @@ watch(
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const allOrganizations = Object.values(AgeRatingOrganization);
|
||||
const ageRatings = ref(
|
||||
(game.value.ageRatings ?? []).map((ar) => ({
|
||||
organization: ar.organization,
|
||||
rating: ar.rating,
|
||||
})),
|
||||
);
|
||||
const showAddAgeRating = ref(false);
|
||||
const newAgeRatingOrg = ref("");
|
||||
const newAgeRatingValue = ref("");
|
||||
|
||||
const availableOrganizations = computed(() =>
|
||||
allOrganizations.filter(
|
||||
(org) => !ageRatings.value.some((ar) => ar.organization === org),
|
||||
),
|
||||
);
|
||||
|
||||
const newAgeRatingOrgValues = computed(() =>
|
||||
newAgeRatingOrg.value
|
||||
? getAvailableRatings(newAgeRatingOrg.value as AgeRatingOrganization)
|
||||
: [],
|
||||
);
|
||||
|
||||
watch(newAgeRatingOrg, () => {
|
||||
newAgeRatingValue.value = "";
|
||||
});
|
||||
|
||||
async function saveAgeRatings() {
|
||||
await $dropFetch(`/api/v1/admin/game/:id/age-ratings`, {
|
||||
method: "PATCH",
|
||||
params: { id: game.value.id },
|
||||
body: { ageRatings: ageRatings.value },
|
||||
failTitle: "Failed to update age ratings",
|
||||
});
|
||||
}
|
||||
|
||||
async function addAgeRating() {
|
||||
if (!newAgeRatingOrg.value || !newAgeRatingValue.value) return;
|
||||
ageRatings.value.push({
|
||||
organization: newAgeRatingOrg.value as AgeRatingOrganization,
|
||||
rating: newAgeRatingValue.value,
|
||||
});
|
||||
newAgeRatingOrg.value = "";
|
||||
newAgeRatingValue.value = "";
|
||||
showAddAgeRating.value = false;
|
||||
await saveAgeRatings();
|
||||
}
|
||||
|
||||
async function removeAgeRating(index: number) {
|
||||
ageRatings.value.splice(index, 1);
|
||||
await saveAgeRatings();
|
||||
}
|
||||
|
||||
const releaseDate = ref(
|
||||
game.value.mReleased
|
||||
? new Date(game.value.mReleased).toISOString().substring(0, 10)
|
||||
|
||||
@@ -318,9 +318,13 @@
|
||||
"detectedGame": "Drop hat erkannt, dass du ein neues Spiel importieren kannst.",
|
||||
"detectedVersion": "Drop hat erkannt, dass du eine neue Version dieses Spiels importieren kannst.",
|
||||
"game": {
|
||||
"addAgeRating": "Bewertung hinzufügen",
|
||||
"addCarouselNoImages": "Keine Bilder zum hinzufügen.",
|
||||
"addDescriptionNoImages": "Keine Bilder zum hinzufügen.",
|
||||
"addImageCarousel": "Aus der Bilderbibliothek hinzufügen",
|
||||
"ageRatingLabel": "{organization}: {rating}",
|
||||
"ageRatings": "Altersfreigaben",
|
||||
"ageRatingsEmpty": "Keine Altersfreigaben festgelegt",
|
||||
"currentBanner": "Banner",
|
||||
"currentCover": "Cover",
|
||||
"deleteImage": "Bild löschen",
|
||||
@@ -332,6 +336,7 @@
|
||||
"imageCarouselEmpty": "Es wurden noch keine Bilder zum Karussell hinzugefügt.",
|
||||
"imageLibrary": "Bilderbibliothek",
|
||||
"imageLibraryDescription": "Bitte beachten: Alle hochgeladenen Bilder sind für alle Nutzer über die Browser-Entwicklertools zugänglich.",
|
||||
"removeAgeRating": "Entfernen",
|
||||
"removeImageCarousel": "Bild entfernen",
|
||||
"setBanner": "Als Banner festlegen",
|
||||
"setCover": "Als Cover festlegen"
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"store": {
|
||||
"about": "Über",
|
||||
"ageRating": "Altersfreigabe",
|
||||
"commingSoon": "Demnächst verfügbar",
|
||||
"developers": "Entwickler | Entwickler | Entwickler",
|
||||
"exploreMore": "Mehr entdecken {arrow}",
|
||||
|
||||
@@ -261,9 +261,13 @@
|
||||
"detectedGame": "Drop has found new plunder to import, argh!",
|
||||
"detectedVersion": "Drop has found new versions of this plunder to import, savvy!",
|
||||
"game": {
|
||||
"addAgeRating": "Add Rating",
|
||||
"addCarouselNoImages": "No images to add, ye dog.",
|
||||
"addDescriptionNoImages": "No images to add, argh.",
|
||||
"addImageCarousel": "Add from image treasure hoard",
|
||||
"ageRatingLabel": "{organization}: {rating}",
|
||||
"ageRatings": "Age Ratings, Savvy?",
|
||||
"ageRatingsEmpty": "Age ratings plundered",
|
||||
"currentBanner": "banner",
|
||||
"currentCover": "cover",
|
||||
"deleteImage": "Scuttle image",
|
||||
@@ -274,6 +278,7 @@
|
||||
"imageCarouselEmpty": "No images added to the carousel yet, argh.",
|
||||
"imageLibrary": "Image treasure hoard",
|
||||
"imageLibraryDescription": "Please note all images hoisted be accessible to all crew through browser dev-tools, savvy.",
|
||||
"removeAgeRating": "Remove",
|
||||
"removeImageCarousel": "Remove image",
|
||||
"setBanner": "Set as banner",
|
||||
"setCover": "Set as cover"
|
||||
@@ -412,6 +417,7 @@
|
||||
"selectLanguage": "Pick yer tongue",
|
||||
"settings": "Settings",
|
||||
"store": {
|
||||
"ageRating": "Age Rating, ye landlubber!",
|
||||
"commingSoon": "comin' soon, argh!",
|
||||
"exploreMore": "Explore more {arrow}, ye dog!",
|
||||
"images": "Plunder Images",
|
||||
|
||||
@@ -381,9 +381,13 @@
|
||||
"noSelected": "No extensions selected."
|
||||
},
|
||||
"game": {
|
||||
"addAgeRating": "Add Rating",
|
||||
"addCarouselNoImages": "No images to add.",
|
||||
"addDescriptionNoImages": "No images to add.",
|
||||
"addImageCarousel": "Add from image library",
|
||||
"ageRatingLabel": "{organization}: {rating}",
|
||||
"ageRatings": "Age Ratings",
|
||||
"ageRatingsEmpty": "No age ratings set",
|
||||
"currentBanner": "banner",
|
||||
"currentCover": "cover",
|
||||
"deleteImage": "Delete image",
|
||||
@@ -395,6 +399,7 @@
|
||||
"imageCarouselEmpty": "No images added to the carousel yet.",
|
||||
"imageLibrary": "Image library",
|
||||
"imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.",
|
||||
"removeAgeRating": "Remove",
|
||||
"removeImageCarousel": "Remove image",
|
||||
"setBanner": "Set as banner",
|
||||
"setCover": "Set as cover"
|
||||
@@ -707,6 +712,7 @@
|
||||
"welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works."
|
||||
},
|
||||
"store": {
|
||||
"ageRating": "Age Rating",
|
||||
"commingSoon": "coming soon",
|
||||
"developers": "Developers | Developer | Developers",
|
||||
"featured": "Featured",
|
||||
|
||||
@@ -318,9 +318,13 @@
|
||||
"detectedGame": "Drop a détecté que vous avez des nouveaux jeux a importer.",
|
||||
"detectedVersion": "Drop a détecté que vous avez des nouvelles versions de ce jeu à importer.",
|
||||
"game": {
|
||||
"addAgeRating": "Ajouter une classification",
|
||||
"addCarouselNoImages": "Pas d'image a ajouter.",
|
||||
"addDescriptionNoImages": "Pas d'image à ajouter.",
|
||||
"addImageCarousel": "Ajouter à partir d'une bibliothèque d'images",
|
||||
"ageRatingLabel": "{organization} : {rating}",
|
||||
"ageRatings": "Classifications d'âge",
|
||||
"ageRatingsEmpty": "Aucune classification d'âge définie",
|
||||
"currentBanner": "bannière",
|
||||
"currentCover": "couverture",
|
||||
"deleteImage": "Supprimer l'image",
|
||||
@@ -332,6 +336,7 @@
|
||||
"imageCarouselEmpty": "Aucune image n'a encore été ajoutée au carousel.",
|
||||
"imageLibrary": "Bibliothèque d'images",
|
||||
"imageLibraryDescription": "Veuillez noter que toutes les images uploadées sont accessible a tous les utilisateurs via des outils de développement des navigateurs.",
|
||||
"removeAgeRating": "Supprimer",
|
||||
"removeImageCarousel": "Retirer l'image",
|
||||
"setBanner": "Définir comme bannière",
|
||||
"setCover": "Définir comme couverture"
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"store": {
|
||||
"about": "À propos",
|
||||
"ageRating": "Classification d'âge",
|
||||
"commingSoon": "prochainement",
|
||||
"developers": "Développeurs | Développeur | Développeurs",
|
||||
"exploreMore": "Explorer plus {arrow}",
|
||||
|
||||
@@ -304,9 +304,13 @@
|
||||
"detectedGame": "Drop wykrył że masz nowe gry do zaimportowania.",
|
||||
"detectedVersion": "Drop wykrył że masz nowe wersje tej gry do zaimportowania.",
|
||||
"game": {
|
||||
"addAgeRating": "Dodaj kategorię",
|
||||
"addCarouselNoImages": "Brak obrazów do dodania.",
|
||||
"addDescriptionNoImages": "Brak obrazów do dodania.",
|
||||
"addImageCarousel": "Dodaj z galerii obrazów",
|
||||
"ageRatingLabel": "{organization}: {rating}",
|
||||
"ageRatings": "Kategorie wiekowe",
|
||||
"ageRatingsEmpty": "Brak kategorii wiekowych",
|
||||
"currentBanner": "baner",
|
||||
"currentCover": "okładka",
|
||||
"deleteImage": "Usuń obraz",
|
||||
@@ -318,6 +322,7 @@
|
||||
"imageCarouselEmpty": "Nie dodano jeszcze żadnych zdjęć do karuzeli.",
|
||||
"imageLibrary": "Biblioteka Obrazów",
|
||||
"imageLibraryDescription": "Należy pamiętać, że wszystkie przesłane obrazy są dostępne dla wszystkich użytkowników za pośrednictwem narzędzi programistycznych przeglądarki.",
|
||||
"removeAgeRating": "Usuń",
|
||||
"removeImageCarousel": "Usuń zdjęcie",
|
||||
"setBanner": "Ustaw jako baner",
|
||||
"setCover": "Ustaw jako okładke"
|
||||
@@ -562,6 +567,7 @@
|
||||
},
|
||||
"store": {
|
||||
"about": "O",
|
||||
"ageRating": "Kategoria wiekowa",
|
||||
"commingSoon": "wkrótce",
|
||||
"developers": "Producentów | Producent | Producentów",
|
||||
"exploreMore": "Odkryj więcej {arrow}",
|
||||
|
||||
@@ -145,6 +145,32 @@
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="game.ageRatings?.length">
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("store.ageRating") }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap flex flex-row items-center gap-x-2 px-3 py-4 text-sm text-zinc-400"
|
||||
>
|
||||
<div
|
||||
v-for="ar in game.ageRatings"
|
||||
:key="ar.id"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<img
|
||||
v-if="ar.ratingCoverUrl"
|
||||
:src="ar.ratingCoverUrl"
|
||||
:alt="`${ar.organization} ${ar.rating}`"
|
||||
class="h-8"
|
||||
/>
|
||||
<span v-else class="text-xs bg-zinc-700 px-2 py-1 rounded">
|
||||
{{ ar.organization }} {{ ar.rating }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="whitespace-nowrap align-top py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
|
||||
+20
@@ -8,8 +8,28 @@ DROP INDEX "GameTag_name_idx";
|
||||
ALTER TABLE "Game" ALTER COLUMN "mImageCarouselObjectIds" SET DEFAULT ARRAY[]::TEXT[];
|
||||
UPDATE "Game" SET "mImageCarouselObjectIds" = '{}' WHERE "mImageCarouselObjectIds" IS NULL;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AgeRatingOrganization" AS ENUM ('ESRB', 'PEGI', 'CERO', 'USK', 'GRAC', 'ClassInd', 'ACB');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GameAgeRating" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organization" "AgeRatingOrganization" NOT NULL,
|
||||
"rating" TEXT NOT NULL,
|
||||
"ratingCoverUrl" TEXT,
|
||||
"gameId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "GameAgeRating_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GameAgeRating_gameId_organization_key" ON "GameAgeRating"("gameId", "organization");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Game_mName_idx" ON "Game" USING GIST ("mName" gist_trgm_ops(siglen=32));
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GameAgeRating" ADD CONSTRAINT "GameAgeRating_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -31,7 +31,8 @@ model Game {
|
||||
mDescription String // Supports markdown
|
||||
mReleased DateTime // When the game was released
|
||||
|
||||
ratings GameRating[]
|
||||
ratings GameRating[]
|
||||
ageRatings GameAgeRating[]
|
||||
|
||||
featured Boolean @default(false)
|
||||
|
||||
@@ -73,6 +74,29 @@ model GameTag {
|
||||
@@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist)
|
||||
}
|
||||
|
||||
enum AgeRatingOrganization {
|
||||
ESRB
|
||||
PEGI
|
||||
CERO
|
||||
USK
|
||||
GRAC
|
||||
ClassInd
|
||||
ACB
|
||||
}
|
||||
|
||||
model GameAgeRating {
|
||||
id String @id @default(uuid())
|
||||
|
||||
organization AgeRatingOrganization
|
||||
rating String // e.g. "E", "T", "M", "18", "CERO_B", comes from server enums
|
||||
ratingCoverUrl String? // Badge image URL from IGDB, optional
|
||||
|
||||
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
|
||||
gameId String
|
||||
|
||||
@@unique([gameId, organization], name: "gameOrganizationKey")
|
||||
}
|
||||
|
||||
model GameRating {
|
||||
id String @id @default(uuid())
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { type } from "arktype";
|
||||
import type { AgeRatingOrganization } from "~/prisma/client/enums";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
import {
|
||||
getAvailableRatings,
|
||||
RATINGS_FOR_ORGANIZATION,
|
||||
} from "~/utils/ageRatings";
|
||||
|
||||
const PatchAgeRatings = type({
|
||||
ageRatings: type({
|
||||
organization: "string",
|
||||
rating: "string",
|
||||
}).array(),
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:update"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readDropValidatedBody(h3, PatchAgeRatings);
|
||||
const id = getRouterParam(h3, "id")!;
|
||||
|
||||
for (const ar of body.ageRatings) {
|
||||
if (!(ar.organization in RATINGS_FOR_ORGANIZATION)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `Invalid organization: ${ar.organization}`,
|
||||
});
|
||||
}
|
||||
const validRatings = getAvailableRatings(
|
||||
ar.organization as AgeRatingOrganization,
|
||||
);
|
||||
if (!validRatings.includes(ar.rating)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `Invalid rating "${ar.rating}" for ${ar.organization}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!game) throw createError({ statusCode: 404, message: "Game not found" });
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.gameAgeRating.deleteMany({
|
||||
where: { gameId: id },
|
||||
}),
|
||||
prisma.gameAgeRating.createMany({
|
||||
data: body.ageRatings.map((ar) => ({
|
||||
gameId: id,
|
||||
organization: ar.organization as AgeRatingOrganization,
|
||||
rating: ar.rating,
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
|
||||
return await prisma.gameAgeRating.findMany({
|
||||
where: { gameId: id },
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ export type AdminFetchGameType = Prisma.GameGetPayload<{
|
||||
};
|
||||
};
|
||||
tags: true;
|
||||
ageRatings: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -111,6 +112,7 @@ export default defineEventHandler<
|
||||
},
|
||||
},
|
||||
tags: true,
|
||||
ageRatings: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export default defineEventHandler(async (h3) => {
|
||||
},
|
||||
},
|
||||
tags: true,
|
||||
ageRatings: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CompanyModel } from "~/prisma/client/models";
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
import { AgeRatingOrganization, MetadataSource } from "~/prisma/client/enums";
|
||||
import type { MetadataProvider } from ".";
|
||||
import { MissingMetadataProviderConfig } from ".";
|
||||
import type {
|
||||
@@ -9,11 +9,20 @@ import type {
|
||||
_FetchCompanyMetadataParams,
|
||||
CompanyMetadata,
|
||||
GameMetadataRating,
|
||||
GameMetadataAgeRating,
|
||||
} from "./types";
|
||||
import TurndownService from "turndown";
|
||||
import { DateTime } from "luxon";
|
||||
import type { TaskRunContext } from "../tasks";
|
||||
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
|
||||
import {
|
||||
ESRBRating,
|
||||
PEGIRating,
|
||||
CEROrating,
|
||||
USKRating,
|
||||
GRACRating,
|
||||
ACBRating,
|
||||
} from "~/utils/ageRatings";
|
||||
|
||||
interface GiantBombResponseType<T> {
|
||||
error: "OK" | string;
|
||||
@@ -79,6 +88,67 @@ interface ReviewResult {
|
||||
site_detail_url: string;
|
||||
}
|
||||
|
||||
interface GameRatingResult {
|
||||
id: number;
|
||||
name: string;
|
||||
rating_board: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ReleaseResult {
|
||||
guid: string;
|
||||
name: string;
|
||||
game_rating?: GameRatingResult;
|
||||
}
|
||||
|
||||
const GB_BOARD_TO_ORG: Record<string, AgeRatingOrganization> = {
|
||||
ESRB: AgeRatingOrganization.ESRB,
|
||||
PEGI: AgeRatingOrganization.PEGI,
|
||||
CERO: AgeRatingOrganization.CERO,
|
||||
USK: AgeRatingOrganization.USK,
|
||||
GRAC: AgeRatingOrganization.GRAC,
|
||||
OFLC: AgeRatingOrganization.ACB,
|
||||
ACB: AgeRatingOrganization.ACB,
|
||||
};
|
||||
|
||||
function lowercaseNormMap(
|
||||
ratings: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.values(ratings).map((v) => [v.toLowerCase(), v]),
|
||||
);
|
||||
}
|
||||
|
||||
const GB_RATING_NORMALIZE: Record<
|
||||
AgeRatingOrganization,
|
||||
Record<string, string>
|
||||
> = {
|
||||
[AgeRatingOrganization.ESRB]: {
|
||||
...lowercaseNormMap(ESRBRating),
|
||||
"early childhood": ESRBRating.EC,
|
||||
everyone: ESRBRating.E,
|
||||
"everyone 10+": ESRBRating.E10,
|
||||
teen: ESRBRating.T,
|
||||
mature: ESRBRating.M,
|
||||
"mature 17+": ESRBRating.M,
|
||||
"adults only": ESRBRating.AO,
|
||||
"adults only 18+": ESRBRating.AO,
|
||||
},
|
||||
[AgeRatingOrganization.PEGI]: lowercaseNormMap(PEGIRating),
|
||||
[AgeRatingOrganization.CERO]: lowercaseNormMap(CEROrating),
|
||||
[AgeRatingOrganization.USK]: lowercaseNormMap(USKRating),
|
||||
[AgeRatingOrganization.GRAC]: lowercaseNormMap(GRACRating),
|
||||
[AgeRatingOrganization.ACB]: {
|
||||
...lowercaseNormMap(ACBRating),
|
||||
"ma 15+": ACBRating.MA15,
|
||||
"r 18+": ACBRating.R18,
|
||||
"refused classification": ACBRating.RC,
|
||||
},
|
||||
[AgeRatingOrganization.ClassInd]: {},
|
||||
};
|
||||
|
||||
interface CompanySearchResult {
|
||||
guid: string;
|
||||
deck: string | null;
|
||||
@@ -246,6 +316,43 @@ export class GiantBombProvider implements MetadataProvider {
|
||||
|
||||
const tags = (gameData.genres ?? []).map((e) => e.name);
|
||||
|
||||
// Fetch age ratings from releases
|
||||
const ageRatings: GameMetadataAgeRating[] = [];
|
||||
try {
|
||||
const releasesResult = await this.request<Array<ReleaseResult>>(
|
||||
"releases",
|
||||
"",
|
||||
{
|
||||
filter: `game:${gameData.guid}`,
|
||||
field_list: "guid,name,game_rating",
|
||||
},
|
||||
);
|
||||
|
||||
const seenOrgs = new Set<AgeRatingOrganization>();
|
||||
for (const release of releasesResult.results) {
|
||||
if (!release.game_rating?.rating_board) continue;
|
||||
|
||||
const boardName = release.game_rating.rating_board.name;
|
||||
const org = GB_BOARD_TO_ORG[boardName];
|
||||
if (!org || seenOrgs.has(org)) continue;
|
||||
|
||||
const ratingName = release.game_rating.name.toLowerCase();
|
||||
const normalized = GB_RATING_NORMALIZE[org]?.[ratingName];
|
||||
if (!normalized) continue;
|
||||
|
||||
seenOrgs.add(org);
|
||||
ageRatings.push({ organization: org, rating: normalized });
|
||||
}
|
||||
|
||||
if (ageRatings.length > 0) {
|
||||
context?.logger.info(
|
||||
`Found ${ageRatings.length} age ratings: ${ageRatings.map((r) => `${r.organization}: ${r.rating}`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
context?.logger.warn(`Failed to fetch age ratings from releases: ${e}`);
|
||||
}
|
||||
|
||||
const metadata: GameMetadata = {
|
||||
id: gameData.guid,
|
||||
name: gameData.name,
|
||||
@@ -256,6 +363,7 @@ export class GiantBombProvider implements MetadataProvider {
|
||||
tags,
|
||||
|
||||
reviews,
|
||||
ageRatings,
|
||||
|
||||
publishers,
|
||||
developers,
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { CompanyModel } from "~/prisma/client/models";
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
import { AgeRatingOrganization, MetadataSource } from "~/prisma/client/enums";
|
||||
import {
|
||||
ESRBRating,
|
||||
PEGIRating,
|
||||
CEROrating,
|
||||
USKRating,
|
||||
GRACRating,
|
||||
ClassIndRating,
|
||||
ACBRating,
|
||||
} from "~/utils/ageRatings";
|
||||
import type { MetadataProvider } from ".";
|
||||
import { MissingMetadataProviderConfig } from ".";
|
||||
import type {
|
||||
GameMetadataSearchResult,
|
||||
GameMetadataAgeRating,
|
||||
_FetchGameMetadataParams,
|
||||
GameMetadata,
|
||||
_FetchCompanyMetadataParams,
|
||||
@@ -81,6 +91,69 @@ interface IGDBSearchStub extends IGDBItem {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
interface IGDBAgeRating extends IGDBItem {
|
||||
category: number; // 1=ESRB, 2=PEGI, 3=CERO, 4=USK, 5=GRAC, 6=CLASS_IND, 7=ACB
|
||||
rating: number; // Specific rating level enum value
|
||||
rating_cover_url?: string;
|
||||
}
|
||||
|
||||
const IGDB_CATEGORY_TO_ORG: Record<number, AgeRatingOrganization> = {
|
||||
1: AgeRatingOrganization.ESRB,
|
||||
2: AgeRatingOrganization.PEGI,
|
||||
3: AgeRatingOrganization.CERO,
|
||||
4: AgeRatingOrganization.USK,
|
||||
5: AgeRatingOrganization.GRAC,
|
||||
6: AgeRatingOrganization.ClassInd,
|
||||
7: AgeRatingOrganization.ACB,
|
||||
};
|
||||
|
||||
const IGDB_RATING_TO_STRING: Record<number, string> = {
|
||||
// PEGI
|
||||
1: PEGIRating["3"],
|
||||
2: PEGIRating["7"],
|
||||
3: PEGIRating["12"],
|
||||
4: PEGIRating["16"],
|
||||
5: PEGIRating["18"],
|
||||
// ESRB
|
||||
7: ESRBRating.EC,
|
||||
8: ESRBRating.E,
|
||||
9: ESRBRating.E10,
|
||||
10: ESRBRating.T,
|
||||
11: ESRBRating.M,
|
||||
12: ESRBRating.AO,
|
||||
// CERO
|
||||
13: CEROrating.A,
|
||||
14: CEROrating.B,
|
||||
15: CEROrating.C,
|
||||
16: CEROrating.D,
|
||||
17: CEROrating.Z,
|
||||
// USK
|
||||
18: USKRating["0"],
|
||||
19: USKRating["6"],
|
||||
20: USKRating["12"],
|
||||
21: USKRating["16"],
|
||||
22: USKRating["18"],
|
||||
// GRAC
|
||||
23: GRACRating.ALL,
|
||||
24: GRACRating["12"],
|
||||
25: GRACRating["15"],
|
||||
26: GRACRating["18"],
|
||||
// CLASS_IND
|
||||
28: ClassIndRating.L,
|
||||
29: ClassIndRating["10"],
|
||||
30: ClassIndRating["12"],
|
||||
31: ClassIndRating["14"],
|
||||
32: ClassIndRating["16"],
|
||||
33: ClassIndRating["18"],
|
||||
// ACB
|
||||
34: ACBRating.G,
|
||||
35: ACBRating.PG,
|
||||
36: ACBRating.M,
|
||||
37: ACBRating.MA15,
|
||||
38: ACBRating.R18,
|
||||
39: ACBRating.RC,
|
||||
};
|
||||
|
||||
// https://api-docs.igdb.com/?shell#game
|
||||
interface IGDBGameFull extends IGDBSearchStub {
|
||||
age_ratings?: IGDBID[];
|
||||
@@ -302,6 +375,34 @@ export class IGDBProvider implements MetadataProvider {
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getAgeRatings(
|
||||
ageRatingIds: IGDBID[] | undefined,
|
||||
): Promise<GameMetadataAgeRating[]> {
|
||||
if (!ageRatingIds?.length) return [];
|
||||
|
||||
const results: GameMetadataAgeRating[] = [];
|
||||
for (const id of ageRatingIds) {
|
||||
const response = await this.request<IGDBAgeRating>(
|
||||
"age_ratings",
|
||||
`where id = ${id}; fields category,rating,rating_cover_url;`,
|
||||
);
|
||||
|
||||
for (const ar of response) {
|
||||
const organization = IGDB_CATEGORY_TO_ORG[ar.category];
|
||||
const rating = IGDB_RATING_TO_STRING[ar.rating];
|
||||
if (!organization || !rating) continue;
|
||||
|
||||
results.push({
|
||||
organization,
|
||||
rating,
|
||||
ratingCoverUrl: ar.rating_cover_url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
name() {
|
||||
return "IGDB";
|
||||
}
|
||||
@@ -444,6 +545,7 @@ export class IGDBProvider implements MetadataProvider {
|
||||
};
|
||||
|
||||
const genres = await this.getGenres(currentGame.genres);
|
||||
const ageRatings = await this.getAgeRatings(currentGame.age_ratings);
|
||||
|
||||
let description = "";
|
||||
let shortDescription = "";
|
||||
@@ -468,6 +570,7 @@ export class IGDBProvider implements MetadataProvider {
|
||||
|
||||
genres,
|
||||
reviews: [review],
|
||||
ageRatings,
|
||||
|
||||
publishers,
|
||||
developers,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
_FetchGameMetadataParams,
|
||||
_FetchCompanyMetadataParams,
|
||||
GameMetadata,
|
||||
GameMetadataAgeRating,
|
||||
GameMetadataSearchResult,
|
||||
InternalGameMetadataResult,
|
||||
CompanyMetadata,
|
||||
@@ -176,6 +177,20 @@ export class MetadataHandler {
|
||||
return results;
|
||||
}
|
||||
|
||||
private parseAgeRatings(ageRatings: GameMetadataAgeRating[], gameId: string) {
|
||||
return ageRatings.map((ar) => ({
|
||||
where: {
|
||||
gameOrganizationKey: {
|
||||
gameId,
|
||||
organization: ar.organization,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
...ar,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async createGame(
|
||||
result: { sourceId: string; id: string; name: string },
|
||||
libraryId: string,
|
||||
@@ -287,6 +302,12 @@ export class MetadataHandler {
|
||||
ratings: {
|
||||
connectOrCreate: metadataHandler.parseRatings(metadata.reviews),
|
||||
},
|
||||
ageRatings: {
|
||||
connectOrCreate: metadataHandler.parseAgeRatings(
|
||||
metadata.ageRatings,
|
||||
gameId,
|
||||
),
|
||||
},
|
||||
tags: {
|
||||
connect: await metadataHandler.parseTags(metadata.tags),
|
||||
},
|
||||
|
||||
@@ -35,6 +35,7 @@ export class ManualMetadataProvider implements MetadataProvider {
|
||||
developers: [],
|
||||
tags: [],
|
||||
reviews: [],
|
||||
ageRatings: [],
|
||||
|
||||
icon: iconId,
|
||||
coverId: iconId,
|
||||
|
||||
@@ -445,6 +445,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
|
||||
tags: this.compileTags(game),
|
||||
|
||||
reviews: pageContent.reception.filter((v) => typeof v !== "undefined"),
|
||||
ageRatings: [],
|
||||
publishers,
|
||||
developers,
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
import { AgeRatingOrganization, MetadataSource } from "~/prisma/client/enums";
|
||||
import {
|
||||
ESRBRating,
|
||||
PEGIRating,
|
||||
USKRating,
|
||||
ACBRating,
|
||||
} from "~/utils/ageRatings";
|
||||
import type { MetadataProvider } from ".";
|
||||
import type {
|
||||
GameMetadataSearchResult,
|
||||
@@ -7,6 +13,7 @@ import type {
|
||||
_FetchCompanyMetadataParams,
|
||||
CompanyMetadata,
|
||||
GameMetadataRating,
|
||||
GameMetadataAgeRating,
|
||||
} from "./types";
|
||||
import type { TaskRunContext } from "../tasks";
|
||||
import * as jdenticon from "jdenticon";
|
||||
@@ -143,6 +150,12 @@ interface SteamTagsPackage {
|
||||
};
|
||||
}
|
||||
|
||||
interface SteamRatingEntry {
|
||||
rating: string;
|
||||
descriptors?: string;
|
||||
required_age?: string;
|
||||
}
|
||||
|
||||
interface SteamWebAppDetailsSmall {
|
||||
type: string;
|
||||
name: string;
|
||||
@@ -162,6 +175,7 @@ interface SteamWebAppDetailsSmall {
|
||||
mac_requirements: { minimum: string; recommended: string };
|
||||
linux_requirements: { minimum: string; recommended: string };
|
||||
legal_notice: string;
|
||||
ratings?: Record<string, SteamRatingEntry>;
|
||||
}
|
||||
|
||||
interface SteamWebAppDetailsLarge extends SteamWebAppDetailsSmall {
|
||||
@@ -178,6 +192,28 @@ interface SteamWebAppDetailsPackage {
|
||||
};
|
||||
}
|
||||
|
||||
const STEAM_RATING_KEY_TO_ORG: Record<string, AgeRatingOrganization> = {
|
||||
esrb: AgeRatingOrganization.ESRB,
|
||||
pegi: AgeRatingOrganization.PEGI,
|
||||
usk: AgeRatingOrganization.USK,
|
||||
oflc: AgeRatingOrganization.ACB,
|
||||
};
|
||||
|
||||
function lowercaseNormMap(
|
||||
ratings: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.values(ratings).map((v) => [v.toLowerCase(), v]),
|
||||
);
|
||||
}
|
||||
|
||||
const STEAM_RATING_NORMALIZE: Record<string, Record<string, string>> = {
|
||||
esrb: lowercaseNormMap(ESRBRating),
|
||||
pegi: lowercaseNormMap(PEGIRating),
|
||||
usk: lowercaseNormMap(USKRating),
|
||||
oflc: lowercaseNormMap(ACBRating),
|
||||
};
|
||||
|
||||
export class SteamProvider implements MetadataProvider {
|
||||
name() {
|
||||
return "Steam";
|
||||
@@ -363,7 +399,7 @@ export class SteamProvider implements MetadataProvider {
|
||||
context?.logger.info("Fetching detailed description and reviews...");
|
||||
const webAppDetails = (await this._getWebAppDetails(
|
||||
id,
|
||||
"metacritic",
|
||||
"metacritic,ratings",
|
||||
)) as SteamWebAppDetailsLarge;
|
||||
|
||||
const detailedDescription =
|
||||
@@ -407,6 +443,13 @@ export class SteamProvider implements MetadataProvider {
|
||||
`Steam reviews: ${steamReviewCount} reviews, ${steamRating}% positive`,
|
||||
);
|
||||
|
||||
const ageRatings = this._extractAgeRatings(webAppDetails?.ratings);
|
||||
if (ageRatings.length > 0) {
|
||||
context?.logger.info(
|
||||
`Found ${ageRatings.length} age ratings: ${ageRatings.map((r) => `${r.organization}: ${r.rating}`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (webAppDetails?.metacritic) {
|
||||
reviews.push({
|
||||
metadataId: id,
|
||||
@@ -437,6 +480,7 @@ export class SteamProvider implements MetadataProvider {
|
||||
developers,
|
||||
tags,
|
||||
reviews,
|
||||
ageRatings,
|
||||
icon,
|
||||
bannerId: banner,
|
||||
coverId: cover,
|
||||
@@ -795,6 +839,24 @@ export class SteamProvider implements MetadataProvider {
|
||||
return appData;
|
||||
}
|
||||
|
||||
private _extractAgeRatings(
|
||||
ratings: Record<string, SteamRatingEntry> | undefined,
|
||||
): GameMetadataAgeRating[] {
|
||||
if (!ratings) return [];
|
||||
|
||||
const results: GameMetadataAgeRating[] = [];
|
||||
for (const [key, entry] of Object.entries(ratings)) {
|
||||
const org = STEAM_RATING_KEY_TO_ORG[key];
|
||||
if (!org) continue;
|
||||
|
||||
const normalized =
|
||||
STEAM_RATING_NORMALIZE[key]?.[entry.rating] ??
|
||||
entry.rating.toUpperCase();
|
||||
results.push({ organization: org, rating: normalized });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private _getImageUrl(filename: string, format?: string): string {
|
||||
if (!filename || filename.trim().length === 0) return "";
|
||||
|
||||
|
||||
+8
@@ -1,4 +1,5 @@
|
||||
import type { Company, GameRating } from "~/prisma/client";
|
||||
import type { AgeRatingOrganization } from "~/prisma/client/enums";
|
||||
import type { TransactionDataType } from "../objects/transactional";
|
||||
import type { ObjectReference } from "../objects/objectHandler";
|
||||
|
||||
@@ -27,6 +28,12 @@ export type GameMetadataRating = Pick<
|
||||
| "mReviewRating"
|
||||
>;
|
||||
|
||||
export interface GameMetadataAgeRating {
|
||||
organization: AgeRatingOrganization;
|
||||
rating: string;
|
||||
ratingCoverUrl?: string;
|
||||
}
|
||||
|
||||
export interface GameMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -42,6 +49,7 @@ export interface GameMetadata {
|
||||
tags: string[];
|
||||
|
||||
reviews: GameMetadataRating[];
|
||||
ageRatings: GameMetadataAgeRating[];
|
||||
|
||||
// Created with another utility function
|
||||
icon: ObjectReference;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { AgeRatingOrganization } from "~/prisma/client/enums";
|
||||
|
||||
/**
|
||||
* This file will live as the known ratings that come from various sources. Each source reports a bit differently, the goal
|
||||
* will be to normalize to these ratings. Admins of course can override and set a separate rating if needed.
|
||||
*
|
||||
* These will change, but historically very infrequently.
|
||||
*/
|
||||
|
||||
export const ESRBRating = {
|
||||
EC: "EC",
|
||||
E: "E",
|
||||
E10: "E10",
|
||||
T: "T",
|
||||
M: "M",
|
||||
AO: "AO",
|
||||
} as const;
|
||||
|
||||
export const PEGIRating = {
|
||||
"3": "3",
|
||||
"7": "7",
|
||||
"12": "12",
|
||||
"16": "16",
|
||||
"18": "18",
|
||||
} as const;
|
||||
|
||||
export const CEROrating = {
|
||||
A: "A",
|
||||
B: "B",
|
||||
C: "C",
|
||||
D: "D",
|
||||
Z: "Z",
|
||||
} as const;
|
||||
|
||||
export const USKRating = {
|
||||
"0": "0",
|
||||
"6": "6",
|
||||
"12": "12",
|
||||
"16": "16",
|
||||
"18": "18",
|
||||
} as const;
|
||||
|
||||
export const GRACRating = {
|
||||
ALL: "ALL",
|
||||
"12": "12",
|
||||
"15": "15",
|
||||
"18": "18",
|
||||
} as const;
|
||||
|
||||
export const ClassIndRating = {
|
||||
L: "L",
|
||||
"10": "10",
|
||||
"12": "12",
|
||||
"14": "14",
|
||||
"16": "16",
|
||||
"18": "18",
|
||||
} as const;
|
||||
|
||||
export const ACBRating = {
|
||||
G: "G",
|
||||
PG: "PG",
|
||||
M: "M",
|
||||
MA15: "MA15",
|
||||
R18: "R18",
|
||||
RC: "RC",
|
||||
} as const;
|
||||
|
||||
export const RATINGS_FOR_ORGANIZATION = {
|
||||
[AgeRatingOrganization.ESRB]: ESRBRating,
|
||||
[AgeRatingOrganization.PEGI]: PEGIRating,
|
||||
[AgeRatingOrganization.CERO]: CEROrating,
|
||||
[AgeRatingOrganization.USK]: USKRating,
|
||||
[AgeRatingOrganization.GRAC]: GRACRating,
|
||||
[AgeRatingOrganization.ClassInd]: ClassIndRating,
|
||||
[AgeRatingOrganization.ACB]: ACBRating,
|
||||
} as const satisfies Record<AgeRatingOrganization, Record<string, string>>;
|
||||
|
||||
export function getAvailableRatings(org: AgeRatingOrganization): string[] {
|
||||
return Object.values(RATINGS_FOR_ORGANIZATION[org]);
|
||||
}
|
||||
Reference in New Issue
Block a user