mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-09 20:12:10 +10:00
Compare commits
1 Commits
d708151c03
...
image-impo
| Author | SHA1 | Date | |
|---|---|---|---|
| a287138650 |
@ -16,7 +16,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||
import type { GameMetadataSearchResult } from "~/server/internal/metadata/content/types";
|
||||
|
||||
const { game } = defineProps<{
|
||||
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };
|
||||
|
||||
@ -171,7 +171,7 @@ import {
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} 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 type { SerializeObject } from "nitropack";
|
||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||
|
||||
@ -309,7 +309,7 @@ import {
|
||||
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
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({
|
||||
layout: "admin",
|
||||
|
||||
@ -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));
|
||||
@ -5,6 +5,7 @@ enum MetadataSource {
|
||||
IGDB
|
||||
Metacritic
|
||||
OpenCritic
|
||||
SteamGridDB
|
||||
}
|
||||
|
||||
model Game {
|
||||
|
||||
11
server/api/v1/admin/game/image/import/index.get.ts
Normal file
11
server/api/v1/admin/game/image/import/index.get.ts
Normal 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;
|
||||
});
|
||||
@ -2,7 +2,7 @@ import { type } from "arktype";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import libraryManager from "~/server/internal/library";
|
||||
import metadataHandler from "~/server/internal/metadata";
|
||||
import metadataHandler from "~/server/internal/metadata/content";
|
||||
|
||||
const ImportGameBody = type({
|
||||
library: "string",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import metadataHandler from "~/server/internal/metadata";
|
||||
import metadataHandler from "~/server/internal/metadata/content";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]);
|
||||
|
||||
@ -71,6 +71,7 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
|
||||
"game:version:update": "Update the version order on a game.",
|
||||
"game:version:delete": "Delete a version 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.",
|
||||
|
||||
"company:read": "Fetch companies.",
|
||||
|
||||
@ -65,6 +65,7 @@ export const systemACLs = [
|
||||
"game:version:update",
|
||||
"game:version:delete",
|
||||
"game:image:new",
|
||||
"game:image:import",
|
||||
"game:image:delete",
|
||||
|
||||
"company:read",
|
||||
|
||||
7
server/internal/metadata/README.md
Normal file
7
server/internal/metadata/README.md
Normal 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`
|
||||
@ -1,6 +1,11 @@
|
||||
/**
|
||||
* Metadata providers search and download game metadata
|
||||
* Basically an overkill auto-fill
|
||||
*/
|
||||
|
||||
import type { Prisma } from "~/prisma/client/client";
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
import prisma from "../db/database";
|
||||
import prisma from "../../db/database";
|
||||
import type {
|
||||
_FetchGameMetadataParams,
|
||||
_FetchCompanyMetadataParams,
|
||||
@ -10,15 +15,15 @@ import type {
|
||||
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 { 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";
|
||||
import libraryManager from "../library";
|
||||
import libraryManager from "../../library";
|
||||
import type { GameTagModel } from "~/prisma/client/models";
|
||||
|
||||
export class MissingMetadataProviderConfig extends Error {
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Company, GameRating } from "~/prisma/client";
|
||||
import type { TransactionDataType } from "../objects/transactional";
|
||||
import type { ObjectReference } from "../objects/objectHandler";
|
||||
import type { TransactionDataType } from "../../objects/transactional";
|
||||
import type { ObjectReference } from "../../objects/objectHandler";
|
||||
|
||||
export interface GameMetadataSearchResult {
|
||||
id: string;
|
||||
81
server/internal/metadata/image/index.ts
Normal file
81
server/internal/metadata/image/index.ts
Normal 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;
|
||||
10
server/internal/metadata/image/types.d.ts
vendored
Normal file
10
server/internal/metadata/image/types.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import type { CompanyModel } from "~/prisma/client/models";
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
import type { MetadataProvider } from ".";
|
||||
import { MissingMetadataProviderConfig } from ".";
|
||||
import type { MetadataProvider } from "../content";
|
||||
import { MissingMetadataProviderConfig } from "../content";
|
||||
import type {
|
||||
GameMetadataSearchResult,
|
||||
_FetchGameMetadataParams,
|
||||
@ -9,11 +9,11 @@ import type {
|
||||
_FetchCompanyMetadataParams,
|
||||
CompanyMetadata,
|
||||
GameMetadataRating,
|
||||
} from "./types";
|
||||
} from "../content/types";
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import TurndownService from "turndown";
|
||||
import { DateTime } from "luxon";
|
||||
import type { TaskRunContext } from "../tasks";
|
||||
import type { TaskRunContext } from "../../tasks";
|
||||
|
||||
interface GiantBombResponseType<T> {
|
||||
error: "OK" | string;
|
||||
@ -277,6 +277,7 @@ export class GiantBombProvider implements MetadataProvider {
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async fetchCompany({
|
||||
query,
|
||||
createObject,
|
||||
@ -1,19 +1,19 @@
|
||||
import type { CompanyModel } from "~/prisma/client/models";
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
import type { MetadataProvider } from ".";
|
||||
import { MissingMetadataProviderConfig } from ".";
|
||||
import type { MetadataProvider } from "../content";
|
||||
import { MissingMetadataProviderConfig } from "../content";
|
||||
import type {
|
||||
GameMetadataSearchResult,
|
||||
_FetchGameMetadataParams,
|
||||
GameMetadata,
|
||||
_FetchCompanyMetadataParams,
|
||||
CompanyMetadata,
|
||||
} from "./types";
|
||||
} from "../content/types";
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
import axios from "axios";
|
||||
import { DateTime } from "luxon";
|
||||
import * as jdenticon from "jdenticon";
|
||||
import type { TaskRunContext } from "../tasks";
|
||||
import type { TaskRunContext } from "../../tasks";
|
||||
import { logger } from "~/server/internal/logging";
|
||||
|
||||
type IGDBID = number;
|
||||
@ -1,11 +1,11 @@
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
import type { MetadataProvider } from ".";
|
||||
import type { MetadataProvider } from "../content";
|
||||
import type {
|
||||
_FetchGameMetadataParams,
|
||||
GameMetadata,
|
||||
_FetchCompanyMetadataParams,
|
||||
CompanyMetadata,
|
||||
} from "./types";
|
||||
} from "../content/types";
|
||||
import * as jdenticon from "jdenticon";
|
||||
|
||||
export class ManualMetadataProvider implements MetadataProvider {
|
||||
@ -1,6 +1,6 @@
|
||||
import type { CompanyModel } from "~/prisma/client/models";
|
||||
import { MetadataSource } from "~/prisma/client/enums";
|
||||
import type { MetadataProvider } from ".";
|
||||
import type { MetadataProvider } from "../content";
|
||||
import type {
|
||||
GameMetadataSearchResult,
|
||||
_FetchGameMetadataParams,
|
||||
@ -8,14 +8,14 @@ import type {
|
||||
_FetchCompanyMetadataParams,
|
||||
CompanyMetadata,
|
||||
GameMetadataRating,
|
||||
} from "./types";
|
||||
} from "../content/types";
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
import axios from "axios";
|
||||
import * as jdenticon from "jdenticon";
|
||||
import { DateTime } from "luxon";
|
||||
import * as cheerio from "cheerio";
|
||||
import { type } from "arktype";
|
||||
import type { TaskRunContext } from "../tasks";
|
||||
import type { TaskRunContext } from "../../tasks";
|
||||
import { logger } from "~/server/internal/logging";
|
||||
|
||||
interface PCGamingWikiParseRawPage {
|
||||
112
server/internal/metadata/providers/steamgriddb.ts
Normal file
112
server/internal/metadata/providers/steamgriddb.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,23 @@
|
||||
import { applicationSettings } from "../internal/config/application-configuration";
|
||||
import type { MetadataProvider } from "../internal/metadata";
|
||||
import metadataHandler from "../internal/metadata";
|
||||
import { GiantBombProvider } from "../internal/metadata/giantbomb";
|
||||
import { IGDBProvider } from "../internal/metadata/igdb";
|
||||
import { ManualMetadataProvider } from "../internal/metadata/manual";
|
||||
import { PCGamingWikiProvider } from "../internal/metadata/pcgamingwiki";
|
||||
import type { MetadataProvider } from "../internal/metadata/content";
|
||||
import metadataHandler from "../internal/metadata/content";
|
||||
import type { ImageProvider } from "../internal/metadata/image";
|
||||
import imageHandler from "../internal/metadata/image";
|
||||
import { GiantBombProvider } from "../internal/metadata/providers/giantbomb";
|
||||
import { IGDBProvider } from "../internal/metadata/providers/igdb";
|
||||
import { ManualMetadataProvider } from "../internal/metadata/providers/manual";
|
||||
import { PCGamingWikiProvider } from "../internal/metadata/providers/pcgamingwiki";
|
||||
import { logger } from "~/server/internal/logging";
|
||||
import { SteamGridDB } from "../internal/metadata/providers/steamgriddb";
|
||||
|
||||
export default defineNitroPlugin(async (_nitro) => {
|
||||
const metadataProviders = [
|
||||
GiantBombProvider,
|
||||
PCGamingWikiProvider,
|
||||
IGDBProvider,
|
||||
SteamGridDB,
|
||||
];
|
||||
|
||||
const providers = new Map<string, MetadataProvider>();
|
||||
const providers = new Map<string, MetadataProvider | ImageProvider>();
|
||||
|
||||
for (const provider of metadataProviders) {
|
||||
try {
|
||||
@ -22,37 +25,25 @@ export default defineNitroPlugin(async (_nitro) => {
|
||||
const id = prov.source();
|
||||
providers.set(id, prov);
|
||||
|
||||
logger.info(`enabled metadata provider: ${prov.name()}`);
|
||||
logger.info(`created metadata/image provider: ${prov.name()}`);
|
||||
} catch (e) {
|
||||
logger.warn(`skipping metadata provider setup: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add providers based on their position in the application settings
|
||||
const configuredProviderList =
|
||||
await applicationSettings.get("metadataProviders");
|
||||
const max = configuredProviderList.length;
|
||||
for (const [index, providerId] of configuredProviderList.entries()) {
|
||||
const max = metadataProviders.length;
|
||||
for (const [index, provider] of providers.entries().map((e, i) => [i, e[1]] as const)) {
|
||||
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
|
||||
for (const [, provider] of providers.entries()) {
|
||||
metadataHandler.addProvider(provider);
|
||||
if ((provider as MetadataProvider)["search"]) {
|
||||
logger.info(`added ${provider.name()} as metadata 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);
|
||||
|
||||
// Update the applicatonConfig
|
||||
await applicationSettings.set(
|
||||
"metadataProviders",
|
||||
metadataHandler.fetchProviderIdsInOrder(),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user