mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-01-31 15:37:09 +01:00
metadata engine
This commit is contained in:
12
server/api/v1/game/search.get.ts
Normal file
12
server/api/v1/game/search.get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const query = getQuery(h3);
|
||||
const search = query["q"]?.toString();
|
||||
if (!search) throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing search param"
|
||||
});
|
||||
|
||||
const results = await h3.context.metadataHandler.search(search);
|
||||
|
||||
return results;
|
||||
});
|
||||
4
server/h3.d.ts
vendored
4
server/h3.d.ts
vendored
@@ -1,8 +1,10 @@
|
||||
import { MetadataHandler } from "./internal/metadata";
|
||||
import { SessionHandler } from "./internal/session";
|
||||
|
||||
export * from "h3";
|
||||
declare module "h3" {
|
||||
interface H3EventContext {
|
||||
session: SessionHandler
|
||||
session: SessionHandler;
|
||||
metadataHandler: MetadataHandler;
|
||||
}
|
||||
}
|
||||
|
||||
207
server/internal/metadata/giantbomb.ts
Normal file
207
server/internal/metadata/giantbomb.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Developer, MetadataSource, Publisher } from "@prisma/client";
|
||||
import { MetadataProvider } from ".";
|
||||
import { GameMetadataSearchResult, _FetchGameMetadataParams, GameMetadata, _FetchPublisherMetadataParams, PublisherMetadata, _FetchDeveloperMetadataParams, DeveloperMetadata } from "./types";
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import moment from "moment";
|
||||
import TurndownService from "turndown";
|
||||
|
||||
interface GiantBombResponseType<T> {
|
||||
error: "OK" | string;
|
||||
limit: number,
|
||||
offset: number,
|
||||
number_of_page_results: number,
|
||||
number_of_total_results: number,
|
||||
status_code: number,
|
||||
results: T,
|
||||
version: string
|
||||
}
|
||||
|
||||
interface GameSearchResult {
|
||||
guid: string,
|
||||
name: string,
|
||||
deck: string,
|
||||
original_release_date?: string
|
||||
expected_release_year?: number
|
||||
image?: {
|
||||
icon_url: string
|
||||
}
|
||||
}
|
||||
|
||||
interface GameResult {
|
||||
guid: string,
|
||||
name: string,
|
||||
deck: string,
|
||||
description?: string,
|
||||
|
||||
developers: Array<{ id: number, name: string }>,
|
||||
publishers: Array<{ id: number, name: string }>
|
||||
|
||||
number_of_user_reviews: number, // Doesn't provide an actual rating, so kinda useless
|
||||
|
||||
image: {
|
||||
icon_url: string,
|
||||
screen_large_url: string,
|
||||
},
|
||||
images: Array<{
|
||||
tags: string; // If it's "All Images", art, otherwise screenshot
|
||||
original: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface CompanySearchResult {
|
||||
guid: string,
|
||||
deck: string,
|
||||
description: string,
|
||||
name: string,
|
||||
|
||||
image: {
|
||||
icon_url: string,
|
||||
screen_large_url: string,
|
||||
}
|
||||
}
|
||||
|
||||
export class GiantBombProvider implements MetadataProvider {
|
||||
private apikey: string;
|
||||
private turndown: TurndownService;
|
||||
|
||||
constructor() {
|
||||
const apikey = process.env.GIANT_BOMB_API_KEY;
|
||||
if (!apikey) throw new Error("No GIANT_BOMB_API_KEY in environment");
|
||||
|
||||
this.apikey = apikey;
|
||||
this.turndown = new TurndownService();
|
||||
}
|
||||
|
||||
private async request<T>(resource: string, url: string, query: { [key: string]: string | Array<string> }, options?: AxiosRequestConfig) {
|
||||
|
||||
const queryOptions = { ...query, api_key: this.apikey, format: 'json' };
|
||||
const queryString = Object.entries(queryOptions).map(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return `${key}=${value.map(encodeURIComponent).join(',')}`
|
||||
}
|
||||
return `${key}=${encodeURIComponent(value)}`;
|
||||
}).join("&");
|
||||
|
||||
const finalURL = `https://www.giantbomb.com/api/${resource}/${url}?${queryString}`;
|
||||
|
||||
const overlay: AxiosRequestConfig = {
|
||||
url: finalURL,
|
||||
baseURL: "",
|
||||
}
|
||||
const response = await axios.request<GiantBombResponseType<T>>(Object.assign({}, options, overlay));
|
||||
return response;
|
||||
}
|
||||
|
||||
id() {
|
||||
return "giantbomb";
|
||||
}
|
||||
name() {
|
||||
return "GiantBomb"
|
||||
}
|
||||
source() {
|
||||
return MetadataSource.GiantBomb;
|
||||
}
|
||||
|
||||
|
||||
async search(query: string): Promise<GameMetadataSearchResult[]> {
|
||||
const results = await this.request<Array<GameSearchResult>>("search", "", { query: query, resources: ["game"] });
|
||||
const mapped = results.data.results.map((result) => {
|
||||
const date = (result.original_release_date ? moment(result.original_release_date).year() : result.expected_release_year) ?? 0;
|
||||
|
||||
const metadata: GameMetadataSearchResult = {
|
||||
id: result.guid,
|
||||
name: result.name,
|
||||
icon: result.image?.icon_url ?? "",
|
||||
description: result.deck,
|
||||
year: date
|
||||
}
|
||||
|
||||
return metadata;
|
||||
})
|
||||
|
||||
return mapped;
|
||||
}
|
||||
async fetchGame({ id, publisher, developer, createObject }: _FetchGameMetadataParams): Promise<GameMetadata> {
|
||||
const result = await this.request<GameResult>("game", id, {});
|
||||
const gameData = result.data.results;
|
||||
|
||||
|
||||
const longDescription = gameData.description ?
|
||||
this.turndown.turndown(gameData.description) :
|
||||
gameData.deck;
|
||||
|
||||
const publishers: Publisher[] = [];
|
||||
for (const pub of gameData.publishers) {
|
||||
publishers.push(await publisher(pub.name));
|
||||
}
|
||||
|
||||
const developers: Developer[] = [];
|
||||
for (const dev of gameData.developers) {
|
||||
developers.push(await developer(dev.name));
|
||||
}
|
||||
|
||||
const icon = createObject(gameData.image.icon_url);
|
||||
const banner = createObject(gameData.image.screen_large_url);
|
||||
|
||||
const artUrls: string[] = [];
|
||||
const screenshotUrls: string[] = [];
|
||||
// If it's "All Images", art, otherwise screenshot
|
||||
for (const image of gameData.images) {
|
||||
if (image.tags == 'All Images') {
|
||||
artUrls.push(image.original)
|
||||
} else {
|
||||
screenshotUrls.push(image.original)
|
||||
}
|
||||
}
|
||||
|
||||
const art = artUrls.map(createObject);
|
||||
const screenshots = screenshotUrls.map(createObject);
|
||||
|
||||
const metadata: GameMetadata = {
|
||||
id: gameData.guid,
|
||||
name: gameData.name,
|
||||
shortDescription: gameData.deck,
|
||||
description: longDescription,
|
||||
|
||||
reviewCount: 0,
|
||||
reviewRating: 0,
|
||||
|
||||
publishers,
|
||||
developers,
|
||||
|
||||
icon,
|
||||
banner,
|
||||
art,
|
||||
screenshots
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
async fetchPublisher({ query, createObject }: _FetchPublisherMetadataParams): Promise<PublisherMetadata> {
|
||||
const results = await this.request<Array<CompanySearchResult>>("search", "", { query, resources: "company" });
|
||||
|
||||
// Find the right entry
|
||||
const company = results.data.results.find((e) => e.name == query) ?? results.data.results.at(0);
|
||||
if (!company) throw new Error(`No results for "${query}"`);
|
||||
|
||||
const longDescription = company.description ?
|
||||
this.turndown.turndown(company.description) :
|
||||
company.deck;
|
||||
|
||||
const metadata: PublisherMetadata = {
|
||||
id: company.guid,
|
||||
name: company.name,
|
||||
shortDescription: company.deck,
|
||||
description: longDescription,
|
||||
|
||||
logo: createObject(company.image.icon_url),
|
||||
banner: createObject(company.image.screen_large_url),
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
async fetchDeveloper(params: _FetchDeveloperMetadataParams): Promise<DeveloperMetadata> {
|
||||
return await this.fetchPublisher(params)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Developer, MetadataSource, PrismaClient, Publisher } from "@prisma/client";
|
||||
import prisma from "../db/database";
|
||||
import { _FetchDeveloperMetadataParams, _FetchGameMetadataParams, _FetchPublisherMetadataParams, DeveloperMetadata, GameMetadata, GameMetadataSearchResult, InternalGameMetadataResult, PublisherMetadata } from "./types";
|
||||
|
||||
import { ObjectTransactionalHandler } from "../objects/transactional";
|
||||
import { PriorityList, PriorityListIndexed } from "../utils/prioritylist";
|
||||
|
||||
export abstract class MetadataProvider {
|
||||
abstract id(): string;
|
||||
abstract name(): string;
|
||||
abstract source(): MetadataSource;
|
||||
|
||||
abstract search(query: string): Promise<GameMetadataSearchResult[]>;
|
||||
abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>;
|
||||
@@ -11,13 +15,13 @@ export abstract class MetadataProvider {
|
||||
abstract fetchDeveloper(params: _FetchDeveloperMetadataParams): Promise<DeveloperMetadata>;
|
||||
}
|
||||
|
||||
class MetadataHandler {
|
||||
export class MetadataHandler {
|
||||
// Ordered by priority
|
||||
private providers: Map<string, MetadataProvider> = new Map();
|
||||
private createObject: (url: string) => Promise<string>;
|
||||
private providers: PriorityListIndexed<MetadataProvider> = new PriorityListIndexed("id");
|
||||
private objectHandler: ObjectTransactionalHandler = new ObjectTransactionalHandler();
|
||||
|
||||
constructor() {
|
||||
this.createObject = async () => "";
|
||||
addProvider(provider: MetadataProvider, priority: number = 0) {
|
||||
this.providers.push(provider, priority);
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
@@ -44,12 +48,114 @@ class MetadataHandler {
|
||||
return successfulResults;
|
||||
}
|
||||
|
||||
async fetchGame(game: InternalGameMetadataResult) {
|
||||
async fetchGame(result: InternalGameMetadataResult) {
|
||||
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: provider.id(),
|
||||
}
|
||||
}
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new();
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await provider.fetchGame({
|
||||
id: result.id,
|
||||
publisher: this.fetchPublisher,
|
||||
developer: this.fetchDeveloper,
|
||||
createObject,
|
||||
})
|
||||
} catch (e) {
|
||||
dumpObjects();
|
||||
throw e;
|
||||
}
|
||||
|
||||
await pullObjects();
|
||||
const game = await prisma.game.create({
|
||||
data: {
|
||||
metadataSource: provider.source(),
|
||||
metadataId: metadata.id,
|
||||
|
||||
mName: metadata.name,
|
||||
mShortDescription: metadata.shortDescription,
|
||||
mDescription: metadata.description,
|
||||
mDevelopers: {
|
||||
connect: metadata.developers
|
||||
},
|
||||
mPublishers: {
|
||||
connect: metadata.publishers,
|
||||
},
|
||||
|
||||
mReviewCount: metadata.reviewCount,
|
||||
mReviewRating: metadata.reviewRating,
|
||||
|
||||
mIconId: metadata.icon,
|
||||
mBannerId: metadata.banner,
|
||||
mArt: metadata.art,
|
||||
mScreenshots: metadata.screenshots,
|
||||
},
|
||||
});
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
async fetchDeveloper(query: string) {
|
||||
|
||||
return await this.fetchDeveloperPublisher(query, "fetchDeveloper", "developer") as Developer;
|
||||
}
|
||||
|
||||
async fetchPublisher(query: string) {
|
||||
return await this.fetchDeveloperPublisher(query, "fetchPublisher", "publisher") as Publisher;
|
||||
}
|
||||
|
||||
// Careful with this function, it has no typechecking
|
||||
// TODO: fix typechecking
|
||||
private async fetchDeveloperPublisher(query: string, functionName: any, databaseName: any) {
|
||||
const existing = await (prisma as any)[databaseName].findFirst({
|
||||
where: {
|
||||
mName: query,
|
||||
}
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
for (const provider of this.providers.values() as any) {
|
||||
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new();
|
||||
let result;
|
||||
try {
|
||||
result = await provider[functionName]({ query, createObject });
|
||||
} catch {
|
||||
dumpObjects();
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we're successful
|
||||
await pullObjects();
|
||||
|
||||
const object = await (prisma as any)[databaseName].create({
|
||||
data: {
|
||||
metadataSource: provider.source(),
|
||||
metadataId: provider.id(),
|
||||
|
||||
mName: result.name,
|
||||
mShortDescription: result.shortDescription,
|
||||
mDescription: result.description,
|
||||
mLogo: result.logo,
|
||||
mBanner: result.banner,
|
||||
},
|
||||
})
|
||||
|
||||
return object;
|
||||
|
||||
}
|
||||
|
||||
throw new Error(`No metadata provider found a ${databaseName} for "${query}"`);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
server/internal/metadata/types.d.ts
vendored
6
server/internal/metadata/types.d.ts
vendored
@@ -17,6 +17,7 @@ export type InternalGameMetadataResult = GameMetadataSearchResult & GameMetadata
|
||||
export type RemoteObject = string;
|
||||
|
||||
export interface GameMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
@@ -37,6 +38,7 @@ export interface GameMetadata {
|
||||
}
|
||||
|
||||
export interface PublisherMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
@@ -53,12 +55,12 @@ export interface _FetchGameMetadataParams {
|
||||
publisher: (query: string) => Promise<Publisher>
|
||||
developer: (query: string) => Promise<Developer>
|
||||
|
||||
createObject: (url: string) => Promise<RemoteObject>
|
||||
createObject: (url: string) => RemoteObject
|
||||
}
|
||||
|
||||
export interface _FetchPublisherMetadataParams {
|
||||
query: string;
|
||||
createObject: (url: string) => Promise<RemoteObject>;
|
||||
createObject: (url: string) => RemoteObject;
|
||||
}
|
||||
|
||||
export type _FetchDeveloperMetadataParams = _FetchPublisherMetadataParams;
|
||||
0
server/internal/objects/index.ts
Normal file
0
server/internal/objects/index.ts
Normal file
38
server/internal/objects/transactional.ts
Normal file
38
server/internal/objects/transactional.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
The purpose of this class is to hold references to remote objects (like images) until they're actually needed
|
||||
This is used as a utility in metadata handling, so we only fetch the objects if we're actually creating a database record.
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type TransactionTable = { [key: string]: string }; // ID to URL
|
||||
type GlobalTransactionRecord = { [key: string]: TransactionTable }; // Transaction ID to table
|
||||
|
||||
type Register = (url: string) => string;
|
||||
type Pull = () => Promise<void>;
|
||||
type Dump = () => void;
|
||||
|
||||
export class ObjectTransactionalHandler {
|
||||
private record: GlobalTransactionRecord = {};
|
||||
|
||||
new(): [Register, Pull, Dump] {
|
||||
const transactionId = uuidv4();
|
||||
|
||||
const register = (url: string) => {
|
||||
const objectId = uuidv4();
|
||||
this.record[transactionId][objectId] = url;
|
||||
|
||||
return objectId;
|
||||
}
|
||||
|
||||
const pull = async () => {
|
||||
// Dummy function
|
||||
dump();
|
||||
}
|
||||
|
||||
const dump = () => {
|
||||
delete this.record[transactionId];
|
||||
}
|
||||
|
||||
return [register, pull, dump];
|
||||
}
|
||||
}
|
||||
89
server/internal/utils/prioritylist.ts
Normal file
89
server/internal/utils/prioritylist.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { FilterConditionally } from "./typefilter";
|
||||
|
||||
interface PriorityTagged<T> {
|
||||
object: T,
|
||||
priority: number, // Higher takes priority
|
||||
addedIndex: number, // Lower takes priority
|
||||
}
|
||||
|
||||
export class PriorityList<T> {
|
||||
private source: Array<PriorityTagged<T>> = [];
|
||||
private cachedSorted: Array<T> | undefined;
|
||||
|
||||
push(item: T, priority: number = 0) {
|
||||
this.source.push({
|
||||
object: item,
|
||||
priority,
|
||||
addedIndex: this.source.length,
|
||||
});
|
||||
this.cachedSorted = undefined;
|
||||
}
|
||||
|
||||
pop(index: number = 0) {
|
||||
this.cachedSorted = undefined;
|
||||
return this.source.splice(index, 1)[0];
|
||||
}
|
||||
|
||||
values() {
|
||||
if (this.cachedSorted !== undefined) {
|
||||
return this.cachedSorted;
|
||||
}
|
||||
|
||||
const sorted = this.source.sort((a, b) => {
|
||||
if (a.priority == a.priority) {
|
||||
return a.addedIndex - b.addedIndex;
|
||||
}
|
||||
|
||||
return b.priority - a.priority;
|
||||
}).map((e) => e.object);
|
||||
this.cachedSorted = sorted;
|
||||
|
||||
return this.cachedSorted;
|
||||
}
|
||||
|
||||
find(predicate: (value: T, index: number, obj: T[]) => boolean) {
|
||||
return this.source.map((e) => e.object).find(predicate);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type IndexableProperty<T> = keyof FilterConditionally<T, (() => string) | string>;
|
||||
export class PriorityListIndexed<T> extends PriorityList<T> {
|
||||
private indexName: IndexableProperty<T>;
|
||||
private indexMap: { [key: string]: T } = {};
|
||||
|
||||
constructor(indexName: IndexableProperty<T>) {
|
||||
super();
|
||||
this.indexName = indexName;
|
||||
}
|
||||
|
||||
private getIndex(object: T): string {
|
||||
const index = object[this.indexName];
|
||||
|
||||
if (typeof index === 'function') {
|
||||
return index();
|
||||
}
|
||||
|
||||
return index as string;
|
||||
}
|
||||
|
||||
push(item: T, priority?: number): void {
|
||||
const index = this.getIndex(item);
|
||||
this.indexMap[index] = item;
|
||||
|
||||
super.push(item, priority);
|
||||
}
|
||||
|
||||
pop(position?: number): PriorityTagged<T> {
|
||||
const value = super.pop(position);
|
||||
|
||||
const index = this.getIndex(value.object);
|
||||
delete this.indexMap[index];
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
get(index: string) {
|
||||
return this.indexMap[index];
|
||||
}
|
||||
}
|
||||
1
server/internal/utils/typefilter.ts
Normal file
1
server/internal/utils/typefilter.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type FilterConditionally<Source, Condition> = Pick<Source, { [K in keyof Source]: Source[K] extends Condition ? K : never }[keyof Source]>;
|
||||
22
server/plugins/metadata.ts
Normal file
22
server/plugins/metadata.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { MetadataHandler, MetadataProvider } from "../internal/metadata";
|
||||
import { GiantBombProvider } from "../internal/metadata/giantbomb";
|
||||
|
||||
export const GlobalMedataHandler = new MetadataHandler();
|
||||
|
||||
const providerCreators: Array<() => MetadataProvider> = [() => new GiantBombProvider()];
|
||||
|
||||
export default defineNitroPlugin(async (nitro) => {
|
||||
for (const creator of providerCreators) {
|
||||
try {
|
||||
const instance = creator();
|
||||
GlobalMedataHandler.addProvider(instance);
|
||||
}
|
||||
catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
nitro.hooks.hook('request', (h3) => {
|
||||
h3.context.metadataHandler = GlobalMedataHandler;
|
||||
})
|
||||
});
|
||||
Reference in New Issue
Block a user