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
@@ -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;
+36 -3
View File
@@ -33,9 +33,10 @@ model Game {
versions GameVersion[] versions GameVersion[]
libraryBasePath String @unique // Base dir for all the game versions libraryBasePath String @unique // Base dir for all the game versions
collections CollectionEntry[] collections CollectionEntry[]
saves SaveSlot[] saves SaveSlot[]
screenshots Screenshot[] screenshots Screenshot[]
companyRelations CompanyGameRelation[]
@@unique([metadataSource, metadataId], name: "metadataKey") @@unique([metadataSource, metadataId], name: "metadataKey")
} }
@@ -102,6 +103,38 @@ model Screenshot {
@@index([gameId, userId]) @@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 { model Developer {
id String @id @default(uuid()) id String @id @default(uuid())
+4 -8
View File
@@ -7,9 +7,8 @@ import type {
_FetchGameMetadataParams, _FetchGameMetadataParams,
GameMetadata, GameMetadata,
_FetchPublisherMetadataParams, _FetchPublisherMetadataParams,
PublisherMetadata, CompanyMetadata,
_FetchDeveloperMetadataParams, _FetchDeveloperMetadataParams,
DeveloperMetadata,
} from "./types"; } from "./types";
import type { AxiosRequestConfig } from "axios"; import type { AxiosRequestConfig } from "axios";
import axios from "axios"; import axios from "axios";
@@ -125,9 +124,6 @@ export class GiantBombProvider implements MetadataProvider {
return response; return response;
} }
id() {
return "giantbomb";
}
name() { name() {
return "GiantBomb"; return "GiantBomb";
} }
@@ -229,7 +225,7 @@ export class GiantBombProvider implements MetadataProvider {
async fetchPublisher({ async fetchPublisher({
query, query,
createObject, createObject,
}: _FetchPublisherMetadataParams): Promise<PublisherMetadata> { }: _FetchPublisherMetadataParams): Promise<CompanyMetadata> {
const results = await this.request<Array<CompanySearchResult>>( const results = await this.request<Array<CompanySearchResult>>(
"search", "search",
"", "",
@@ -246,7 +242,7 @@ export class GiantBombProvider implements MetadataProvider {
? this.turndown.turndown(company.description) ? this.turndown.turndown(company.description)
: company.deck; : company.deck;
const metadata: PublisherMetadata = { const metadata: CompanyMetadata = {
id: company.guid, id: company.guid,
name: company.name, name: company.name,
shortDescription: company.deck ?? "", shortDescription: company.deck ?? "",
@@ -261,7 +257,7 @@ export class GiantBombProvider implements MetadataProvider {
} }
async fetchDeveloper( async fetchDeveloper(
params: _FetchDeveloperMetadataParams, params: _FetchDeveloperMetadataParams,
): Promise<DeveloperMetadata> { ): Promise<CompanyMetadata> {
return await this.fetchPublisher(params); return await this.fetchPublisher(params);
} }
} }
+4 -8
View File
@@ -7,9 +7,8 @@ import type {
_FetchGameMetadataParams, _FetchGameMetadataParams,
GameMetadata, GameMetadata,
_FetchPublisherMetadataParams, _FetchPublisherMetadataParams,
PublisherMetadata, CompanyMetadata,
_FetchDeveloperMetadataParams, _FetchDeveloperMetadataParams,
DeveloperMetadata,
} from "./types"; } from "./types";
import type { AxiosRequestConfig } from "axios"; import type { AxiosRequestConfig } from "axios";
import axios from "axios"; import axios from "axios";
@@ -265,9 +264,6 @@ export class IGDBProvider implements MetadataProvider {
return msg.length > len ? msg.substring(0, 280) + "..." : msg; return msg.length > len ? msg.substring(0, 280) + "..." : msg;
} }
id() {
return "igdb";
}
name() { name() {
return "IGDB"; return "IGDB";
} }
@@ -375,7 +371,7 @@ export class IGDBProvider implements MetadataProvider {
async fetchPublisher({ async fetchPublisher({
query, query,
createObject, createObject,
}: _FetchPublisherMetadataParams): Promise<PublisherMetadata> { }: _FetchPublisherMetadataParams): Promise<CompanyMetadata> {
const response = await this.request<IGDBCompany>( const response = await this.request<IGDBCompany>(
"companies", "companies",
`where name = "${query}"; fields *; limit 1;`, `where name = "${query}"; fields *; limit 1;`,
@@ -395,7 +391,7 @@ export class IGDBProvider implements MetadataProvider {
if (company_url.length <= 0) company_url = site.url; if (company_url.length <= 0) company_url = site.url;
} }
} }
const metadata: PublisherMetadata = { const metadata: CompanyMetadata = {
id: "" + company.id, id: "" + company.id,
name: company.name, name: company.name,
shortDescription: this.trimMessage(company.description, 280), shortDescription: this.trimMessage(company.description, 280),
@@ -413,7 +409,7 @@ export class IGDBProvider implements MetadataProvider {
} }
async fetchDeveloper( async fetchDeveloper(
params: _FetchDeveloperMetadataParams, params: _FetchDeveloperMetadataParams,
): Promise<DeveloperMetadata> { ): Promise<CompanyMetadata> {
return await this.fetchPublisher(params); return await this.fetchPublisher(params);
} }
} }
+70 -26
View File
@@ -5,11 +5,10 @@ import type {
_FetchDeveloperMetadataParams, _FetchDeveloperMetadataParams,
_FetchGameMetadataParams, _FetchGameMetadataParams,
_FetchPublisherMetadataParams, _FetchPublisherMetadataParams,
DeveloperMetadata,
GameMetadata, GameMetadata,
GameMetadataSearchResult, GameMetadataSearchResult,
InternalGameMetadataResult, InternalGameMetadataResult,
PublisherMetadata, CompanyMetadata,
} from "./types"; } from "./types";
import { ObjectTransactionalHandler } from "../objects/transactional"; import { ObjectTransactionalHandler } from "../objects/transactional";
import { PriorityListIndexed } from "../utils/prioritylist"; import { PriorityListIndexed } from "../utils/prioritylist";
@@ -31,7 +30,6 @@ export class MissingMetadataProviderConfig extends Error {
export const DropUserAgent = "Drop/0.2"; export const DropUserAgent = "Drop/0.2";
export abstract class MetadataProvider { export abstract class MetadataProvider {
abstract id(): string;
abstract name(): string; abstract name(): string;
abstract source(): MetadataSource; abstract source(): MetadataSource;
@@ -39,16 +37,16 @@ export abstract class MetadataProvider {
abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>; abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>;
abstract fetchPublisher( abstract fetchPublisher(
params: _FetchPublisherMetadataParams, params: _FetchPublisherMetadataParams,
): Promise<PublisherMetadata | undefined>; ): Promise<CompanyMetadata | undefined>;
abstract fetchDeveloper( abstract fetchDeveloper(
params: _FetchDeveloperMetadataParams, params: _FetchDeveloperMetadataParams,
): Promise<DeveloperMetadata | undefined>; ): Promise<CompanyMetadata | undefined>;
} }
export class MetadataHandler { export class MetadataHandler {
// Ordered by priority // Ordered by priority
private providers: PriorityListIndexed<MetadataProvider> = private providers: PriorityListIndexed<MetadataProvider> =
new PriorityListIndexed("id"); new PriorityListIndexed("source");
private objectHandler: ObjectTransactionalHandler = private objectHandler: ObjectTransactionalHandler =
new ObjectTransactionalHandler(); new ObjectTransactionalHandler();
@@ -63,8 +61,8 @@ export class MetadataHandler {
fetchProviderIdsInOrder() { fetchProviderIdsInOrder() {
return this.providers return this.providers
.values() .values()
.map((e) => e.id()) .map((e) => e.source())
.filter((e) => e !== "manual"); .filter((e) => e !== "Manual");
} }
async search(query: string) { async search(query: string) {
@@ -80,7 +78,7 @@ export class MetadataHandler {
const mappedResults: InternalGameMetadataResult[] = results.map( const mappedResults: InternalGameMetadataResult[] = results.map(
(result) => (result) =>
Object.assign({}, result, { Object.assign({}, result, {
sourceId: provider.id(), sourceId: provider.source(),
sourceName: provider.name(), sourceName: provider.name(),
}), }),
); );
@@ -129,7 +127,7 @@ export class MetadataHandler {
where: { where: {
metadataKey: { metadataKey: {
metadataSource: provider.source(), metadataSource: provider.source(),
metadataId: provider.id(), metadataId: result.id,
}, },
}, },
}); });
@@ -163,12 +161,6 @@ export class MetadataHandler {
mName: metadata.name, mName: metadata.name,
mShortDescription: metadata.shortDescription, mShortDescription: metadata.shortDescription,
mDescription: metadata.description, mDescription: metadata.description,
mDevelopers: {
connect: metadata.developers,
},
mPublishers: {
connect: metadata.publishers,
},
mReviewCount: metadata.reviewCount, mReviewCount: metadata.reviewCount,
mReviewRating: metadata.reviewRating, mReviewRating: metadata.reviewRating,
@@ -182,6 +174,44 @@ export class MetadataHandler {
libraryBasePath, 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(); await pullObjects();
return game; return game;
@@ -208,10 +238,10 @@ export class MetadataHandler {
private async fetchDeveloperPublisher( private async fetchDeveloperPublisher(
query: string, query: string,
functionName: "fetchDeveloper" | "fetchPublisher", functionName: "fetchDeveloper" | "fetchPublisher",
databaseName: "developer" | "publisher", type: "developer" | "publisher",
) { ) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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: { where: {
metadataOriginalQuery: query, metadataOriginalQuery: query,
}, },
@@ -226,12 +256,12 @@ export class MetadataHandler {
{}, {},
["internal:read"], ["internal:read"],
); );
let result: PublisherMetadata | undefined; let result: CompanyMetadata | undefined;
try { try {
result = await provider[functionName]({ query, createObject }); result = await provider[functionName]({ query, createObject });
if (result === undefined) { if (result === undefined) {
throw new Error( throw new Error(
`${provider.source()} failed to find a ${databaseName} for "${query}`, `${provider.source()} failed to find a ${type} for "${query}`,
); );
} }
} catch (e) { } catch (e) {
@@ -243,18 +273,32 @@ export class MetadataHandler {
// If we're successful // If we're successful
await pullObjects(); await pullObjects();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // TODO: dedupe metadata in event that a company with same source and id appears
const object = await (prisma as any)[databaseName].create({ const object = await prisma.company.upsert({
data: { where: {
metadataKey: {
metadataId: result.id,
metadataSource: provider.source(),
},
},
create: {
metadataSource: provider.source(), metadataSource: provider.source(),
metadataId: provider.id(), metadataId: result.id,
metadataOriginalQuery: query, metadataOriginalQuery: query,
mName: result.name, mName: result.name,
mShortDescription: result.shortDescription, mShortDescription: result.shortDescription,
mDescription: result.description, mDescription: result.description,
mLogo: result.logo, mLogoObjectId: result.logo,
mBanner: result.banner, 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, mWebsite: result.website,
}, },
}); });
+3 -7
View File
@@ -4,16 +4,12 @@ import type {
_FetchGameMetadataParams, _FetchGameMetadataParams,
GameMetadata, GameMetadata,
_FetchPublisherMetadataParams, _FetchPublisherMetadataParams,
PublisherMetadata, CompanyMetadata,
_FetchDeveloperMetadataParams, _FetchDeveloperMetadataParams,
DeveloperMetadata,
} from "./types"; } from "./types";
import * as jdenticon from "jdenticon"; import * as jdenticon from "jdenticon";
export class ManualMetadataProvider implements MetadataProvider { export class ManualMetadataProvider implements MetadataProvider {
id() {
return "manual";
}
name() { name() {
return "Manual"; return "Manual";
} }
@@ -49,12 +45,12 @@ export class ManualMetadataProvider implements MetadataProvider {
} }
async fetchPublisher( async fetchPublisher(
_params: _FetchPublisherMetadataParams, _params: _FetchPublisherMetadataParams,
): Promise<PublisherMetadata> { ): Promise<CompanyMetadata> {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
async fetchDeveloper( async fetchDeveloper(
_params: _FetchDeveloperMetadataParams, _params: _FetchDeveloperMetadataParams,
): Promise<DeveloperMetadata> { ): Promise<CompanyMetadata> {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
} }
+5 -9
View File
@@ -6,9 +6,8 @@ import type {
_FetchGameMetadataParams, _FetchGameMetadataParams,
GameMetadata, GameMetadata,
_FetchPublisherMetadataParams, _FetchPublisherMetadataParams,
PublisherMetadata, CompanyMetadata,
_FetchDeveloperMetadataParams, _FetchDeveloperMetadataParams,
DeveloperMetadata,
} from "./types"; } from "./types";
import type { AxiosRequestConfig } from "axios"; import type { AxiosRequestConfig } from "axios";
import axios from "axios"; import axios from "axios";
@@ -60,9 +59,6 @@ interface PCGamingWikiCargoResult<T> {
// Api Docs: https://www.pcgamingwiki.com/wiki/PCGamingWiki:API // Api Docs: https://www.pcgamingwiki.com/wiki/PCGamingWiki:API
// Good tool for helping build cargo queries: https://www.pcgamingwiki.com/wiki/Special:CargoQuery // Good tool for helping build cargo queries: https://www.pcgamingwiki.com/wiki/Special:CargoQuery
export class PCGamingWikiProvider implements MetadataProvider { export class PCGamingWikiProvider implements MetadataProvider {
id() {
return "pcgamingwiki";
}
name() { name() {
return "PCGamingWiki"; return "PCGamingWiki";
} }
@@ -219,12 +215,12 @@ export class PCGamingWikiProvider implements MetadataProvider {
async fetchPublisher({ async fetchPublisher({
query, query,
createObject, createObject,
}: _FetchPublisherMetadataParams): Promise<PublisherMetadata> { }: _FetchPublisherMetadataParams): Promise<CompanyMetadata> {
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
action: "cargoquery", action: "cargoquery",
tables: "Company", tables: "Company",
fields: 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}"`, where: `Company._pageName="Company:${query}"`,
format: "json", format: "json",
}); });
@@ -240,7 +236,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
const fixedCompanyName = const fixedCompanyName =
company.PageName.split("Company:").at(1) ?? company.PageName; company.PageName.split("Company:").at(1) ?? company.PageName;
const metadata: PublisherMetadata = { const metadata: CompanyMetadata = {
id: company.PageID, id: company.PageID,
name: fixedCompanyName, name: fixedCompanyName,
shortDescription: "", shortDescription: "",
@@ -258,7 +254,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
async fetchDeveloper( async fetchDeveloper(
params: _FetchDeveloperMetadataParams, params: _FetchDeveloperMetadataParams,
): Promise<DeveloperMetadata> { ): Promise<CompanyMetadata> {
return await this.fetchPublisher(params); return await this.fetchPublisher(params);
} }
} }
+1 -3
View File
@@ -40,7 +40,7 @@ export interface GameMetadata {
images: ObjectReference[]; images: ObjectReference[];
} }
export interface PublisherMetadata { export interface CompanyMetadata {
id: string; id: string;
name: string; name: string;
shortDescription: string; shortDescription: string;
@@ -51,8 +51,6 @@ export interface PublisherMetadata {
website: string; website: string;
} }
export type DeveloperMetadata = PublisherMetadata;
export interface _FetchGameMetadataParams { export interface _FetchGameMetadataParams {
id: string; id: string;
name: string; name: string;
+1 -1
View File
@@ -18,7 +18,7 @@ export default defineNitroPlugin(async (_nitro) => {
for (const provider of metadataProviders) { for (const provider of metadataProviders) {
try { try {
const prov = new provider(); const prov = new provider();
const id = prov.id(); const id = prov.source();
providers.set(id, prov); providers.set(id, prov);
console.log(`enabled metadata provider: ${prov.name()}`); console.log(`enabled metadata provider: ${prov.name()}`);