mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-08-24 23:32:04 +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:
|
db:
|
||||||
image: postgres:14-alpine
|
image: postgres:14-alpine
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: drop
|
POSTGRES_USER: drop
|
||||||
POSTGRES_PASSWORD: drop
|
POSTGRES_PASSWORD: drop
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ DATABASE_URL="postgres://drop:drop@127.0.0.1:5432/drop"
|
|||||||
|
|
||||||
GIANT_BOMB_API_KEY=""
|
GIANT_BOMB_API_KEY=""
|
||||||
|
|
||||||
|
# Optional, IGDB client information
|
||||||
|
IGDB_CLIENT_ID=""
|
||||||
|
IGDB_CLIENT_SECRET=""
|
||||||
|
|
||||||
EXTERNAL_URL="http://localhost:3000"
|
EXTERNAL_URL="http://localhost:3000"
|
||||||
|
|
||||||
NUXT_PORT=4000
|
NUXT_PORT=4000
|
||||||
|
|||||||
@@ -52,6 +52,92 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- image carousel pick -->
|
<!-- image carousel pick -->
|
||||||
@@ -468,6 +554,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { GameModel } from "~/prisma/client/models";
|
import type { GameModel } from "~/prisma/client/models";
|
||||||
|
import { AgeRatingOrganization } from "~/prisma/client/enums";
|
||||||
|
import { getAvailableRatings } from "~/utils/ageRatings";
|
||||||
import { micromark } from "micromark";
|
import { micromark } from "micromark";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@@ -521,6 +609,59 @@ watch(
|
|||||||
{ deep: true },
|
{ 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(
|
const releaseDate = ref(
|
||||||
game.value.mReleased
|
game.value.mReleased
|
||||||
? new Date(game.value.mReleased).toISOString().substring(0, 10)
|
? new Date(game.value.mReleased).toISOString().substring(0, 10)
|
||||||
|
|||||||
@@ -318,9 +318,13 @@
|
|||||||
"detectedGame": "Drop hat erkannt, dass du ein neues Spiel importieren kannst.",
|
"detectedGame": "Drop hat erkannt, dass du ein neues Spiel importieren kannst.",
|
||||||
"detectedVersion": "Drop hat erkannt, dass du eine neue Version dieses Spiels importieren kannst.",
|
"detectedVersion": "Drop hat erkannt, dass du eine neue Version dieses Spiels importieren kannst.",
|
||||||
"game": {
|
"game": {
|
||||||
|
"addAgeRating": "Bewertung hinzufügen",
|
||||||
"addCarouselNoImages": "Keine Bilder zum hinzufügen.",
|
"addCarouselNoImages": "Keine Bilder zum hinzufügen.",
|
||||||
"addDescriptionNoImages": "Keine Bilder zum hinzufügen.",
|
"addDescriptionNoImages": "Keine Bilder zum hinzufügen.",
|
||||||
"addImageCarousel": "Aus der Bilderbibliothek hinzufügen",
|
"addImageCarousel": "Aus der Bilderbibliothek hinzufügen",
|
||||||
|
"ageRatingLabel": "{organization}: {rating}",
|
||||||
|
"ageRatings": "Altersfreigaben",
|
||||||
|
"ageRatingsEmpty": "Keine Altersfreigaben festgelegt",
|
||||||
"currentBanner": "Banner",
|
"currentBanner": "Banner",
|
||||||
"currentCover": "Cover",
|
"currentCover": "Cover",
|
||||||
"deleteImage": "Bild löschen",
|
"deleteImage": "Bild löschen",
|
||||||
@@ -332,6 +336,7 @@
|
|||||||
"imageCarouselEmpty": "Es wurden noch keine Bilder zum Karussell hinzugefügt.",
|
"imageCarouselEmpty": "Es wurden noch keine Bilder zum Karussell hinzugefügt.",
|
||||||
"imageLibrary": "Bilderbibliothek",
|
"imageLibrary": "Bilderbibliothek",
|
||||||
"imageLibraryDescription": "Bitte beachten: Alle hochgeladenen Bilder sind für alle Nutzer über die Browser-Entwicklertools zugänglich.",
|
"imageLibraryDescription": "Bitte beachten: Alle hochgeladenen Bilder sind für alle Nutzer über die Browser-Entwicklertools zugänglich.",
|
||||||
|
"removeAgeRating": "Entfernen",
|
||||||
"removeImageCarousel": "Bild entfernen",
|
"removeImageCarousel": "Bild entfernen",
|
||||||
"setBanner": "Als Banner festlegen",
|
"setBanner": "Als Banner festlegen",
|
||||||
"setCover": "Als Cover festlegen"
|
"setCover": "Als Cover festlegen"
|
||||||
@@ -586,6 +591,7 @@
|
|||||||
},
|
},
|
||||||
"store": {
|
"store": {
|
||||||
"about": "Über",
|
"about": "Über",
|
||||||
|
"ageRating": "Altersfreigabe",
|
||||||
"commingSoon": "Demnächst verfügbar",
|
"commingSoon": "Demnächst verfügbar",
|
||||||
"developers": "Entwickler | Entwickler | Entwickler",
|
"developers": "Entwickler | Entwickler | Entwickler",
|
||||||
"exploreMore": "Mehr entdecken {arrow}",
|
"exploreMore": "Mehr entdecken {arrow}",
|
||||||
|
|||||||
@@ -261,9 +261,13 @@
|
|||||||
"detectedGame": "Drop has found new plunder to import, argh!",
|
"detectedGame": "Drop has found new plunder to import, argh!",
|
||||||
"detectedVersion": "Drop has found new versions of this plunder to import, savvy!",
|
"detectedVersion": "Drop has found new versions of this plunder to import, savvy!",
|
||||||
"game": {
|
"game": {
|
||||||
|
"addAgeRating": "Add Rating",
|
||||||
"addCarouselNoImages": "No images to add, ye dog.",
|
"addCarouselNoImages": "No images to add, ye dog.",
|
||||||
"addDescriptionNoImages": "No images to add, argh.",
|
"addDescriptionNoImages": "No images to add, argh.",
|
||||||
"addImageCarousel": "Add from image treasure hoard",
|
"addImageCarousel": "Add from image treasure hoard",
|
||||||
|
"ageRatingLabel": "{organization}: {rating}",
|
||||||
|
"ageRatings": "Age Ratings, Savvy?",
|
||||||
|
"ageRatingsEmpty": "Age ratings plundered",
|
||||||
"currentBanner": "banner",
|
"currentBanner": "banner",
|
||||||
"currentCover": "cover",
|
"currentCover": "cover",
|
||||||
"deleteImage": "Scuttle image",
|
"deleteImage": "Scuttle image",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"imageCarouselEmpty": "No images added to the carousel yet, argh.",
|
"imageCarouselEmpty": "No images added to the carousel yet, argh.",
|
||||||
"imageLibrary": "Image treasure hoard",
|
"imageLibrary": "Image treasure hoard",
|
||||||
"imageLibraryDescription": "Please note all images hoisted be accessible to all crew through browser dev-tools, savvy.",
|
"imageLibraryDescription": "Please note all images hoisted be accessible to all crew through browser dev-tools, savvy.",
|
||||||
|
"removeAgeRating": "Remove",
|
||||||
"removeImageCarousel": "Remove image",
|
"removeImageCarousel": "Remove image",
|
||||||
"setBanner": "Set as banner",
|
"setBanner": "Set as banner",
|
||||||
"setCover": "Set as cover"
|
"setCover": "Set as cover"
|
||||||
@@ -412,6 +417,7 @@
|
|||||||
"selectLanguage": "Pick yer tongue",
|
"selectLanguage": "Pick yer tongue",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"store": {
|
"store": {
|
||||||
|
"ageRating": "Age Rating, ye landlubber!",
|
||||||
"commingSoon": "comin' soon, argh!",
|
"commingSoon": "comin' soon, argh!",
|
||||||
"exploreMore": "Explore more {arrow}, ye dog!",
|
"exploreMore": "Explore more {arrow}, ye dog!",
|
||||||
"images": "Plunder Images",
|
"images": "Plunder Images",
|
||||||
|
|||||||
@@ -381,9 +381,13 @@
|
|||||||
"noSelected": "No extensions selected."
|
"noSelected": "No extensions selected."
|
||||||
},
|
},
|
||||||
"game": {
|
"game": {
|
||||||
|
"addAgeRating": "Add Rating",
|
||||||
"addCarouselNoImages": "No images to add.",
|
"addCarouselNoImages": "No images to add.",
|
||||||
"addDescriptionNoImages": "No images to add.",
|
"addDescriptionNoImages": "No images to add.",
|
||||||
"addImageCarousel": "Add from image library",
|
"addImageCarousel": "Add from image library",
|
||||||
|
"ageRatingLabel": "{organization}: {rating}",
|
||||||
|
"ageRatings": "Age Ratings",
|
||||||
|
"ageRatingsEmpty": "No age ratings set",
|
||||||
"currentBanner": "banner",
|
"currentBanner": "banner",
|
||||||
"currentCover": "cover",
|
"currentCover": "cover",
|
||||||
"deleteImage": "Delete image",
|
"deleteImage": "Delete image",
|
||||||
@@ -395,6 +399,7 @@
|
|||||||
"imageCarouselEmpty": "No images added to the carousel yet.",
|
"imageCarouselEmpty": "No images added to the carousel yet.",
|
||||||
"imageLibrary": "Image library",
|
"imageLibrary": "Image library",
|
||||||
"imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.",
|
"imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.",
|
||||||
|
"removeAgeRating": "Remove",
|
||||||
"removeImageCarousel": "Remove image",
|
"removeImageCarousel": "Remove image",
|
||||||
"setBanner": "Set as banner",
|
"setBanner": "Set as banner",
|
||||||
"setCover": "Set as cover"
|
"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."
|
"welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works."
|
||||||
},
|
},
|
||||||
"store": {
|
"store": {
|
||||||
|
"ageRating": "Age Rating",
|
||||||
"commingSoon": "coming soon",
|
"commingSoon": "coming soon",
|
||||||
"developers": "Developers | Developer | Developers",
|
"developers": "Developers | Developer | Developers",
|
||||||
"featured": "Featured",
|
"featured": "Featured",
|
||||||
|
|||||||
@@ -318,9 +318,13 @@
|
|||||||
"detectedGame": "Drop a détecté que vous avez des nouveaux jeux a importer.",
|
"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.",
|
"detectedVersion": "Drop a détecté que vous avez des nouvelles versions de ce jeu à importer.",
|
||||||
"game": {
|
"game": {
|
||||||
|
"addAgeRating": "Ajouter une classification",
|
||||||
"addCarouselNoImages": "Pas d'image a ajouter.",
|
"addCarouselNoImages": "Pas d'image a ajouter.",
|
||||||
"addDescriptionNoImages": "Pas d'image à ajouter.",
|
"addDescriptionNoImages": "Pas d'image à ajouter.",
|
||||||
"addImageCarousel": "Ajouter à partir d'une bibliothèque d'images",
|
"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",
|
"currentBanner": "bannière",
|
||||||
"currentCover": "couverture",
|
"currentCover": "couverture",
|
||||||
"deleteImage": "Supprimer l'image",
|
"deleteImage": "Supprimer l'image",
|
||||||
@@ -332,6 +336,7 @@
|
|||||||
"imageCarouselEmpty": "Aucune image n'a encore été ajoutée au carousel.",
|
"imageCarouselEmpty": "Aucune image n'a encore été ajoutée au carousel.",
|
||||||
"imageLibrary": "Bibliothèque d'images",
|
"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.",
|
"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",
|
"removeImageCarousel": "Retirer l'image",
|
||||||
"setBanner": "Définir comme bannière",
|
"setBanner": "Définir comme bannière",
|
||||||
"setCover": "Définir comme couverture"
|
"setCover": "Définir comme couverture"
|
||||||
@@ -586,6 +591,7 @@
|
|||||||
},
|
},
|
||||||
"store": {
|
"store": {
|
||||||
"about": "À propos",
|
"about": "À propos",
|
||||||
|
"ageRating": "Classification d'âge",
|
||||||
"commingSoon": "prochainement",
|
"commingSoon": "prochainement",
|
||||||
"developers": "Développeurs | Développeur | Développeurs",
|
"developers": "Développeurs | Développeur | Développeurs",
|
||||||
"exploreMore": "Explorer plus {arrow}",
|
"exploreMore": "Explorer plus {arrow}",
|
||||||
|
|||||||
@@ -304,9 +304,13 @@
|
|||||||
"detectedGame": "Drop wykrył że masz nowe gry do zaimportowania.",
|
"detectedGame": "Drop wykrył że masz nowe gry do zaimportowania.",
|
||||||
"detectedVersion": "Drop wykrył że masz nowe wersje tej gry do zaimportowania.",
|
"detectedVersion": "Drop wykrył że masz nowe wersje tej gry do zaimportowania.",
|
||||||
"game": {
|
"game": {
|
||||||
|
"addAgeRating": "Dodaj kategorię",
|
||||||
"addCarouselNoImages": "Brak obrazów do dodania.",
|
"addCarouselNoImages": "Brak obrazów do dodania.",
|
||||||
"addDescriptionNoImages": "Brak obrazów do dodania.",
|
"addDescriptionNoImages": "Brak obrazów do dodania.",
|
||||||
"addImageCarousel": "Dodaj z galerii obrazów",
|
"addImageCarousel": "Dodaj z galerii obrazów",
|
||||||
|
"ageRatingLabel": "{organization}: {rating}",
|
||||||
|
"ageRatings": "Kategorie wiekowe",
|
||||||
|
"ageRatingsEmpty": "Brak kategorii wiekowych",
|
||||||
"currentBanner": "baner",
|
"currentBanner": "baner",
|
||||||
"currentCover": "okładka",
|
"currentCover": "okładka",
|
||||||
"deleteImage": "Usuń obraz",
|
"deleteImage": "Usuń obraz",
|
||||||
@@ -318,6 +322,7 @@
|
|||||||
"imageCarouselEmpty": "Nie dodano jeszcze żadnych zdjęć do karuzeli.",
|
"imageCarouselEmpty": "Nie dodano jeszcze żadnych zdjęć do karuzeli.",
|
||||||
"imageLibrary": "Biblioteka Obrazów",
|
"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.",
|
"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",
|
"removeImageCarousel": "Usuń zdjęcie",
|
||||||
"setBanner": "Ustaw jako baner",
|
"setBanner": "Ustaw jako baner",
|
||||||
"setCover": "Ustaw jako okładke"
|
"setCover": "Ustaw jako okładke"
|
||||||
@@ -562,6 +567,7 @@
|
|||||||
},
|
},
|
||||||
"store": {
|
"store": {
|
||||||
"about": "O",
|
"about": "O",
|
||||||
|
"ageRating": "Kategoria wiekowa",
|
||||||
"commingSoon": "wkrótce",
|
"commingSoon": "wkrótce",
|
||||||
"developers": "Producentów | Producent | Producentów",
|
"developers": "Producentów | Producent | Producentów",
|
||||||
"exploreMore": "Odkryj więcej {arrow}",
|
"exploreMore": "Odkryj więcej {arrow}",
|
||||||
|
|||||||
@@ -145,6 +145,32 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td
|
<td
|
||||||
class="whitespace-nowrap align-top py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
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[];
|
ALTER TABLE "Game" ALTER COLUMN "mImageCarouselObjectIds" SET DEFAULT ARRAY[]::TEXT[];
|
||||||
UPDATE "Game" SET "mImageCarouselObjectIds" = '{}' WHERE "mImageCarouselObjectIds" IS NULL;
|
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
|
-- CreateIndex
|
||||||
CREATE INDEX "Game_mName_idx" ON "Game" USING GIST ("mName" gist_trgm_ops(siglen=32));
|
CREATE INDEX "Game_mName_idx" ON "Game" USING GIST ("mName" gist_trgm_ops(siglen=32));
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
|
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;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ model Game {
|
|||||||
mReleased DateTime // When the game was released
|
mReleased DateTime // When the game was released
|
||||||
|
|
||||||
ratings GameRating[]
|
ratings GameRating[]
|
||||||
|
ageRatings GameAgeRating[]
|
||||||
|
|
||||||
featured Boolean @default(false)
|
featured Boolean @default(false)
|
||||||
|
|
||||||
@@ -73,6 +74,29 @@ model GameTag {
|
|||||||
@@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist)
|
@@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 {
|
model GameRating {
|
||||||
id String @id @default(uuid())
|
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;
|
tags: true;
|
||||||
|
ageRatings: true;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ export default defineEventHandler<
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
tags: true,
|
tags: true,
|
||||||
|
ageRatings: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default defineEventHandler(async (h3) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
tags: true,
|
tags: true,
|
||||||
|
ageRatings: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CompanyModel } from "~/prisma/client/models";
|
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 type { MetadataProvider } from ".";
|
||||||
import { MissingMetadataProviderConfig } from ".";
|
import { MissingMetadataProviderConfig } from ".";
|
||||||
import type {
|
import type {
|
||||||
@@ -9,11 +9,20 @@ import type {
|
|||||||
_FetchCompanyMetadataParams,
|
_FetchCompanyMetadataParams,
|
||||||
CompanyMetadata,
|
CompanyMetadata,
|
||||||
GameMetadataRating,
|
GameMetadataRating,
|
||||||
|
GameMetadataAgeRating,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import TurndownService from "turndown";
|
import TurndownService from "turndown";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import type { TaskRunContext } from "../tasks";
|
import type { TaskRunContext } from "../tasks";
|
||||||
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
|
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
|
||||||
|
import {
|
||||||
|
ESRBRating,
|
||||||
|
PEGIRating,
|
||||||
|
CEROrating,
|
||||||
|
USKRating,
|
||||||
|
GRACRating,
|
||||||
|
ACBRating,
|
||||||
|
} from "~/utils/ageRatings";
|
||||||
|
|
||||||
interface GiantBombResponseType<T> {
|
interface GiantBombResponseType<T> {
|
||||||
error: "OK" | string;
|
error: "OK" | string;
|
||||||
@@ -79,6 +88,67 @@ interface ReviewResult {
|
|||||||
site_detail_url: string;
|
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 {
|
interface CompanySearchResult {
|
||||||
guid: string;
|
guid: string;
|
||||||
deck: string | null;
|
deck: string | null;
|
||||||
@@ -246,6 +316,43 @@ export class GiantBombProvider implements MetadataProvider {
|
|||||||
|
|
||||||
const tags = (gameData.genres ?? []).map((e) => e.name);
|
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 = {
|
const metadata: GameMetadata = {
|
||||||
id: gameData.guid,
|
id: gameData.guid,
|
||||||
name: gameData.name,
|
name: gameData.name,
|
||||||
@@ -256,6 +363,7 @@ export class GiantBombProvider implements MetadataProvider {
|
|||||||
tags,
|
tags,
|
||||||
|
|
||||||
reviews,
|
reviews,
|
||||||
|
ageRatings,
|
||||||
|
|
||||||
publishers,
|
publishers,
|
||||||
developers,
|
developers,
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import type { CompanyModel } from "~/prisma/client/models";
|
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 type { MetadataProvider } from ".";
|
||||||
import { MissingMetadataProviderConfig } from ".";
|
import { MissingMetadataProviderConfig } from ".";
|
||||||
import type {
|
import type {
|
||||||
GameMetadataSearchResult,
|
GameMetadataSearchResult,
|
||||||
|
GameMetadataAgeRating,
|
||||||
_FetchGameMetadataParams,
|
_FetchGameMetadataParams,
|
||||||
GameMetadata,
|
GameMetadata,
|
||||||
_FetchCompanyMetadataParams,
|
_FetchCompanyMetadataParams,
|
||||||
@@ -81,6 +91,69 @@ interface IGDBSearchStub extends IGDBItem {
|
|||||||
summary: string;
|
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
|
// https://api-docs.igdb.com/?shell#game
|
||||||
interface IGDBGameFull extends IGDBSearchStub {
|
interface IGDBGameFull extends IGDBSearchStub {
|
||||||
age_ratings?: IGDBID[];
|
age_ratings?: IGDBID[];
|
||||||
@@ -302,6 +375,34 @@ export class IGDBProvider implements MetadataProvider {
|
|||||||
return results;
|
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() {
|
name() {
|
||||||
return "IGDB";
|
return "IGDB";
|
||||||
}
|
}
|
||||||
@@ -444,6 +545,7 @@ export class IGDBProvider implements MetadataProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const genres = await this.getGenres(currentGame.genres);
|
const genres = await this.getGenres(currentGame.genres);
|
||||||
|
const ageRatings = await this.getAgeRatings(currentGame.age_ratings);
|
||||||
|
|
||||||
let description = "";
|
let description = "";
|
||||||
let shortDescription = "";
|
let shortDescription = "";
|
||||||
@@ -468,6 +570,7 @@ export class IGDBProvider implements MetadataProvider {
|
|||||||
|
|
||||||
genres,
|
genres,
|
||||||
reviews: [review],
|
reviews: [review],
|
||||||
|
ageRatings,
|
||||||
|
|
||||||
publishers,
|
publishers,
|
||||||
developers,
|
developers,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
_FetchGameMetadataParams,
|
_FetchGameMetadataParams,
|
||||||
_FetchCompanyMetadataParams,
|
_FetchCompanyMetadataParams,
|
||||||
GameMetadata,
|
GameMetadata,
|
||||||
|
GameMetadataAgeRating,
|
||||||
GameMetadataSearchResult,
|
GameMetadataSearchResult,
|
||||||
InternalGameMetadataResult,
|
InternalGameMetadataResult,
|
||||||
CompanyMetadata,
|
CompanyMetadata,
|
||||||
@@ -176,6 +177,20 @@ export class MetadataHandler {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseAgeRatings(ageRatings: GameMetadataAgeRating[], gameId: string) {
|
||||||
|
return ageRatings.map((ar) => ({
|
||||||
|
where: {
|
||||||
|
gameOrganizationKey: {
|
||||||
|
gameId,
|
||||||
|
organization: ar.organization,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
...ar,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async createGame(
|
async createGame(
|
||||||
result: { sourceId: string; id: string; name: string },
|
result: { sourceId: string; id: string; name: string },
|
||||||
libraryId: string,
|
libraryId: string,
|
||||||
@@ -287,6 +302,12 @@ export class MetadataHandler {
|
|||||||
ratings: {
|
ratings: {
|
||||||
connectOrCreate: metadataHandler.parseRatings(metadata.reviews),
|
connectOrCreate: metadataHandler.parseRatings(metadata.reviews),
|
||||||
},
|
},
|
||||||
|
ageRatings: {
|
||||||
|
connectOrCreate: metadataHandler.parseAgeRatings(
|
||||||
|
metadata.ageRatings,
|
||||||
|
gameId,
|
||||||
|
),
|
||||||
|
},
|
||||||
tags: {
|
tags: {
|
||||||
connect: await metadataHandler.parseTags(metadata.tags),
|
connect: await metadataHandler.parseTags(metadata.tags),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class ManualMetadataProvider implements MetadataProvider {
|
|||||||
developers: [],
|
developers: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
reviews: [],
|
reviews: [],
|
||||||
|
ageRatings: [],
|
||||||
|
|
||||||
icon: iconId,
|
icon: iconId,
|
||||||
coverId: iconId,
|
coverId: iconId,
|
||||||
|
|||||||
@@ -445,6 +445,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
|
|||||||
tags: this.compileTags(game),
|
tags: this.compileTags(game),
|
||||||
|
|
||||||
reviews: pageContent.reception.filter((v) => typeof v !== "undefined"),
|
reviews: pageContent.reception.filter((v) => typeof v !== "undefined"),
|
||||||
|
ageRatings: [],
|
||||||
publishers,
|
publishers,
|
||||||
developers,
|
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 { MetadataProvider } from ".";
|
||||||
import type {
|
import type {
|
||||||
GameMetadataSearchResult,
|
GameMetadataSearchResult,
|
||||||
@@ -7,6 +13,7 @@ import type {
|
|||||||
_FetchCompanyMetadataParams,
|
_FetchCompanyMetadataParams,
|
||||||
CompanyMetadata,
|
CompanyMetadata,
|
||||||
GameMetadataRating,
|
GameMetadataRating,
|
||||||
|
GameMetadataAgeRating,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { TaskRunContext } from "../tasks";
|
import type { TaskRunContext } from "../tasks";
|
||||||
import * as jdenticon from "jdenticon";
|
import * as jdenticon from "jdenticon";
|
||||||
@@ -143,6 +150,12 @@ interface SteamTagsPackage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SteamRatingEntry {
|
||||||
|
rating: string;
|
||||||
|
descriptors?: string;
|
||||||
|
required_age?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SteamWebAppDetailsSmall {
|
interface SteamWebAppDetailsSmall {
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -162,6 +175,7 @@ interface SteamWebAppDetailsSmall {
|
|||||||
mac_requirements: { minimum: string; recommended: string };
|
mac_requirements: { minimum: string; recommended: string };
|
||||||
linux_requirements: { minimum: string; recommended: string };
|
linux_requirements: { minimum: string; recommended: string };
|
||||||
legal_notice: string;
|
legal_notice: string;
|
||||||
|
ratings?: Record<string, SteamRatingEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SteamWebAppDetailsLarge extends SteamWebAppDetailsSmall {
|
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 {
|
export class SteamProvider implements MetadataProvider {
|
||||||
name() {
|
name() {
|
||||||
return "Steam";
|
return "Steam";
|
||||||
@@ -363,7 +399,7 @@ export class SteamProvider implements MetadataProvider {
|
|||||||
context?.logger.info("Fetching detailed description and reviews...");
|
context?.logger.info("Fetching detailed description and reviews...");
|
||||||
const webAppDetails = (await this._getWebAppDetails(
|
const webAppDetails = (await this._getWebAppDetails(
|
||||||
id,
|
id,
|
||||||
"metacritic",
|
"metacritic,ratings",
|
||||||
)) as SteamWebAppDetailsLarge;
|
)) as SteamWebAppDetailsLarge;
|
||||||
|
|
||||||
const detailedDescription =
|
const detailedDescription =
|
||||||
@@ -407,6 +443,13 @@ export class SteamProvider implements MetadataProvider {
|
|||||||
`Steam reviews: ${steamReviewCount} reviews, ${steamRating}% positive`,
|
`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) {
|
if (webAppDetails?.metacritic) {
|
||||||
reviews.push({
|
reviews.push({
|
||||||
metadataId: id,
|
metadataId: id,
|
||||||
@@ -437,6 +480,7 @@ export class SteamProvider implements MetadataProvider {
|
|||||||
developers,
|
developers,
|
||||||
tags,
|
tags,
|
||||||
reviews,
|
reviews,
|
||||||
|
ageRatings,
|
||||||
icon,
|
icon,
|
||||||
bannerId: banner,
|
bannerId: banner,
|
||||||
coverId: cover,
|
coverId: cover,
|
||||||
@@ -795,6 +839,24 @@ export class SteamProvider implements MetadataProvider {
|
|||||||
return appData;
|
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 {
|
private _getImageUrl(filename: string, format?: string): string {
|
||||||
if (!filename || filename.trim().length === 0) return "";
|
if (!filename || filename.trim().length === 0) return "";
|
||||||
|
|
||||||
|
|||||||
+8
@@ -1,4 +1,5 @@
|
|||||||
import type { Company, GameRating } from "~/prisma/client";
|
import type { Company, GameRating } from "~/prisma/client";
|
||||||
|
import type { AgeRatingOrganization } from "~/prisma/client/enums";
|
||||||
import type { TransactionDataType } from "../objects/transactional";
|
import type { TransactionDataType } from "../objects/transactional";
|
||||||
import type { ObjectReference } from "../objects/objectHandler";
|
import type { ObjectReference } from "../objects/objectHandler";
|
||||||
|
|
||||||
@@ -27,6 +28,12 @@ export type GameMetadataRating = Pick<
|
|||||||
| "mReviewRating"
|
| "mReviewRating"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export interface GameMetadataAgeRating {
|
||||||
|
organization: AgeRatingOrganization;
|
||||||
|
rating: string;
|
||||||
|
ratingCoverUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameMetadata {
|
export interface GameMetadata {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -42,6 +49,7 @@ export interface GameMetadata {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
|
|
||||||
reviews: GameMetadataRating[];
|
reviews: GameMetadataRating[];
|
||||||
|
ageRatings: GameMetadataAgeRating[];
|
||||||
|
|
||||||
// Created with another utility function
|
// Created with another utility function
|
||||||
icon: ObjectReference;
|
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