From dad21617541c78233b01fd235e2c0c56993f1fb8 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sun, 11 May 2025 12:52:00 -0400 Subject: [PATCH] feat: games now have tag support --- .../migration.sql | 16 ++++ .../api/v1/admin/game/image/index.delete.ts | 6 +- server/internal/metadata/giantbomb.ts | 2 + server/internal/metadata/igdb.ts | 3 + server/internal/metadata/index.ts | 28 ++++++ server/internal/metadata/manual.ts | 1 + server/internal/metadata/pcgamingwiki.ts | 90 ++++++++++++++----- server/internal/metadata/types.d.ts | 2 + 8 files changed, 125 insertions(+), 23 deletions(-) create mode 100644 prisma/migrations/20250511154134_add_tags_to_games/migration.sql diff --git a/prisma/migrations/20250511154134_add_tags_to_games/migration.sql b/prisma/migrations/20250511154134_add_tags_to_games/migration.sql new file mode 100644 index 0000000..fb10131 --- /dev/null +++ b/prisma/migrations/20250511154134_add_tags_to_games/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "_GameToTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_GameToTag_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_GameToTag_B_index" ON "_GameToTag"("B"); + +-- AddForeignKey +ALTER TABLE "_GameToTag" ADD CONSTRAINT "_GameToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_GameToTag" ADD CONSTRAINT "_GameToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/api/v1/admin/game/image/index.delete.ts b/server/api/v1/admin/game/image/index.delete.ts index 9d9754e..9ef00ac 100644 --- a/server/api/v1/admin/game/image/index.delete.ts +++ b/server/api/v1/admin/game/image/index.delete.ts @@ -3,18 +3,18 @@ import prisma from "~/server/internal/db/database"; import objectHandler from "~/server/internal/objects"; import { type } from "arktype"; -const ModifyGameImage = type({ +const DeleteGameImage = type({ gameId: "string", imageId: "string", }); export default defineEventHandler<{ - body: typeof ModifyGameImage.infer; + body: typeof DeleteGameImage.infer; }>(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:image:delete"]); if (!allowed) throw createError({ statusCode: 403 }); - const body = ModifyGameImage(await readBody(h3)); + const body = DeleteGameImage(await readBody(h3)); if (body instanceof type.errors) { // hover out.summary to see validation errors console.error(body.summary); diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index 0a9922d..2441aa4 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -205,6 +205,8 @@ export class GiantBombProvider implements MetadataProvider { description: longDescription, released: releaseDate, + tags: [], + reviewCount: 0, reviewRating: 0, diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 1970d89..22ae57e 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -358,6 +358,9 @@ export class IGDBProvider implements MetadataProvider { publishers: [], developers: [], + // TODO: support tags + tags: [], + icon, bannerId: banner, coverId: icon, diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 9c1f03f..f50e8e4 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -110,6 +110,30 @@ export class MetadataHandler { ); } + private parseTags(tags: string[]) { + const results: { + where: { + name: string; + }; + create: { + name: string; + }; + }[] = []; + + tags.forEach((t) => + results.push({ + where: { + name: t, + }, + create: { + name: t, + }, + }), + ); + + return results; + } + async createGame( result: InternalGameMetadataResult, libraryBasePath: string, @@ -173,6 +197,10 @@ export class MetadataHandler { connect: metadata.developers, }, + tags: { + connectOrCreate: this.parseTags(metadata.tags), + }, + libraryBasePath, }, }); diff --git a/server/internal/metadata/manual.ts b/server/internal/metadata/manual.ts index fb3786c..d7b00dc 100644 --- a/server/internal/metadata/manual.ts +++ b/server/internal/metadata/manual.ts @@ -33,6 +33,7 @@ export class ManualMetadataProvider implements MetadataProvider { released: new Date(), publishers: [], developers: [], + tags: [], reviewCount: 0, reviewRating: 0, diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index 504c617..489e41a 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -48,12 +48,19 @@ interface PCGamingWikiSearchStub extends PCGamingWikiPage { } interface PCGamingWikiGame extends PCGamingWikiSearchStub { - Developers: string | null; - Genres: string | null; - Publishers: string | null; - Themes: string | null; + Developers: string | string[] | null; + Publishers: string | string[] | null; + + // TODO: save this somewhere, maybe a tag? Series: string | null; - Modes: string | null; + + // tags + Perspectives: string | string[] | null; // ie: First-person + Genres: string | string[] | null; // ie: Action, FPS + "Art styles": string | string[] | null; // ie: Stylized + Themes: string | string[] | null; // ie: Post-apocalyptic, Sci-fi, Space + Modes: string | string[] | null; // ie: Singleplayer, Multiplayer + Pacing: string | string[] | null; // ie: Real-time } interface PCGamingWikiCompany extends PCGamingWikiPage { @@ -78,6 +85,10 @@ interface PCGamingWikiCargoResult { }; } +type StringArrayKeys = { + [K in keyof T]: T[K] extends string | string[] | null ? K : never; +}[keyof T]; + // Api Docs: https://www.pcgamingwiki.com/wiki/PCGamingWiki:API // Good tool for helping build cargo queries: https://www.pcgamingwiki.com/wiki/Special:CargoQuery export class PCGamingWikiProvider implements MetadataProvider { @@ -135,6 +146,8 @@ export class PCGamingWikiProvider implements MetadataProvider { const $ = cheerio.load(res.data.parse.text["*"]); // get intro based on 'introduction' class const introductionEle = $(".introduction").first(); + // remove citations from intro + introductionEle.find("sup").remove(); return { shortIntro: introductionEle.find("p").first().text(), @@ -175,20 +188,33 @@ export class PCGamingWikiProvider implements MetadataProvider { } /** - * Parses the specific format that the wiki returns when specifying a company - * @param companyStr + * Parses the specific format that the wiki returns when specifying an array + * @param input string or array * @returns */ - private parseCompanyStr(companyStr: string): string[] { - const results: string[] = []; - // provides the string as a list of companies - // ie: "Company:Digerati Distribution,Company:Greylock Studio" - const items = companyStr.split(","); + private parseWikiStringArray(input: string | string[]): string[] { + const cleanStr = (str: string): string => { + // remove any dumb prefixes we don't care about + return str.replace("Company:", "").trim(); + }; - items.forEach((item) => { - // remove the `Company:` and trim and whitespace - results.push(item.replace("Company:", "").trim()); - }); + // input can provides the string as a list + // ie: "Company:Digerati Distribution,Company:Greylock Studio" + // or as an array, sometimes the array has empty values + + const results: string[] = []; + if (Array.isArray(input)) { + input.forEach((c) => { + const clean = cleanStr(c); + if (clean !== "") results.push(clean); + }); + } else { + const items = input.split(","); + items.forEach((item) => { + const clean = cleanStr(item); + if (clean !== "") results.push(clean); + }); + } return results; } @@ -209,6 +235,28 @@ export class PCGamingWikiProvider implements MetadataProvider { return websiteStr.replaceAll(/\[|]/g, "").split(" ")[0] ?? ""; } + private compileTags(game: PCGamingWikiGame): string[] { + const results: string[] = []; + + const properties: StringArrayKeys[] = [ + "Art styles", + "Genres", + "Modes", + "Pacing", + "Perspectives", + "Themes", + ]; + + // loop through all above keys, get the tags they contain + properties.forEach((p) => { + if (game[p] === null) return; + + results.push(...this.parseWikiStringArray(game[p])); + }); + + return results; + } + async fetchGame({ id, name, @@ -220,7 +268,7 @@ export class PCGamingWikiProvider implements MetadataProvider { action: "cargoquery", tables: "Infobox_game", fields: - "Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Developers,Infobox_game.Released,Infobox_game.Genres,Infobox_game.Publishers,Infobox_game.Themes,Infobox_game.Series,Infobox_game.Modes", + "Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Developers,Infobox_game.Released,Infobox_game.Genres,Infobox_game.Publishers,Infobox_game.Themes,Infobox_game.Series,Infobox_game.Modes,Infobox_game.Perspectives,Infobox_game.Art_styles,Infobox_game.Pacing", where: `Infobox_game._pageID="${id}"`, format: "json", }); @@ -236,7 +284,7 @@ export class PCGamingWikiProvider implements MetadataProvider { const publishers: Company[] = []; if (game.Publishers !== null) { - const pubListClean = this.parseCompanyStr(game.Publishers); + const pubListClean = this.parseWikiStringArray(game.Publishers); for (const pub of pubListClean) { const res = await publisher(pub); if (res === undefined) continue; @@ -246,7 +294,7 @@ export class PCGamingWikiProvider implements MetadataProvider { const developers: Company[] = []; if (game.Developers !== null) { - const devListClean = this.parseCompanyStr(game.Developers); + const devListClean = this.parseWikiStringArray(game.Developers); for (const dev of devListClean) { const res = await developer(dev); if (res === undefined) continue; @@ -268,6 +316,8 @@ export class PCGamingWikiProvider implements MetadataProvider { ? DateTime.fromISO(game.Released.split(";")[0]).toJSDate() : new Date(), + tags: this.compileTags(game), + reviewCount: 0, reviewRating: 0, @@ -305,7 +355,7 @@ export class PCGamingWikiProvider implements MetadataProvider { const company = res.data.cargoquery[i].title; const fixedCompanyName = - this.parseCompanyStr(company.PageName)[0] ?? company.PageName; + this.parseWikiStringArray(company.PageName)[0] ?? company.PageName; const metadata: CompanyMetadata = { id: company.PageID, diff --git a/server/internal/metadata/types.d.ts b/server/internal/metadata/types.d.ts index 22f30f8..b627041 100644 --- a/server/internal/metadata/types.d.ts +++ b/server/internal/metadata/types.d.ts @@ -30,6 +30,8 @@ export interface GameMetadata { publishers: Company[]; developers: Company[]; + tags: string[]; + reviewCount: number; reviewRating: number;