Files
drop/server/internal/metadata/index.ts
DecDuck 45848d175e Small fixes (#141)
* fix: save task as Json rather than string

* fix: pull objects before creating game in database

* fix: strips relative dirs from version information

* fix: #132

* fix: lint

* fix: news object ids and small tweaks

* fix: notification styling errors

* fix: lint
2025-07-20 14:56:15 +10:00

348 lines
9.4 KiB
TypeScript

import { type Prisma, MetadataSource } from "~/prisma/client";
import prisma from "../db/database";
import type {
_FetchGameMetadataParams,
_FetchCompanyMetadataParams,
GameMetadata,
GameMetadataSearchResult,
InternalGameMetadataResult,
CompanyMetadata,
GameMetadataRating,
} from "./types";
import { ObjectTransactionalHandler } from "../objects/transactional";
import { PriorityListIndexed } from "../utils/prioritylist";
import { systemConfig } from "../config/sys-conf";
import type { TaskRunContext } from "../tasks";
import taskHandler, { wrapTaskContext } from "../tasks";
import { randomUUID } from "crypto";
import { fuzzy } from "fast-fuzzy";
import { logger } from "~/server/internal/logging";
export class MissingMetadataProviderConfig extends Error {
private providerName: string;
constructor(configKey: string, providerName: string) {
super(`Missing config item ${configKey} for ${providerName}`);
this.providerName = providerName;
}
getProviderName() {
return this.providerName;
}
}
// TODO: add useragent to all outbound api calls (best practice)
export const DropUserAgent = `Drop/${systemConfig.getDropVersion()}`;
export abstract class MetadataProvider {
abstract name(): string;
abstract source(): MetadataSource;
abstract search(query: string): Promise<GameMetadataSearchResult[]>;
abstract fetchGame(
params: _FetchGameMetadataParams,
taskRunContext?: TaskRunContext,
): Promise<GameMetadata>;
abstract fetchCompany(
params: _FetchCompanyMetadataParams,
taskRunContext?: TaskRunContext,
): Promise<CompanyMetadata | undefined>;
}
export class MetadataHandler {
// Ordered by priority
private providers: PriorityListIndexed<MetadataProvider> =
new PriorityListIndexed("source");
private objectHandler: ObjectTransactionalHandler =
new ObjectTransactionalHandler();
addProvider(provider: MetadataProvider, priority: number = 0) {
this.providers.push(provider, priority);
}
/**
* Returns provider IDs, used to save to applicationConfig
* @returns The provider IDs in order, missing manual
*/
fetchProviderIdsInOrder() {
return this.providers
.values()
.map((e) => e.source())
.filter((e) => e !== "Manual");
}
async search(query: string) {
const promises: Promise<InternalGameMetadataResult[]>[] = [];
for (const provider of this.providers.values()) {
const queryTransformationPromise = new Promise<
InternalGameMetadataResult[]
// TODO: fix eslint error
// eslint-disable-next-line no-async-promise-executor
>(async (resolve, reject) => {
try {
const results = await provider.search(query);
const mappedResults: InternalGameMetadataResult[] = results.map(
(result) =>
Object.assign({}, result, {
sourceId: provider.source(),
sourceName: provider.name(),
}),
);
resolve(mappedResults);
} catch (e) {
logger.warn(e);
reject(e);
}
});
promises.push(queryTransformationPromise);
}
const results = await Promise.allSettled(promises);
const successfulResults = results
.filter((result) => result.status === "fulfilled")
.map((result) => result.value)
.flat()
.map((result) => {
const match = fuzzy(query, result.name);
return { ...result, fuzzy: match };
})
.sort((a, b) => b.fuzzy - a.fuzzy);
return successfulResults;
}
async createGameWithoutMetadata(libraryId: string, libraryPath: string) {
return await this.createGame(
{
id: "",
name: libraryPath,
sourceId: MetadataSource.Manual,
},
libraryId,
libraryPath,
);
}
private parseTags(tags: string[]) {
const results: Array<Prisma.TagCreateOrConnectWithoutGamesInput> = [];
tags.forEach((t) =>
results.push({
where: {
name: t,
},
create: {
name: t,
},
}),
);
return results;
}
private parseRatings(ratings: GameMetadataRating[]) {
const results: Array<Prisma.GameRatingCreateOrConnectWithoutGameInput> = [];
ratings.forEach((r) => {
results.push({
where: {
metadataKey: {
metadataId: r.metadataId,
metadataSource: r.metadataSource,
},
},
create: {
...r,
},
});
});
return results;
}
async createGame(
result: { sourceId: string; id: string; name: string },
libraryId: string,
libraryPath: string,
) {
const provider = this.providers.get(result.sourceId);
if (!provider)
throw new Error(`Invalid metadata provider for ID "${result.sourceId}"`);
const existing = await prisma.game.findUnique({
where: {
metadataKey: {
metadataSource: provider.source(),
metadataId: result.id,
},
},
});
if (existing) return undefined;
const gameId = randomUUID();
const taskId = `import:${gameId}`;
await taskHandler.create({
name: `Import game "${result.name}" (${libraryPath})`,
id: taskId,
taskGroup: "import:game",
acls: ["system:import:game:read"],
async run(context) {
const { progress, logger } = context;
progress(0);
const [createObject, pullObjects, dumpObjects] =
metadataHandler.objectHandler.new(
{},
["internal:read"],
wrapTaskContext(context, {
min: 60,
max: 95,
prefix: "[object import] ",
}),
);
let metadata: GameMetadata | undefined = undefined;
try {
metadata = await provider.fetchGame(
{
id: result.id,
name: result.name,
// wrap in anonymous functions to keep references to this
publisher: (name: string) => metadataHandler.fetchCompany(name),
developer: (name: string) => metadataHandler.fetchCompany(name),
createObject,
},
wrapTaskContext(context, {
min: 0,
max: 60,
prefix: "[metadata import] ",
}),
);
} catch (e) {
dumpObjects();
throw e;
}
context?.progress(60);
logger.info(`Successfully fetched all metadata.`);
logger.info(`Importing objects...`);
await pullObjects();
progress(95);
await prisma.game.create({
data: {
id: gameId,
metadataSource: provider.source(),
metadataId: metadata.id,
mName: metadata.name,
mShortDescription: metadata.shortDescription,
mDescription: metadata.description,
mReleased: metadata.released,
mIconObjectId: metadata.icon,
mBannerObjectId: metadata.bannerId,
mCoverObjectId: metadata.coverId,
mImageLibraryObjectIds: metadata.images,
publishers: {
connect: metadata.publishers,
},
developers: {
connect: metadata.developers,
},
ratings: {
connectOrCreate: metadataHandler.parseRatings(metadata.reviews),
},
tags: {
connectOrCreate: metadataHandler.parseTags(metadata.tags),
},
libraryId,
libraryPath,
},
});
logger.info(`Finished game import.`);
},
});
return taskId;
}
// Careful with this function, it has no typechecking
// Type-checking this thing is impossible
private async fetchCompany(query: string) {
const existing = await prisma.company.findFirst({
where: {
metadataOriginalQuery: query,
},
});
if (existing) return existing;
for (const provider of this.providers.values()) {
// don't allow manual provider to "fetch" metadata
if (provider.source() === MetadataSource.Manual) continue;
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new(
{},
["internal:read"],
);
let result: CompanyMetadata | undefined;
try {
result = await provider.fetchCompany({ query, createObject });
if (result === undefined) {
throw new Error(
`${provider.source()} failed to find a company for "${query}`,
);
}
} catch (e) {
logger.warn(e);
dumpObjects();
continue;
}
const object = await prisma.company.upsert({
where: {
metadataKey: {
metadataSource: provider.source(),
metadataId: result.id,
},
},
create: {
metadataSource: provider.source(),
metadataId: result.id,
metadataOriginalQuery: query,
mName: result.name,
mShortDescription: result.shortDescription,
mDescription: result.description,
mLogoObjectId: result.logo,
mBannerObjectId: result.banner,
mWebsite: result.website,
},
update: {},
});
if (object.mLogoObjectId == result.logo) {
// We created, and didn't update
// So pull objects
await pullObjects();
}
return object;
}
return undefined;
}
}
export const metadataHandler = new MetadataHandler();
export default metadataHandler;