feat: image provider + importer partial backend

This commit is contained in:
DecDuck
2025-08-01 18:33:06 +10:00
parent 545a6b154a
commit a287138650
21 changed files with 287 additions and 58 deletions

View File

@ -16,7 +16,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types"; import type { GameMetadataSearchResult } from "~/server/internal/metadata/content/types";
const { game } = defineProps<{ const { game } = defineProps<{
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string }; game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };

View File

@ -171,7 +171,7 @@ import {
ListboxOption, ListboxOption,
ListboxOptions, ListboxOptions,
} from "@headlessui/vue"; } from "@headlessui/vue";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types"; import type { GameMetadataSearchResult } from "~/server/internal/metadata/content/types";
import { FetchError } from "ofetch"; import { FetchError } from "ofetch";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import { XCircleIcon } from "@heroicons/vue/24/solid"; import { XCircleIcon } from "@heroicons/vue/24/solid";

View File

@ -309,7 +309,7 @@ import {
import { XCircleIcon } from "@heroicons/vue/16/solid"; import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline"; import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types"; import type { GameMetadataSearchResult } from "~/server/internal/metadata/content/types";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",

View File

@ -0,0 +1,8 @@
-- AlterEnum
ALTER TYPE "MetadataSource" ADD VALUE 'SteamGridDB';
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -5,6 +5,7 @@ enum MetadataSource {
IGDB IGDB
Metacritic Metacritic
OpenCritic OpenCritic
SteamGridDB
} }
model Game { model Game {

View File

@ -0,0 +1,11 @@
import aclManager from "~/server/internal/acls";
import imageHandler from "~/server/internal/metadata/image";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:image:import"]);
if (!allowed) throw createError({ statusCode: 403 });
const images = await imageHandler.searchImages("space engineers");
return images;
});

View File

@ -2,7 +2,7 @@ import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import libraryManager from "~/server/internal/library"; import libraryManager from "~/server/internal/library";
import metadataHandler from "~/server/internal/metadata"; import metadataHandler from "~/server/internal/metadata/content";
const ImportGameBody = type({ const ImportGameBody = type({
library: "string", library: "string",

View File

@ -1,5 +1,5 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import metadataHandler from "~/server/internal/metadata"; import metadataHandler from "~/server/internal/metadata/content";
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]); const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]);

View File

@ -71,6 +71,7 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"game:version:update": "Update the version order on a game.", "game:version:update": "Update the version order on a game.",
"game:version:delete": "Delete a version for a game.", "game:version:delete": "Delete a version for a game.",
"game:image:new": "Upload an image for a game.", "game:image:new": "Upload an image for a game.",
"game:image:import": "Import images from image providers for a game.",
"game:image:delete": "Delete an image for a game.", "game:image:delete": "Delete an image for a game.",
"company:read": "Fetch companies.", "company:read": "Fetch companies.",

View File

@ -65,6 +65,7 @@ export const systemACLs = [
"game:version:update", "game:version:update",
"game:version:delete", "game:version:delete",
"game:image:new", "game:image:new",
"game:image:import",
"game:image:delete", "game:image:delete",
"company:read", "company:read",

View File

@ -0,0 +1,7 @@
# Metadata structure
## Content providers (`MetadataProvider`)
Types & implementation is under `./content`. Providers under `./providers`
## Image providers (`ImageProvider`)
Types & implementation is under `./image`. Providers under `./providers`

View File

@ -1,6 +1,11 @@
/**
* Metadata providers search and download game metadata
* Basically an overkill auto-fill
*/
import type { Prisma } from "~/prisma/client/client"; import type { Prisma } from "~/prisma/client/client";
import { MetadataSource } from "~/prisma/client/enums"; import { MetadataSource } from "~/prisma/client/enums";
import prisma from "../db/database"; import prisma from "../../db/database";
import type { import type {
_FetchGameMetadataParams, _FetchGameMetadataParams,
_FetchCompanyMetadataParams, _FetchCompanyMetadataParams,
@ -10,15 +15,15 @@ import type {
CompanyMetadata, CompanyMetadata,
GameMetadataRating, GameMetadataRating,
} from "./types"; } from "./types";
import { ObjectTransactionalHandler } from "../objects/transactional"; import { ObjectTransactionalHandler } from "../../objects/transactional";
import { PriorityListIndexed } from "../utils/prioritylist"; import { PriorityListIndexed } from "../../utils/prioritylist";
import { systemConfig } from "../config/sys-conf"; import { systemConfig } from "../../config/sys-conf";
import type { TaskRunContext } from "../tasks"; import type { TaskRunContext } from "../../tasks";
import taskHandler, { wrapTaskContext } from "../tasks"; import taskHandler, { wrapTaskContext } from "../../tasks";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { fuzzy } from "fast-fuzzy"; import { fuzzy } from "fast-fuzzy";
import { logger } from "~/server/internal/logging"; import { logger } from "~/server/internal/logging";
import libraryManager from "../library"; import libraryManager from "../../library";
import type { GameTagModel } from "~/prisma/client/models"; import type { GameTagModel } from "~/prisma/client/models";
export class MissingMetadataProviderConfig extends Error { export class MissingMetadataProviderConfig extends Error {

View File

@ -1,6 +1,6 @@
import type { Company, GameRating } from "~/prisma/client"; import type { Company, GameRating } from "~/prisma/client";
import type { TransactionDataType } from "../objects/transactional"; import type { TransactionDataType } from "../../objects/transactional";
import type { ObjectReference } from "../objects/objectHandler"; import type { ObjectReference } from "../../objects/objectHandler";
export interface GameMetadataSearchResult { export interface GameMetadataSearchResult {
id: string; id: string;

View File

@ -0,0 +1,81 @@
import type { MetadataSource } from "~/prisma/client/enums";
import type { ImageSearchResult } from "./types";
import type { TaskRunContext } from "../../tasks";
import { ObjectTransactionalHandler } from "../../objects/transactional";
import { PriorityListIndexed } from "../../utils/prioritylist";
import { logger } from "../../logging";
export abstract class ImageProvider {
abstract name(): string;
abstract source(): MetadataSource;
abstract searchImages(query: string): Promise<ImageSearchResult[]>;
abstract importImages(
images: ImageSearchResult[],
taskRunContext?: TaskRunContext,
): Promise<string[]>; // List of object IDs
}
/**
* Confusingly, does videos too.
*/
export class ImageHandler {
private providers: PriorityListIndexed<ImageProvider> =
new PriorityListIndexed("source");
private objectHandler: ObjectTransactionalHandler =
new ObjectTransactionalHandler();
addProvider(provider: ImageProvider, 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 searchImages(query: string) {
const providers = this.providers.values();
const promises = [];
for (const provider of providers) {
const localFetch = async () => {
try {
const providerResults = await provider.searchImages(query);
return providerResults.map((result) => ({ ...result, provider: provider.source() }));
} catch (e) {
throw `${provider.name()}: ${e}`;
}
};
promises.push(localFetch());
}
const result = await Promise.allSettled(promises);
const fails = result.filter((e) => e.status === "rejected");
if (fails.length > 0) {
const failText = fails
.map((e) => e.reason)
.map((e) => "" + e)
.join("\n");
logger.warn(
`Failed to fetch some images from providers. Errors:\n${failText}`,
);
}
const successes = result
.filter((e) => e.status === "fulfilled")
.map((e) => e.value)
.flat();
return successes;
}
}
export const imageHandler = new ImageHandler();
export default imageHandler;

View File

@ -0,0 +1,10 @@
export type ImageSearchResultType = "image" | "video";
export interface ImageSearchResult {
id: string; // internal identifier for the result
url: string;
type: ImageSearchResultType;
name?: string;
size?: number;
}

View File

@ -1,7 +1,7 @@
import type { CompanyModel } from "~/prisma/client/models"; import type { CompanyModel } from "~/prisma/client/models";
import { MetadataSource } from "~/prisma/client/enums"; import { MetadataSource } from "~/prisma/client/enums";
import type { MetadataProvider } from "."; import type { MetadataProvider } from "../content";
import { MissingMetadataProviderConfig } from "."; import { MissingMetadataProviderConfig } from "../content";
import type { import type {
GameMetadataSearchResult, GameMetadataSearchResult,
_FetchGameMetadataParams, _FetchGameMetadataParams,
@ -9,11 +9,11 @@ import type {
_FetchCompanyMetadataParams, _FetchCompanyMetadataParams,
CompanyMetadata, CompanyMetadata,
GameMetadataRating, GameMetadataRating,
} from "./types"; } from "../content/types";
import axios, { type AxiosRequestConfig } from "axios"; import axios, { type AxiosRequestConfig } from "axios";
import TurndownService from "turndown"; import TurndownService from "turndown";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import type { TaskRunContext } from "../tasks"; import type { TaskRunContext } from "../../tasks";
interface GiantBombResponseType<T> { interface GiantBombResponseType<T> {
error: "OK" | string; error: "OK" | string;
@ -277,6 +277,7 @@ export class GiantBombProvider implements MetadataProvider {
return metadata; return metadata;
} }
async fetchCompany({ async fetchCompany({
query, query,
createObject, createObject,

View File

@ -1,19 +1,19 @@
import type { CompanyModel } from "~/prisma/client/models"; import type { CompanyModel } from "~/prisma/client/models";
import { MetadataSource } from "~/prisma/client/enums"; import { MetadataSource } from "~/prisma/client/enums";
import type { MetadataProvider } from "."; import type { MetadataProvider } from "../content";
import { MissingMetadataProviderConfig } from "."; import { MissingMetadataProviderConfig } from "../content";
import type { import type {
GameMetadataSearchResult, GameMetadataSearchResult,
_FetchGameMetadataParams, _FetchGameMetadataParams,
GameMetadata, GameMetadata,
_FetchCompanyMetadataParams, _FetchCompanyMetadataParams,
CompanyMetadata, CompanyMetadata,
} from "./types"; } from "../content/types";
import type { AxiosRequestConfig } from "axios"; import type { AxiosRequestConfig } from "axios";
import axios from "axios"; import axios from "axios";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import * as jdenticon from "jdenticon"; import * as jdenticon from "jdenticon";
import type { TaskRunContext } from "../tasks"; import type { TaskRunContext } from "../../tasks";
import { logger } from "~/server/internal/logging"; import { logger } from "~/server/internal/logging";
type IGDBID = number; type IGDBID = number;

View File

@ -1,11 +1,11 @@
import { MetadataSource } from "~/prisma/client/enums"; import { MetadataSource } from "~/prisma/client/enums";
import type { MetadataProvider } from "."; import type { MetadataProvider } from "../content";
import type { import type {
_FetchGameMetadataParams, _FetchGameMetadataParams,
GameMetadata, GameMetadata,
_FetchCompanyMetadataParams, _FetchCompanyMetadataParams,
CompanyMetadata, CompanyMetadata,
} from "./types"; } from "../content/types";
import * as jdenticon from "jdenticon"; import * as jdenticon from "jdenticon";
export class ManualMetadataProvider implements MetadataProvider { export class ManualMetadataProvider implements MetadataProvider {

View File

@ -1,6 +1,6 @@
import type { CompanyModel } from "~/prisma/client/models"; import type { CompanyModel } from "~/prisma/client/models";
import { MetadataSource } from "~/prisma/client/enums"; import { MetadataSource } from "~/prisma/client/enums";
import type { MetadataProvider } from "."; import type { MetadataProvider } from "../content";
import type { import type {
GameMetadataSearchResult, GameMetadataSearchResult,
_FetchGameMetadataParams, _FetchGameMetadataParams,
@ -8,14 +8,14 @@ import type {
_FetchCompanyMetadataParams, _FetchCompanyMetadataParams,
CompanyMetadata, CompanyMetadata,
GameMetadataRating, GameMetadataRating,
} from "./types"; } from "../content/types";
import type { AxiosRequestConfig } from "axios"; import type { AxiosRequestConfig } from "axios";
import axios from "axios"; import axios from "axios";
import * as jdenticon from "jdenticon"; import * as jdenticon from "jdenticon";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import * as cheerio from "cheerio"; import * as cheerio from "cheerio";
import { type } from "arktype"; import { type } from "arktype";
import type { TaskRunContext } from "../tasks"; import type { TaskRunContext } from "../../tasks";
import { logger } from "~/server/internal/logging"; import { logger } from "~/server/internal/logging";
interface PCGamingWikiParseRawPage { interface PCGamingWikiParseRawPage {

View File

@ -0,0 +1,112 @@
import { MetadataSource } from "~/prisma/client/enums";
import type { TaskRunContext } from "../../tasks";
import type { ImageProvider } from "../image";
import { ImageSearchResultType, type ImageSearchResult } from "../image/types";
import { MissingMetadataProviderConfig } from "../content";
import type { NitroFetchOptions } from "nitropack";
interface SearchResult {
id: string;
name: string;
release_date: number;
}
interface SteamGridResponse<T> {
success: boolean;
data: Array<T>;
}
interface Grid {
id: number;
nsfw: boolean;
humor: boolean;
url: string;
thumb: string;
}
export class SteamGridDB implements ImageProvider {
private apikey: string;
constructor() {
const apikey = process.env.STEAMGRID_API_KEY;
if (!apikey)
throw new MissingMetadataProviderConfig("STEAMGRID_API_KEY", this.name());
this.apikey = apikey;
}
private async request<T>(
path: string,
opts?: NitroFetchOptions<
string,
| "get"
| "head"
| "patch"
| "post"
| "put"
| "delete"
| "connect"
| "options"
| "trace"
>,
) {
const fullPath = `https://www.steamgriddb.com/api/v2${path}`;
const response = await $fetch<T>(fullPath, {
...opts,
headers: { ...opts?.headers, Authorization: `Bearer ${this.apikey}` },
});
return response;
}
name(): string {
return "SteamGridDB";
}
source(): MetadataSource {
return MetadataSource.SteamGridDB;
}
async searchImages(query: string): Promise<ImageSearchResult[]> {
const games = await this.request<SteamGridResponse<SearchResult>>(
`/search/autocomplete/${encodeURIComponent(query)}`,
);
if (!games.success)
throw createError({
statusCode: 500,
statusMessage: "[SteamGridDB] Search indicated failed response.",
});
const firstGame = games.data.at(0);
if (!firstGame)
throw createError({
statusCode: 404,
statusMessage: "No results found.",
});
const grids = await this.request<SteamGridResponse<Grid>>(
`/grids/game/${firstGame.id}&nsfw=false&humor=false`,
);
if (!grids.success)
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch grids for result.",
});
const results = grids.data.map(
(e) =>
({
id: e.id.toString(),
url: e.thumb,
type: "image",
}) satisfies ImageSearchResult,
);
return results;
}
importImages(
images: ImageSearchResult[],
taskRunContext?: TaskRunContext,
): Promise<string[]> {
throw new Error("Method not implemented.");
}
}

View File

@ -1,20 +1,23 @@
import { applicationSettings } from "../internal/config/application-configuration"; import type { MetadataProvider } from "../internal/metadata/content";
import type { MetadataProvider } from "../internal/metadata"; import metadataHandler from "../internal/metadata/content";
import metadataHandler from "../internal/metadata"; import type { ImageProvider } from "../internal/metadata/image";
import { GiantBombProvider } from "../internal/metadata/giantbomb"; import imageHandler from "../internal/metadata/image";
import { IGDBProvider } from "../internal/metadata/igdb"; import { GiantBombProvider } from "../internal/metadata/providers/giantbomb";
import { ManualMetadataProvider } from "../internal/metadata/manual"; import { IGDBProvider } from "../internal/metadata/providers/igdb";
import { PCGamingWikiProvider } from "../internal/metadata/pcgamingwiki"; import { ManualMetadataProvider } from "../internal/metadata/providers/manual";
import { PCGamingWikiProvider } from "../internal/metadata/providers/pcgamingwiki";
import { logger } from "~/server/internal/logging"; import { logger } from "~/server/internal/logging";
import { SteamGridDB } from "../internal/metadata/providers/steamgriddb";
export default defineNitroPlugin(async (_nitro) => { export default defineNitroPlugin(async (_nitro) => {
const metadataProviders = [ const metadataProviders = [
GiantBombProvider, GiantBombProvider,
PCGamingWikiProvider, PCGamingWikiProvider,
IGDBProvider, IGDBProvider,
SteamGridDB,
]; ];
const providers = new Map<string, MetadataProvider>(); const providers = new Map<string, MetadataProvider | ImageProvider>();
for (const provider of metadataProviders) { for (const provider of metadataProviders) {
try { try {
@ -22,37 +25,25 @@ export default defineNitroPlugin(async (_nitro) => {
const id = prov.source(); const id = prov.source();
providers.set(id, prov); providers.set(id, prov);
logger.info(`enabled metadata provider: ${prov.name()}`); logger.info(`created metadata/image provider: ${prov.name()}`);
} catch (e) { } catch (e) {
logger.warn(`skipping metadata provider setup: ${e}`); logger.warn(`skipping metadata provider setup: ${e}`);
} }
} }
// Add providers based on their position in the application settings const max = metadataProviders.length;
const configuredProviderList = for (const [index, provider] of providers.entries().map((e, i) => [i, e[1]] as const)) {
await applicationSettings.get("metadataProviders");
const max = configuredProviderList.length;
for (const [index, providerId] of configuredProviderList.entries()) {
const priority = max * 2 - index; // Offset by the length --- (max - index) + max const priority = max * 2 - index; // Offset by the length --- (max - index) + max
const provider = providers.get(providerId);
if (!provider) {
logger.warn(`failed to add existing metadata provider: ${providerId}`);
continue;
}
metadataHandler.addProvider(provider, priority);
providers.delete(providerId);
}
// Add the rest with no position if ((provider as MetadataProvider)["search"]) {
for (const [, provider] of providers.entries()) { logger.info(`added ${provider.name()} as metadata provider`);
metadataHandler.addProvider(provider); metadataHandler.addProvider(provider as MetadataProvider, priority);
}
if((provider as ImageProvider)["searchImages"]){
logger.info(`added ${provider.name()} as image provider`);
imageHandler.addProvider(provider as ImageProvider, priority);
}
} }
metadataHandler.addProvider(new ManualMetadataProvider(), -1000); metadataHandler.addProvider(new ManualMetadataProvider(), -1000);
// Update the applicatonConfig
await applicationSettings.set(
"metadataProviders",
metadataHandler.fetchProviderIdsInOrder(),
);
}); });