feat: unified company metadata store

still need to migrate users from old developer and publisher tables
This commit is contained in:
Huskydog9988
2025-05-08 20:35:15 -04:00
committed by DecDuck
parent 14f0833d17
commit afaaaf2eb5
9 changed files with 159 additions and 65 deletions

View File

@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE "Company" (
"id" TEXT NOT NULL,
"metadataSource" "MetadataSource" NOT NULL,
"metadataId" TEXT NOT NULL,
"metadataOriginalQuery" TEXT NOT NULL,
"mName" TEXT NOT NULL,
"mShortDescription" TEXT NOT NULL,
"mDescription" TEXT NOT NULL,
"mLogoObjectId" TEXT NOT NULL,
"mBannerObjectId" TEXT NOT NULL,
"mWebsite" TEXT NOT NULL,
CONSTRAINT "Company_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CompanyGameRelation" (
"companyId" TEXT NOT NULL,
"gameId" TEXT NOT NULL,
"developer" BOOLEAN NOT NULL DEFAULT false,
"publisher" BOOLEAN NOT NULL DEFAULT false
);
-- CreateIndex
CREATE UNIQUE INDEX "Company_metadataSource_metadataId_key" ON "Company"("metadataSource", "metadataId");
-- CreateIndex
CREATE UNIQUE INDEX "CompanyGameRelation_companyId_gameId_key" ON "CompanyGameRelation"("companyId", "gameId");
-- AddForeignKey
ALTER TABLE "CompanyGameRelation" ADD CONSTRAINT "CompanyGameRelation_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CompanyGameRelation" ADD CONSTRAINT "CompanyGameRelation_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -33,9 +33,10 @@ model Game {
versions GameVersion[]
libraryBasePath String @unique // Base dir for all the game versions
collections CollectionEntry[]
saves SaveSlot[]
screenshots Screenshot[]
collections CollectionEntry[]
saves SaveSlot[]
screenshots Screenshot[]
companyRelations CompanyGameRelation[]
@@unique([metadataSource, metadataId], name: "metadataKey")
}
@ -102,6 +103,38 @@ model Screenshot {
@@index([gameId, userId])
}
model Company {
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
metadataOriginalQuery String
mName String
mShortDescription String
mDescription String
mLogoObjectId String
mBannerObjectId String
mWebsite String
gameRelations CompanyGameRelation[]
@@unique([metadataSource, metadataId], name: "metadataKey")
}
model CompanyGameRelation {
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
// what the company did for the game
developer Boolean @default(false)
publisher Boolean @default(false)
@@unique([companyId, gameId], name: "companyGame")
}
model Developer {
id String @id @default(uuid())

View File

@ -7,9 +7,8 @@ import type {
_FetchGameMetadataParams,
GameMetadata,
_FetchPublisherMetadataParams,
PublisherMetadata,
CompanyMetadata,
_FetchDeveloperMetadataParams,
DeveloperMetadata,
} from "./types";
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
@ -125,9 +124,6 @@ export class GiantBombProvider implements MetadataProvider {
return response;
}
id() {
return "giantbomb";
}
name() {
return "GiantBomb";
}
@ -229,7 +225,7 @@ export class GiantBombProvider implements MetadataProvider {
async fetchPublisher({
query,
createObject,
}: _FetchPublisherMetadataParams): Promise<PublisherMetadata> {
}: _FetchPublisherMetadataParams): Promise<CompanyMetadata> {
const results = await this.request<Array<CompanySearchResult>>(
"search",
"",
@ -246,7 +242,7 @@ export class GiantBombProvider implements MetadataProvider {
? this.turndown.turndown(company.description)
: company.deck;
const metadata: PublisherMetadata = {
const metadata: CompanyMetadata = {
id: company.guid,
name: company.name,
shortDescription: company.deck ?? "",
@ -261,7 +257,7 @@ export class GiantBombProvider implements MetadataProvider {
}
async fetchDeveloper(
params: _FetchDeveloperMetadataParams,
): Promise<DeveloperMetadata> {
): Promise<CompanyMetadata> {
return await this.fetchPublisher(params);
}
}

View File

@ -7,9 +7,8 @@ import type {
_FetchGameMetadataParams,
GameMetadata,
_FetchPublisherMetadataParams,
PublisherMetadata,
CompanyMetadata,
_FetchDeveloperMetadataParams,
DeveloperMetadata,
} from "./types";
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
@ -265,9 +264,6 @@ export class IGDBProvider implements MetadataProvider {
return msg.length > len ? msg.substring(0, 280) + "..." : msg;
}
id() {
return "igdb";
}
name() {
return "IGDB";
}
@ -375,7 +371,7 @@ export class IGDBProvider implements MetadataProvider {
async fetchPublisher({
query,
createObject,
}: _FetchPublisherMetadataParams): Promise<PublisherMetadata> {
}: _FetchPublisherMetadataParams): Promise<CompanyMetadata> {
const response = await this.request<IGDBCompany>(
"companies",
`where name = "${query}"; fields *; limit 1;`,
@ -395,7 +391,7 @@ export class IGDBProvider implements MetadataProvider {
if (company_url.length <= 0) company_url = site.url;
}
}
const metadata: PublisherMetadata = {
const metadata: CompanyMetadata = {
id: "" + company.id,
name: company.name,
shortDescription: this.trimMessage(company.description, 280),
@ -413,7 +409,7 @@ export class IGDBProvider implements MetadataProvider {
}
async fetchDeveloper(
params: _FetchDeveloperMetadataParams,
): Promise<DeveloperMetadata> {
): Promise<CompanyMetadata> {
return await this.fetchPublisher(params);
}
}

View File

@ -5,11 +5,10 @@ import type {
_FetchDeveloperMetadataParams,
_FetchGameMetadataParams,
_FetchPublisherMetadataParams,
DeveloperMetadata,
GameMetadata,
GameMetadataSearchResult,
InternalGameMetadataResult,
PublisherMetadata,
CompanyMetadata,
} from "./types";
import { ObjectTransactionalHandler } from "../objects/transactional";
import { PriorityListIndexed } from "../utils/prioritylist";
@ -31,7 +30,6 @@ export class MissingMetadataProviderConfig extends Error {
export const DropUserAgent = "Drop/0.2";
export abstract class MetadataProvider {
abstract id(): string;
abstract name(): string;
abstract source(): MetadataSource;
@ -39,16 +37,16 @@ export abstract class MetadataProvider {
abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>;
abstract fetchPublisher(
params: _FetchPublisherMetadataParams,
): Promise<PublisherMetadata | undefined>;
): Promise<CompanyMetadata | undefined>;
abstract fetchDeveloper(
params: _FetchDeveloperMetadataParams,
): Promise<DeveloperMetadata | undefined>;
): Promise<CompanyMetadata | undefined>;
}
export class MetadataHandler {
// Ordered by priority
private providers: PriorityListIndexed<MetadataProvider> =
new PriorityListIndexed("id");
new PriorityListIndexed("source");
private objectHandler: ObjectTransactionalHandler =
new ObjectTransactionalHandler();
@ -63,8 +61,8 @@ export class MetadataHandler {
fetchProviderIdsInOrder() {
return this.providers
.values()
.map((e) => e.id())
.filter((e) => e !== "manual");
.map((e) => e.source())
.filter((e) => e !== "Manual");
}
async search(query: string) {
@ -80,7 +78,7 @@ export class MetadataHandler {
const mappedResults: InternalGameMetadataResult[] = results.map(
(result) =>
Object.assign({}, result, {
sourceId: provider.id(),
sourceId: provider.source(),
sourceName: provider.name(),
}),
);
@ -129,7 +127,7 @@ export class MetadataHandler {
where: {
metadataKey: {
metadataSource: provider.source(),
metadataId: provider.id(),
metadataId: result.id,
},
},
});
@ -163,12 +161,6 @@ export class MetadataHandler {
mName: metadata.name,
mShortDescription: metadata.shortDescription,
mDescription: metadata.description,
mDevelopers: {
connect: metadata.developers,
},
mPublishers: {
connect: metadata.publishers,
},
mReviewCount: metadata.reviewCount,
mReviewRating: metadata.reviewRating,
@ -182,6 +174,44 @@ export class MetadataHandler {
libraryBasePath,
},
});
// relate companies to game
for (const pub of metadata.publishers) {
await prisma.companyGameRelation.upsert({
where: {
companyGame: {
gameId: game.id,
companyId: pub.id,
},
},
create: {
gameId: game.id,
companyId: pub.id,
publisher: true,
},
update: {
publisher: true,
},
});
}
for (const dev of metadata.developers) {
await prisma.companyGameRelation.upsert({
where: {
companyGame: {
gameId: game.id,
companyId: dev.id,
},
},
create: {
gameId: game.id,
companyId: dev.id,
developer: true,
},
update: {
developer: true,
},
});
}
await pullObjects();
return game;
@ -208,10 +238,10 @@ export class MetadataHandler {
private async fetchDeveloperPublisher(
query: string,
functionName: "fetchDeveloper" | "fetchPublisher",
databaseName: "developer" | "publisher",
type: "developer" | "publisher",
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const existing = await (prisma as any)[databaseName].findFirst({
const existing = await (prisma as any)[type].findFirst({
where: {
metadataOriginalQuery: query,
},
@ -226,12 +256,12 @@ export class MetadataHandler {
{},
["internal:read"],
);
let result: PublisherMetadata | undefined;
let result: CompanyMetadata | undefined;
try {
result = await provider[functionName]({ query, createObject });
if (result === undefined) {
throw new Error(
`${provider.source()} failed to find a ${databaseName} for "${query}`,
`${provider.source()} failed to find a ${type} for "${query}`,
);
}
} catch (e) {
@ -243,18 +273,32 @@ export class MetadataHandler {
// If we're successful
await pullObjects();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const object = await (prisma as any)[databaseName].create({
data: {
// TODO: dedupe metadata in event that a company with same source and id appears
const object = await prisma.company.upsert({
where: {
metadataKey: {
metadataId: result.id,
metadataSource: provider.source(),
},
},
create: {
metadataSource: provider.source(),
metadataId: provider.id(),
metadataId: result.id,
metadataOriginalQuery: query,
mName: result.name,
mShortDescription: result.shortDescription,
mDescription: result.description,
mLogo: result.logo,
mBanner: result.banner,
mLogoObjectId: result.logo,
mBannerObjectId: result.banner,
mWebsite: result.website,
},
update: {
mName: result.name,
mShortDescription: result.shortDescription,
mDescription: result.description,
mLogoObjectId: result.logo,
mBannerObjectId: result.banner,
mWebsite: result.website,
},
});

View File

@ -4,16 +4,12 @@ import type {
_FetchGameMetadataParams,
GameMetadata,
_FetchPublisherMetadataParams,
PublisherMetadata,
CompanyMetadata,
_FetchDeveloperMetadataParams,
DeveloperMetadata,
} from "./types";
import * as jdenticon from "jdenticon";
export class ManualMetadataProvider implements MetadataProvider {
id() {
return "manual";
}
name() {
return "Manual";
}
@ -49,12 +45,12 @@ export class ManualMetadataProvider implements MetadataProvider {
}
async fetchPublisher(
_params: _FetchPublisherMetadataParams,
): Promise<PublisherMetadata> {
): Promise<CompanyMetadata> {
throw new Error("Method not implemented.");
}
async fetchDeveloper(
_params: _FetchDeveloperMetadataParams,
): Promise<DeveloperMetadata> {
): Promise<CompanyMetadata> {
throw new Error("Method not implemented.");
}
}

View File

@ -6,9 +6,8 @@ import type {
_FetchGameMetadataParams,
GameMetadata,
_FetchPublisherMetadataParams,
PublisherMetadata,
CompanyMetadata,
_FetchDeveloperMetadataParams,
DeveloperMetadata,
} from "./types";
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
@ -60,9 +59,6 @@ interface PCGamingWikiCargoResult<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 {
id() {
return "pcgamingwiki";
}
name() {
return "PCGamingWiki";
}
@ -219,12 +215,12 @@ export class PCGamingWikiProvider implements MetadataProvider {
async fetchPublisher({
query,
createObject,
}: _FetchPublisherMetadataParams): Promise<PublisherMetadata> {
}: _FetchPublisherMetadataParams): Promise<CompanyMetadata> {
const searchParams = new URLSearchParams({
action: "cargoquery",
tables: "Company",
fields:
"Company.Parent,Company.Founded,Company.Defunct,Company.Website,Company._pageName=PageName,Company._pageID=pageID",
"Company.Parent,Company.Founded,Company.Defunct,Company.Website,Company._pageName=PageName,Company._pageID=PageID",
where: `Company._pageName="Company:${query}"`,
format: "json",
});
@ -240,7 +236,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
const fixedCompanyName =
company.PageName.split("Company:").at(1) ?? company.PageName;
const metadata: PublisherMetadata = {
const metadata: CompanyMetadata = {
id: company.PageID,
name: fixedCompanyName,
shortDescription: "",
@ -258,7 +254,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
async fetchDeveloper(
params: _FetchDeveloperMetadataParams,
): Promise<DeveloperMetadata> {
): Promise<CompanyMetadata> {
return await this.fetchPublisher(params);
}
}

View File

@ -40,7 +40,7 @@ export interface GameMetadata {
images: ObjectReference[];
}
export interface PublisherMetadata {
export interface CompanyMetadata {
id: string;
name: string;
shortDescription: string;
@ -51,8 +51,6 @@ export interface PublisherMetadata {
website: string;
}
export type DeveloperMetadata = PublisherMetadata;
export interface _FetchGameMetadataParams {
id: string;
name: string;

View File

@ -18,7 +18,7 @@ export default defineNitroPlugin(async (_nitro) => {
for (const provider of metadataProviders) {
try {
const prov = new provider();
const id = prov.id();
const id = prov.source();
providers.set(id, prov);
console.log(`enabled metadata provider: ${prov.name()}`);