diff --git a/nuxt.config.ts b/nuxt.config.ts index c9f3eeb..acda755 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -65,6 +65,7 @@ export default defineNuxtConfig({ "data:", "https://www.giantbomb.com", "https://images.pcgamingwiki.com", + "https://images.igdb.com", ], }, }, diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index 02de9d0..e9435e4 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -1,5 +1,5 @@ import { Developer, MetadataSource, Publisher } from "@prisma/client"; -import { MetadataProvider, MissingMetadataProviderApiKey } from "."; +import { MetadataProvider, MissingMetadataProviderConfig } from "."; import { GameMetadataSearchResult, _FetchGameMetadataParams, @@ -81,7 +81,11 @@ export class GiantBombProvider implements MetadataProvider { constructor() { const apikey = process.env.GIANT_BOMB_API_KEY; - if (!apikey) throw new MissingMetadataProviderApiKey(this.name()); + if (!apikey) + throw new MissingMetadataProviderConfig( + "GIANT_BOMB_API_KEY", + this.name() + ); this.apikey = apikey; diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts new file mode 100644 index 0000000..c653cb7 --- /dev/null +++ b/server/internal/metadata/igdb.ts @@ -0,0 +1,399 @@ +import { Developer, MetadataSource, Publisher } from "@prisma/client"; +import { MetadataProvider, MissingMetadataProviderConfig } from "."; +import { + GameMetadataSearchResult, + _FetchGameMetadataParams, + GameMetadata, + _FetchPublisherMetadataParams, + PublisherMetadata, + _FetchDeveloperMetadataParams, + DeveloperMetadata, +} from "./types"; +import axios, { AxiosRequestConfig } from "axios"; +import { inspect } from "util"; +import moment from "moment"; + +type IGDBID = number; + +interface TwitchAuthResponse { + access_token: string; + expires_in: number; + token_type: string; // likely 'bearer' +} + +interface IGDBErrorResponse { + title: string; + status: number; + cause: string; +} + +interface IGDBItem { + id: IGDBID; +} + +// denotes role a company had in a game +interface IGDBInvolvedCompany extends IGDBItem { + company: IGDBID; + game: IGDBID; + + developer: boolean; + porting: boolean; + publisher: boolean; + supporting: boolean; + + created_at: number; + updated_at: number; +} + +interface IGDBCompany extends IGDBItem { + name: string; + country: number; // ISO 3166-1 country code + description: string; + logo: IGDBID; + parent: IGDBID; + slug: string; + start_date: number; + status: IGDBID; + websites: IGDBID[]; +} + +interface IGDBCompanyWebsite extends IGDBItem { + trusted: boolean; + url: string; +} + +interface IGDBCover extends IGDBItem { + url: string; +} + +interface IGDBSearchStub extends IGDBItem { + name: string; + cover: IGDBID; + first_release_date: number; // unix timestamp + summary: string; +} + +// https://api-docs.igdb.com/?shell#game +interface IGDBGameFull extends IGDBSearchStub { + age_ratings?: IGDBID[]; + aggregated_rating?: number; + aggregated_rating_count?: number; + alternative_names?: IGDBID[]; + artworks?: IGDBID[]; + bundles?: IGDBID[]; + checksum?: string; + collections?: IGDBID[]; + created_at: number; // unix timestamp + dlcs?: IGDBID[]; + expanded_games?: IGDBID[]; + expansions?: IGDBID[]; + external_games?: IGDBID[]; + forks?: IGDBID[]; + franchise?: IGDBID; + franchises?: IGDBID[]; + game_engines?: IGDBID[]; + game_localizations?: IGDBID[]; + game_modes?: IGDBID[]; + game_status?: IGDBID; + game_type?: IGDBID; + genres?: IGDBID[]; + hypes?: number; + involved_companies?: IGDBID[]; + keywords?: IGDBID[]; + language_supports?: IGDBID[]; + multiplayer_modes?: IGDBID[]; + platforms?: IGDBID[]; + player_perspectives?: IGDBID[]; + ports?: IGDBID[]; + rating?: number; + rating_count?: number; + release_dates?: IGDBID[]; + remakes?: IGDBID[]; + remasters?: IGDBID[]; + screenshots?: IGDBID[]; + similar_games?: IGDBID[]; + slug: string; + standalone_expansions?: IGDBID[]; + storyline?: string; + tags?: IGDBID[]; + themes?: IGDBID[]; + total_rating?: number; + total_rating_count?: number; + updated_at: number; + url: string; + version_parent?: IGDBID; + version_title?: string; + videos?: IGDBID[]; + websites?: IGDBID[]; +} + +// Api Docs: https://api-docs.igdb.com/ +export class IGDBProvider implements MetadataProvider { + private client_id: string; + private client_secret: string; + private access_token: string; + + constructor() { + const client_id = process.env.IGDB_CLIENT_ID; + if (!client_id) + throw new MissingMetadataProviderConfig("IGDB_CLIENT_ID", this.name()); + const client_secret = process.env.IGDB_CLIENT_SECRET; + if (!client_secret) + throw new MissingMetadataProviderConfig( + "IGDB_CLIENT_SECRET", + this.name() + ); + + this.client_id = client_id; + this.client_secret = client_secret; + + this.access_token = "6lkqltu4m70i46jhcdrz8qt8tb7rdh"; + // this.authWithTwitch(); + } + + private async authWithTwitch() { + const params = new URLSearchParams({ + client_id: this.client_id, + client_secret: this.client_secret, + grant_type: "client_credentials", + }); + + const response = await axios.request({ + url: `https://id.twitch.tv/oauth2/token?${params.toString()}`, + baseURL: "", + method: "POST", + }); + + console.log(inspect(response.data)); + + this.access_token = response.data.access_token; + // TODO: handle token expiration, time in seconds is provided, on a long running server + // this WILL be an issue. Can use node timers, or maybe nuxt tasks? problem is + // that idk if tasks can be variable like twitch wants, expires_in is variable + } + + private async request( + resource: string, + body: string, + options?: AxiosRequestConfig + ) { + // prevent calling api before auth is complete + if (this.access_token.length <= 0) + throw new Error( + "IGDB either failed to authenticate, or has not done so yet" + ); + + const finalURL = `https://api.igdb.com/v4/${resource}`; + + const overlay: AxiosRequestConfig = { + url: finalURL, + baseURL: "", + method: "POST", + data: body, + headers: { + Accept: "application/json", + "Client-ID": this.client_id, + Authorization: `Bearer ${this.access_token}`, + "content-type": "text/plain", + }, + }; + const response = await axios.request( + Object.assign({}, options, overlay) + ); + + if (response.status !== 200) { + let cause = ""; + + response.data.forEach((item) => { + if ("cause" in item) cause = item.cause; + }); + + throw new Error( + `Error in igdb \nStatus Code: ${response.status} \nCause: ${cause}` + ); + } + + // should not have an error object if the status code is 200 + return response.data; + } + + private async _getMediaInternal(mediaID: IGDBID, type: string) { + const body = `where id = ${mediaID}; fields url;`; + const response = await this.request(type, body); + + let result = ""; + + response.forEach((cover) => { + if (cover.url.startsWith("https:")) { + result = cover.url; + } else { + // twitch *sometimes* provides it in the format "//images.igdb.com" + result = `https:${cover.url}`; + } + }); + return result; + } + + private async getCoverURl(id: IGDBID) { + return await this._getMediaInternal(id, "covers"); + } + + private async getArtworkURl(id: IGDBID) { + return await this._getMediaInternal(id, "artworks"); + } + + private async getCompanyLogoURl(id: IGDBID) { + return await this._getMediaInternal(id, "company_logos"); + } + + private trimMessage(msg: string, len: number) { + return msg.length > len ? msg.substring(0, 280) + "..." : msg; + } + + id() { + return "igdb"; + } + name() { + return "IGDB"; + } + source() { + return MetadataSource.IGDB; + } + + async search(query: string): Promise { + // throw new Error("Not implemented"); + + const body = `search "${query}"; fields name,cover,first_release_date,summary; limit 3;`; + const response = await this.request("games", body); + + const results: GameMetadataSearchResult[] = []; + for (let i = 0; i < response.length; i++) { + results.push({ + id: "" + response[i].id, + name: response[i].name, + icon: await this.getCoverURl(response[i].cover), + description: response[i].summary, + year: moment.unix(response[i].first_release_date).year(), + }); + } + + return results; + } + async fetchGame({ + id, + publisher, + developer, + createObject, + }: _FetchGameMetadataParams): Promise { + const body = `where id = ${id}; fields *;`; + const response = await this.request("games", body); + + for (let i = 0; i < response.length; i++) { + const icon = createObject(await this.getCoverURl(response[i].cover)); + let banner = ""; + + const images = [icon]; + for (const art of response[i]?.artworks ?? []) { + // if banner not set + if (banner.length <= 0) { + banner = createObject(await this.getArtworkURl(art)); + images.push(banner); + } else images.push(createObject(await this.getArtworkURl(art))); + } + + const publishers: Publisher[] = []; + const developers: Developer[] = []; + for (const involved_company of response[i]?.involved_companies ?? []) { + // get details about the involved company + const involved_company_response = + await this.request( + "involved_companies", + `where id = ${involved_company}; fields *;` + ); + for (const found_involed of involved_company_response) { + // now we need to get the actual company so we can get the name + const find_company_response = await this.request< + { name: string } & IGDBItem + >("companies", `where id = ${found_involed.company}; fields name;`); + + for (const company of find_company_response) { + // if company was a dev or publisher + // CANNOT use else since a company can be both + + // TODO: why did this call manual metadata??? + + if (found_involed.developer) + developers.push(await developer(company.name)); + if (found_involed.publisher) + publishers.push(await publisher(company.name)); + } + } + } + + return { + id: "" + response[i].id, + name: response[i].name, + shortDescription: this.trimMessage(response[i].summary, 280), + description: response[i].summary, + released: moment.unix(response[i].first_release_date).toDate(), + + reviewCount: response[i]?.total_rating_count ?? 0, + reviewRating: response[i]?.total_rating ?? 0, + + publishers: [], + developers: [], + + icon, + bannerId: banner, + coverId: icon, + images, + }; + } + + throw new Error("No game found on igdb with that id"); + } + async fetchPublisher({ + query, + createObject, + }: _FetchPublisherMetadataParams): Promise { + const response = await this.request( + "companies", + `search "${query}"; fields *;` + ); + + for (const company of response) { + const logo = createObject(await this.getCompanyLogoURl(company.logo)); + + let company_url = ""; + for (const company_site of company.websites) { + const company_site_res = await this.request( + "company_websites", + `where id = ${company_site}; fields *;` + ); + + for (const site of company_site_res) { + if (company_url.length <= 0) company_url = site.url; + } + } + const metadata: PublisherMetadata = { + id: "" + company.id, + name: company.name, + shortDescription: this.trimMessage(company.description, 280), + description: company.description, + website: company_url, + + logo: logo, + banner: logo, + }; + + return metadata; + } + + throw new Error("No results found"); + } + async fetchDeveloper( + params: _FetchDeveloperMetadataParams + ): Promise { + return await this.fetchPublisher(params); + } +} diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 4c18a27..5d82751 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -20,12 +20,13 @@ import { PriorityList, PriorityListIndexed } from "../utils/prioritylist"; import { GiantBombProvider } from "./giantbomb"; import { ManualMetadataProvider } from "./manual"; import { PCGamingWikiProvider } from "./pcgamingwiki"; +import { IGDBProvider } from "./igdb"; -export class MissingMetadataProviderApiKey extends Error { +export class MissingMetadataProviderConfig extends Error { private providerName: string; - constructor(providerName: string) { - super(`Missing ${providerName} api key`); + constructor(configKey: string, providerName: string) { + super(`Missing config item ${configKey} for ${providerName}`); this.providerName = providerName; } @@ -34,6 +35,9 @@ export class MissingMetadataProviderApiKey extends Error { } } +// TODO: add useragent to all outbound api calls (best practice) +export const DropUserAgent = "Drop/0.2"; + export abstract class MetadataProvider { abstract id(): string; abstract name(): string; @@ -208,6 +212,8 @@ export class MetadataHandler { if (existing) return existing; for (const provider of this.providers.values() as any) { + // TODO: why did this call manual metadata??? + const [createObject, pullObjects, dumpObjects] = this.objectHandler.new( {}, ["internal:read"] @@ -256,6 +262,7 @@ const metadataProviders = [ GiantBombProvider, ManualMetadataProvider, PCGamingWikiProvider, + IGDBProvider, ]; for (const provider of metadataProviders) { @@ -265,7 +272,7 @@ for (const provider of metadataProviders) { enabledMedadataProviders.push(prov.id()); console.log(`enabled metadata provider: ${prov.name()}`); } catch (e) { - if (e instanceof MissingMetadataProviderApiKey) { + if (e instanceof MissingMetadataProviderConfig) { console.warn(`Disabling ${e.getProviderName()} metadata provider`); } else { console.error(`skipping metadata provider setup: ${e}`); diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index be92c03..61ca7ff 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -1,5 +1,5 @@ import { Developer, MetadataSource, Publisher } from "@prisma/client"; -import { MetadataProvider, MissingMetadataProviderApiKey } from "."; +import { MetadataProvider, MissingMetadataProviderConfig } from "."; import { GameMetadataSearchResult, _FetchGameMetadataParams, @@ -180,7 +180,6 @@ export class PCGamingWikiProvider implements MetadataProvider { if (game.Publishers !== undefined) { const pubListClean = this.parseCompanyStr(game.Publishers); for (const pub of pubListClean) { - console.log("Found publisher: ", pub); publishers.push(await publisher(pub)); } } @@ -189,8 +188,7 @@ export class PCGamingWikiProvider implements MetadataProvider { if (game.Developers !== undefined) { const devListClean = this.parseCompanyStr(game.Developers); for (const dev of devListClean) { - console.log("Found dev: ", dev); - developers.push(await developer(dev.replace("Company:", ""))); + developers.push(await developer(dev)); } } @@ -237,14 +235,11 @@ export class PCGamingWikiProvider implements MetadataProvider { format: "json", }); - console.log("Searching for: " + query); const res = await this.request(searchParams); // TODO: replace const icon = createObject(jdenticon.toPng(query, 512)); - console.log("Found: ", res.data.cargoquery); - for (let i = 0; i < res.data.cargoquery.length; i++) { const company = this.markNullUndefined(res.data.cargoquery[i].title);