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:
AgentScrubbles
2026-07-26 11:42:16 +10:00
committed by GitHub
co-authored by Robert Clabough
parent 8e75a0bf56
commit 97c6f2c8a6
22 changed files with 704 additions and 5 deletions
+2
View File
@@ -10,6 +10,8 @@ services:
db:
image: postgres:14-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: drop
POSTGRES_PASSWORD: drop
+4
View File
@@ -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
+141
View File
@@ -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)
+6
View File
@@ -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}",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+26
View File
@@ -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"
@@ -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;
+25 -1
View File
@@ -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,
},
});
+109 -1
View File
@@ -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,
+104 -1
View File
@@ -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,
+21
View File
@@ -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,
+64 -2
View File
@@ -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
View File
@@ -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;
+80
View File
@@ -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]);
}