diff --git a/README.md b/README.md index 61f013e..7cd31f3 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Now you select the result you want, and the plugin will cast its magic, creating | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | [Jikan](https://jikan.moe/) | Jikan is an API that uses [My Anime List](https://myanimelist.net) and offers metadata for anime. | series, movies, specials, OVAs, manga, manwha, novels | No | 60 per minute and 3 per second | Yes | | [OMDb](https://www.omdbapi.com/) | OMDb is an API that offers metadata for movies, series, and games. | series, movies, games | Yes, you can get a free key here [here](https://www.omdbapi.com/apikey.aspx) | 1000 per day | No | -| [TMDB](https://www.themoviedb.org/) | TMDB is a API that offers community editable metadata for movies and series. | series, movies | Yes, by making an account [here](https://www.themoviedb.org/signup) and getting your `API Key` (**not** `API Read Access Token`) [here](https://www.themoviedb.org/settings/api) | 50 per second | Yes | +| [TMDB](https://www.themoviedb.org/) | TMDB is a API that offers community editable metadata for movies and series. | series, movies | Yes, by making an account [here](https://www.themoviedb.org/signup) and getting your `API Read Access Token` (**not** `API Key`) [here](https://www.themoviedb.org/settings/api) | 50 per second | Yes | | [MusicBrainz](https://musicbrainz.org/) | MusicBrainz is an API that offers information about music releases. | music releases | No | 50 per second | No | | [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) | The Wikipedia API allows access to all Wikipedia articles. | wiki articles | No | None | No | | [Steam](https://store.steampowered.com/) | The Steam API offers information on all Steam games. | games | No | 10000 per day | No | @@ -152,6 +152,11 @@ Now you select the result you want, and the plugin will cast its magic, creating - the ID you need is the ID of the movie or show on [IMDb](https://www.imdb.com) - you can find this ID in the URL - e.g. for "Rogue One" the URL looks like this `https://www.imdb.com/title/tt3748528/` so the ID is `tt3748528` +- [TMDB](https://www.themoviedb.org/) + - the ID you need is the numeric value in the URL directly following `/movie/` or `/tv/` + - e.g. for "Stargate" the URL looks like this `https://www.themoviedb.org/movie/2164-stargate` so the ID is `2164` + - When searching by ID you need to select `TMDBSeriesAPI`, `TMDBSeasonAPI`, or `TMDBMovieAPI` for series, seasons, and movies respectively. + - Season ID searches use the format `/season/` - season 1 of The Expanse expects `63639/season/1` - [MusicBrainz](https://musicbrainz.org/) - the id of a release is not easily accessible; you are better off just searching by title - the search is generally for albums but you can have a more granular search like so: diff --git a/src/api/apis/IGDBAPI.ts b/src/api/apis/IGDBAPI.ts new file mode 100644 index 0000000..14094d0 --- /dev/null +++ b/src/api/apis/IGDBAPI.ts @@ -0,0 +1,159 @@ +import { requestUrl } from 'obsidian'; +import type MediaDbPlugin from '../../main'; +import { GameModel } from '../../models/GameModel'; +import type { MediaTypeModel } from '../../models/MediaTypeModel'; +import { MediaType } from '../../utils/MediaType'; +import { APIModel } from '../APIModel'; + +interface IGDBCover { + url: string; +} + +interface IGDBGenre { + name: string; +} + +interface IGDBCompany { + name: string; +} + +interface IGDBInvolvedCompany { + company: IGDBCompany; + developer: boolean; + publisher: boolean; +} + +interface IGDBGame { + id: number; + name: string; + cover?: IGDBCover; + first_release_date?: number; + summary?: string; + total_rating?: number; + url?: string; + genres?: IGDBGenre[]; + involved_companies?: IGDBInvolvedCompany[]; +} + +interface TwitchAuthResponse { + access_token: string; + expires_in: number; +} + +export class IGDBAPI extends APIModel { + plugin: MediaDbPlugin; + apiDateFormat: string = 'YYYY-MM-DD'; + private accessToken: string = ''; + private tokenExpiry: number = 0; + + constructor(plugin: MediaDbPlugin) { + super(); + this.plugin = plugin; + this.apiName = 'IGDBAPI'; + this.apiDescription = 'A free API for games (Requires Twitch Client ID & Secret).'; + this.apiUrl = 'https://api.igdb.com/v4'; + this.types = [MediaType.Game]; + } + + private async getAuthToken(): Promise { + const currentTime = Date.now(); + if (this.accessToken && currentTime < this.tokenExpiry) return this.accessToken; + + const clientId = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientId); + const clientSecret = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientSecret); + + if (!clientId || !clientSecret) { + throw Error(`MDB | Client ID or Client Secret for ${this.apiName} missing.`); + } + console.log(`MDB | Refreshing Twitch Auth Token for ${this.apiName}`); + const response = await requestUrl({ + url: `https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`, + method: 'POST', + }); + if (response.status !== 200) throw Error(`MDB | Auth failed for ${this.apiName}. Check Credentials.`); + const data = response.json as TwitchAuthResponse; + this.accessToken = data.access_token; + this.tokenExpiry = currentTime + data.expires_in * 1000 - 60000; + return this.accessToken; + } + + async searchByTitle(title: string): Promise { + console.log(`MDB | api "${this.apiName}" queried by Title`); + const clientId = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientId); + if (!clientId) throw Error(`MDB | Client ID for ${this.apiName} missing.`); + const token = await this.getAuthToken(); + const queryBody = `search "${title}"; fields name, cover.url, first_release_date, summary, total_rating; limit 20;`; + const response = await requestUrl({ + url: `${this.apiUrl}/games`, + method: 'POST', + headers: { 'Client-ID': clientId, Authorization: `Bearer ${token}`, Accept: 'application/json' }, + body: queryBody, + }); + if (response.status !== 200) throw Error(`MDB | Received status code ${response.status} from ${this.apiName}.`); + + const data = response.json as IGDBGame[]; + return data.map(result => { + const year = result.first_release_date ? new Date(result.first_release_date * 1000).getFullYear().toString() : ''; + const image = result.cover?.url ? 'https:' + result.cover.url.replace('t_thumb', 't_cover_big') : ''; + return new GameModel({ + type: MediaType.Game, + title: result.name, + englishTitle: result.name, + year: year, + dataSource: this.apiName, + id: result.id.toString(), + image: image, + }); + }); + } + + async getById(id: string): Promise { + console.log(`MDB | api "${this.apiName}" queried by ID`); + const clientId = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientId); + if (!clientId) throw Error(`MDB | Client ID for ${this.apiName} missing.`); + const token = await this.getAuthToken(); + const queryBody = `fields name, cover.url, first_release_date, summary, total_rating, url, genres.name, involved_companies.company.name, involved_companies.developer, involved_companies.publisher; where id = ${id};`; + const response = await requestUrl({ + url: `${this.apiUrl}/games`, + method: 'POST', + headers: { 'Client-ID': clientId, Authorization: `Bearer ${token}`, Accept: 'application/json' }, + body: queryBody, + }); + if (response.status !== 200) throw Error(`MDB | Received status code ${response.status} from ${this.apiName}.`); + + const data = response.json as IGDBGame[]; + if (!data || data.length === 0) throw Error(`MDB | No result found for ID ${id}`); + const result = data[0]; + + const developers: string[] = []; + const publishers: string[] = []; + result.involved_companies?.forEach(c => { + if (c.developer) developers.push(c.company.name); + if (c.publisher) publishers.push(c.company.name); + }); + const dateStr = result.first_release_date ? new Date(result.first_release_date * 1000).toISOString().split('T')[0] : ''; + const image = result.cover?.url ? 'https:' + result.cover.url.replace('t_thumb', 't_cover_big') : ''; + + return new GameModel({ + type: MediaType.Game, + title: result.name, + englishTitle: result.name, + year: result.first_release_date ? new Date(result.first_release_date * 1000).getFullYear().toString() : '', + dataSource: this.apiName, + url: result.url, + id: result.id.toString(), + developers: developers, + publishers: publishers, + genres: result.genres?.map(g => g.name) ?? [], + onlineRating: result.total_rating, + image: image, + released: true, + releaseDate: dateStr ? this.plugin.dateFormatter.format(dateStr, this.apiDateFormat) : '', + userData: { played: false, personalRating: 0 }, + }); + } + + getDisabledMediaTypes(): MediaType[] { + return this.plugin.settings.IGDBAPI_disabledMediaTypes ?? []; + } +} diff --git a/src/api/apis/MALAPI.ts b/src/api/apis/MALAPI.ts index 5d27a61..3083e23 100644 --- a/src/api/apis/MALAPI.ts +++ b/src/api/apis/MALAPI.ts @@ -132,6 +132,7 @@ export class MALAPI extends APIModel { subType: undefined, title: result.title, englishTitle: result.title_english ?? result.title, + japaneseTitle: result.title_japanese, year: year, dataSource: this.apiName, url: result.url, @@ -162,6 +163,7 @@ export class MALAPI extends APIModel { subType: type, title: result.title, englishTitle: result.title_english ?? result.title, + japaneseTitle: result.title_japanese, year: year, dataSource: this.apiName, url: result.url, @@ -190,6 +192,7 @@ export class MALAPI extends APIModel { subType: type, title: result.title, englishTitle: result.title_english ?? result.title, + japaneseTitle: result.title_japanese, year: year, dataSource: this.apiName, url: result.url, diff --git a/src/api/apis/RAWGAPI.ts b/src/api/apis/RAWGAPI.ts new file mode 100644 index 0000000..ec0a53e --- /dev/null +++ b/src/api/apis/RAWGAPI.ts @@ -0,0 +1,105 @@ +import { requestUrl } from 'obsidian'; +import type MediaDbPlugin from '../../main'; +import { GameModel } from '../../models/GameModel'; +import type { MediaTypeModel } from '../../models/MediaTypeModel'; +import { MediaType } from '../../utils/MediaType'; +import { APIModel } from '../APIModel'; + +interface RAWGGame { + id: number; + name: string; + released?: string; + background_image?: string; + name_original?: string; + website?: string; + slug?: string; + metacritic?: number; + developers?: { name: string }[]; + publishers?: { name: string }[]; + genres?: { name: string }[]; +} + +interface RAWGSearchResponse { + results: RAWGGame[]; +} + +export class RAWGAPI extends APIModel { + plugin: MediaDbPlugin; + apiDateFormat: string = 'YYYY-MM-DD'; + + constructor(plugin: MediaDbPlugin) { + super(); + this.plugin = plugin; + this.apiName = 'RAWGAPI'; + this.apiDescription = 'A large open video game database.'; + this.apiUrl = 'https://api.rawg.io/api'; + this.types = [MediaType.Game]; + } + + async searchByTitle(title: string): Promise { + const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.RAWGAPIKeyId); + if (!key) { + throw Error(`MDB | API key for ${this.apiName} missing.`); + } + + const response = await requestUrl({ + url: `${this.apiUrl}/games?key=${key}&search=${encodeURIComponent(title)}&page_size=20`, + method: 'GET', + }); + if (response.status !== 200) { + throw Error(`MDB | Error ${response.status} from ${this.apiName}.`); + } + + const data = response.json as RAWGSearchResponse; + return data.results.map( + result => + new GameModel({ + type: MediaType.Game, + title: result.name, + englishTitle: result.name, + year: result.released ? new Date(result.released).getFullYear().toString() : '', + dataSource: this.apiName, + id: result.id.toString(), + image: result.background_image, + }), + ); + } + + async getById(id: string): Promise { + const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.RAWGAPIKeyId); + if (!key) { + throw Error(`MDB | API key for ${this.apiName} missing.`); + } + + const response = await requestUrl({ + url: `${this.apiUrl}/games/${id}?key=${key}`, + method: 'GET', + }); + if (response.status !== 200) { + throw Error(`MDB | Error ${response.status} from ${this.apiName}.`); + } + + const result = response.json as RAWGGame; + return new GameModel({ + type: MediaType.Game, + title: result.name, + englishTitle: result.name_original ?? result.name, + year: result.released ? new Date(result.released).getFullYear().toString() : '', + dataSource: this.apiName, + url: result.website ?? `https://rawg.io/games/${result.slug}`, + id: result.id.toString(), + developers: result.developers?.map(d => d.name) ?? [], + publishers: result.publishers?.map(p => p.name) ?? [], + genres: result.genres?.map(g => g.name) ?? [], + onlineRating: result.metacritic, + image: result.background_image, + released: result.released != null, + releaseDate: result.released, + userData: { played: false, personalRating: 0 }, + }); + } + + getDisabledMediaTypes(): MediaType[] { + return this.plugin.settings.RAWGAPI_disabledMediaTypes ?? []; + } +} diff --git a/src/main.ts b/src/main.ts index 56d415b..8a83e92 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,12 +5,14 @@ import { APIManager } from './api/APIManager'; import { BoardGameGeekAPI } from './api/apis/BoardGameGeekAPI'; import { ComicVineAPI } from './api/apis/ComicVineAPI'; import { GiantBombAPI } from './api/apis/GiantBombAPI'; +import { IGDBAPI } from './api/apis/IGDBAPI'; import { MALAPI } from './api/apis/MALAPI'; import { MALAPIManga } from './api/apis/MALAPIManga'; import { MobyGamesAPI } from './api/apis/MobyGamesAPI'; import { MusicBrainzAPI } from './api/apis/MusicBrainzAPI'; import { OMDbAPI } from './api/apis/OMDbAPI'; import { OpenLibraryAPI } from './api/apis/OpenLibraryAPI'; +import { RAWGAPI } from './api/apis/RAWGAPI'; import { SteamAPI } from './api/apis/SteamAPI'; import { TMDBMovieAPI } from './api/apis/TMDBMovieAPI'; import { TMDBSeasonAPI } from './api/apis/TMDBSeasonAPI'; @@ -72,6 +74,8 @@ export default class MediaDbPlugin extends Plugin { this.apiManager.registerAPI(new ComicVineAPI(this)); this.apiManager.registerAPI(new MobyGamesAPI(this)); this.apiManager.registerAPI(new GiantBombAPI(this)); + this.apiManager.registerAPI(new IGDBAPI(this)); + this.apiManager.registerAPI(new RAWGAPI(this)); this.apiManager.registerAPI(new VNDBAPI(this)); this.mediaTypeManager = new MediaTypeManager(); diff --git a/src/models/ComicMangaModel.ts b/src/models/ComicMangaModel.ts index 46f1deb..ec47a95 100644 --- a/src/models/ComicMangaModel.ts +++ b/src/models/ComicMangaModel.ts @@ -61,7 +61,13 @@ export class ComicMangaModel extends MediaTypeModel { } getTags(): string[] { - return [mediaDbTag, 'manga', 'light-novel', 'comicbook']; + const tags = [mediaDbTag]; + if (this.subType) { + tags.push(this.subType); + } else { + tags.push('comicManga'); + } + return tags; } getMediaType(): MediaType { diff --git a/src/models/MovieModel.ts b/src/models/MovieModel.ts index c91c2d4..563a0f9 100644 --- a/src/models/MovieModel.ts +++ b/src/models/MovieModel.ts @@ -6,6 +6,7 @@ import { MediaTypeModel } from './MediaTypeModel'; export type MovieData = ModelToData; export class MovieModel extends MediaTypeModel { + japaneseTitle: string; plot: string; genres: string[]; director: string[]; @@ -32,6 +33,7 @@ export class MovieModel extends MediaTypeModel { constructor(obj: MovieData) { super(); + this.japaneseTitle = ''; this.plot = ''; this.genres = []; this.director = []; diff --git a/src/models/SeriesModel.ts b/src/models/SeriesModel.ts index 4489629..b87a32c 100644 --- a/src/models/SeriesModel.ts +++ b/src/models/SeriesModel.ts @@ -6,6 +6,7 @@ import { MediaTypeModel } from './MediaTypeModel'; export type SeriesData = ModelToData; export class SeriesModel extends MediaTypeModel { + japaneseTitle: string; plot: string; genres: string[]; writer: string[]; @@ -33,6 +34,7 @@ export class SeriesModel extends MediaTypeModel { constructor(obj: SeriesData) { super(); + this.japaneseTitle = ''; this.plot = ''; this.genres = []; this.writer = []; diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 1b29fb3..c7e694a 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -18,6 +18,9 @@ export interface MediaDbPluginSettings { TMDBKeyId: string; MobyGamesKeyId: string; GiantBombKeyId: string; + IGDBClientId: string; + IGDBClientSecret: string; + RAWGAPIKeyId: string; ComicVineKeyId: string; BoardgameGeekKeyId: string; @@ -33,6 +36,8 @@ export interface MediaDbPluginSettings { BoardgameGeekAPI_disabledMediaTypes: MediaType[]; ComicVineAPI_disabledMediaTypes: MediaType[]; GiantBombAPI_disabledMediaTypes: MediaType[]; + IGDBAPI_disabledMediaTypes: MediaType[]; + RAWGAPI_disabledMediaTypes: MediaType[]; MALAPI_disabledMediaTypes: MediaType[]; MALAPIManga_disabledMediaTypes: MediaType[]; MobyGamesAPI_disabledMediaTypes: MediaType[]; @@ -272,6 +277,9 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { TMDBKeyId: '', MobyGamesKeyId: '', GiantBombKeyId: '', + IGDBClientId: '', + IGDBClientSecret: '', + RAWGAPIKeyId: '', ComicVineKeyId: '', BoardgameGeekKeyId: '', @@ -287,6 +295,8 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { BoardgameGeekAPI_disabledMediaTypes: [], ComicVineAPI_disabledMediaTypes: [], GiantBombAPI_disabledMediaTypes: [], + IGDBAPI_disabledMediaTypes: [], + RAWGAPI_disabledMediaTypes: [], MALAPI_disabledMediaTypes: [], MALAPIManga_disabledMediaTypes: [], MobyGamesAPI_disabledMediaTypes: [], @@ -551,7 +561,7 @@ export class MediaDbSettingTab extends PluginSettingTab { setting => void setting .setName('TMDB API key') - .setDesc('API key for "https://www.themoviedb.org".') + .setDesc('API Read Access Token for "https://www.themoviedb.org".') .addComponent(el => { const component = new SecretComponent(this.app, el); @@ -595,6 +605,54 @@ export class MediaDbSettingTab extends PluginSettingTab { return component; }), ); + apiKeyGroup.addSetting( + setting => + void setting + .setName('IGDB Client ID') + .setDesc('Client ID for IGDB API (Required for Twitch OAuth).') + .addComponent(el => { + const component = new SecretComponent(this.app, el); + + component.setValue(this.plugin.settings.IGDBClientId).onChange(data => { + this.plugin.settings.IGDBClientId = data; + void this.plugin.saveSettings(); + }); + + return component; + }), + ); + apiKeyGroup.addSetting( + setting => + void setting + .setName('IGDB Client Secret') + .setDesc('Client Secret for IGDB API.') + .addComponent(el => { + const component = new SecretComponent(this.app, el); + + component.setValue(this.plugin.settings.IGDBClientSecret).onChange(data => { + this.plugin.settings.IGDBClientSecret = data; + void this.plugin.saveSettings(); + }); + + return component; + }), + ); + apiKeyGroup.addSetting( + setting => + void setting + .setName('RAWG API Key') + .setDesc('API key for "rawg.io".') + .addComponent(el => { + const component = new SecretComponent(this.app, el); + + component.setValue(this.plugin.settings.RAWGAPIKeyId).onChange(data => { + this.plugin.settings.RAWGAPIKeyId = data; + void this.plugin.saveSettings(); + }); + + return component; + }), + ); apiKeyGroup.addSetting( setting => void setting