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
@@ -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;