add obsidian eslint plugin, solve major issues

This commit is contained in:
Moritz Jung 2026-05-06 16:26:59 +02:00
parent 84214a1c3c
commit 498e2611ae
105 changed files with 5702 additions and 5308 deletions

View file

@ -0,0 +1,113 @@
import type { APIModel } from 'packages/obsidian/src/api/APIModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, ok } from 'packages/obsidian/src/utils/result';
export interface ApiQueryOk {
items: MediaTypeModel[];
warnings: AppError[];
}
export class APIManager {
apis: APIModel[];
constructor() {
this.apis = [];
}
/**
* Queries the basic info for one query string and multiple APIs.
*
* @param query
* @param apisToQuery
*/
async query(query: string, apisToQuery: string[]): Promise<Result<ApiQueryOk, AppError>> {
Logger.debug(`MDB | api manager queried with "${query}"`);
const apis = this.apis.filter(api => apisToQuery.includes(api.apiName));
const results = await Promise.all(apis.map(api => api.searchByTitle(query)));
const items: MediaTypeModel[] = [];
const warnings: AppError[] = [];
for (const result of results) {
if (result.ok) {
items.push(...result.value);
} else {
warnings.push(result.error);
}
}
if (items.length === 0 && warnings.length > 0) {
// If all APIs failed, surface an error (using the first as representative)
return err(
toAppError(warnings[0], {
kind: AppErrorKind.Api,
message: 'Failed to query APIs',
userMessage: 'Failed to query APIs',
context: { query, apisToQuery },
}),
);
}
for (const warning of warnings) {
Logger.warn(warning);
}
return ok({ items, warnings });
}
/**
* Queries detailed information for a MediaTypeModel.
*
* @param item
*/
async queryDetailedInfo(item: MediaTypeModel): Promise<Result<MediaTypeModel | undefined, AppError>> {
return await this.queryDetailedInfoById(item.id, item.dataSource);
}
/**
* Queries detailed info for an id from an API.
*
* @param id
* @param apiName
*/
async queryDetailedInfoById(id: string, apiName: string): Promise<Result<MediaTypeModel | undefined, AppError>> {
for (const api of this.apis) {
if (api.apiName === apiName) {
const result = await api.getById(id);
if (!result.ok) {
Logger.warn(result.error);
}
return result.ok ? ok(result.value) : err(result.error);
}
}
return err(
toAppError(new Error(`API not found: ${apiName}`), {
kind: AppErrorKind.Validation,
message: `API not found: ${apiName}`,
userMessage: `API not found: ${apiName}`,
context: { apiName, id },
}),
);
}
getApiByName(name: string): APIModel | undefined {
for (const api of this.apis) {
if (api.apiName === name) {
return api;
}
}
return undefined;
}
registerAPI(api: APIModel): void {
this.apis.push(api);
}
}

View file

@ -0,0 +1,33 @@
import type MediaDbPlugin from 'packages/obsidian/src/main';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
export abstract class APIModel {
apiName!: string;
apiUrl!: string;
apiDescription!: string;
types!: MediaType[];
plugin!: MediaDbPlugin;
/**
* This function should query the api and return a list of matches. The matches should be capped at 20.
*
* @param title the title to query for
*/
abstract searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>>;
abstract getById(id: string): Promise<Result<MediaTypeModel, AppError>>;
abstract getDisabledMediaTypes(): MediaType[];
hasType(type: MediaType): boolean {
const disabledMediaTypes = this.getDisabledMediaTypes();
return this.types.includes(type) && !disabledMediaTypes.includes(type);
}
hasTypeOverlap(types: MediaType[]): boolean {
return types.some(type => this.hasType(type));
}
}

View file

@ -0,0 +1,217 @@
import { requestUrl } from 'obsidian';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { BoardGameModel } from 'packages/obsidian/src/models/BoardGameModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
// sadly no open api schema available
export class BoardGameGeekAPI extends APIModel {
plugin: MediaDbPlugin;
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'BoardGameGeekAPI';
this.apiDescription = 'A free API for BoardGameGeek things.';
this.apiUrl = 'https://boardgamegeek.com/xmlapi/';
this.types = [MediaType.BoardGame];
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.BoardgameGeekKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const searchUrl = `${this.apiUrl}/search?search=${encodeURIComponent(title)}`;
const fetchDataResult = await fromPromise(
requestUrl({
url: searchUrl,
headers: {
Authorization: `Bearer ${key}`,
},
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, title },
}),
);
if (!fetchDataResult.ok) {
return err(fetchDataResult.error);
}
const fetchData = fetchDataResult.value;
if (fetchData.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName },
});
}
if (fetchData.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: fetchData.status },
});
}
const data = fetchData.text;
const response = new window.DOMParser().parseFromString(data, 'text/xml');
// console.debug(response);
const ret: MediaTypeModel[] = [];
for (const boardgame of Array.from(response.querySelectorAll('boardgame'))) {
const id = boardgame.attributes.getNamedItem('objectid')?.value;
const title = boardgame.querySelector('name[primary=true]')?.textContent ?? boardgame.querySelector('name')?.textContent ?? undefined;
const year = boardgame.querySelector('yearpublished')?.textContent ?? '';
ret.push(
new BoardGameModel({
dataSource: this.apiName,
id,
title,
englishTitle: title,
year,
}),
);
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.BoardgameGeekKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const searchUrl = `${this.apiUrl}/boardgame/${encodeURIComponent(id)}?stats=1`;
const fetchDataResult = await fromPromise(
requestUrl({
url: searchUrl,
headers: {
Authorization: `Bearer ${key}`,
},
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!fetchDataResult.ok) {
return err(fetchDataResult.error);
}
const fetchData = fetchDataResult.value;
if (fetchData.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName },
});
}
if (fetchData.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: fetchData.status, id },
});
}
const data = fetchData.text;
const response = new window.DOMParser().parseFromString(data, 'text/xml');
// console.debug(response);
const boardgame = response.querySelector('boardgame');
if (!boardgame) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received invalid data from ${this.apiName}.`,
userMessage: `Received invalid data from ${this.apiName}.`,
context: { apiName: this.apiName, id },
});
}
const title = boardgame.querySelector('name[primary=true]')?.textContent;
const year = boardgame.querySelector('yearpublished')?.textContent ?? '';
const image = boardgame.querySelector('image')?.textContent ?? undefined;
const onlineRating = Number.parseFloat(boardgame.querySelector('statistics ratings average')?.textContent ?? '0');
const genres = Array.from(boardgame.querySelectorAll('boardgamecategory'))
.map(n => n.textContent)
.filter(n => n !== null);
const complexityRating = Number.parseFloat(boardgame.querySelector('averageweight')?.textContent ?? '0');
const minPlayers = Number.parseFloat(boardgame.querySelector('minplayers')?.textContent ?? '0');
const maxPlayers = Number.parseFloat(boardgame.querySelector('maxplayers')?.textContent ?? '0');
const playtime = (boardgame.querySelector('playingtime')?.textContent ?? 'unknown') + ' minutes';
const publishers = Array.from(boardgame.querySelectorAll('boardgamepublisher'))
.map(n => n.textContent)
.filter(n => n !== null);
return ok(
new BoardGameModel({
title: title ?? undefined,
englishTitle: title ?? undefined,
year: year === '0' ? '' : year,
dataSource: this.apiName,
url: `https://boardgamegeek.com/boardgame/${id}`,
id: id,
genres: genres,
onlineRating: onlineRating,
complexityRating: complexityRating,
minPlayers: minPlayers,
maxPlayers: maxPlayers,
playtime: playtime,
publishers: publishers,
image: image,
released: true,
userData: {
played: false,
personalRating: 0,
},
}),
);
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.BoardgameGeekAPI_disabledMediaTypes;
}
}

View file

@ -0,0 +1,170 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
import { requestUrl } from 'obsidian';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { ComicMangaModel } from 'packages/obsidian/src/models/ComicMangaModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
// sadly no open api schema available
export class ComicVineAPI extends APIModel {
plugin: MediaDbPlugin;
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'ComicVineAPI';
this.apiDescription = 'A free API for comic books.';
this.apiUrl = 'https://comicvine.gamespot.com/api';
this.types = [MediaType.ComicManga];
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.ComicVineKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const searchUrl = `${this.apiUrl}/search/?api_key=${key}&format=json&resources=volume&query=${encodeURIComponent(title)}`;
const fetchDataResult = await fromPromise(
requestUrl({
url: searchUrl,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, title },
}),
);
if (!fetchDataResult.ok) {
return err(fetchDataResult.error);
}
const fetchData = fetchDataResult.value;
// console.debug(fetchData);
if (fetchData.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: fetchData.status },
});
}
const data = await fetchData.json;
// console.debug(data);
const ret: MediaTypeModel[] = [];
for (const result of data.results) {
ret.push(
new ComicMangaModel({
title: result.name,
englishTitle: result.name,
year: result.start_year,
dataSource: this.apiName,
id: `4050-${result.id}`,
publishers: result.publisher?.name,
}),
);
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.ComicVineKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName, id },
});
}
const searchUrl = `${this.apiUrl}/volume/${encodeURIComponent(id)}/?api_key=${key}&format=json`;
const fetchDataResult = await fromPromise(
requestUrl({
url: searchUrl,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!fetchDataResult.ok) {
return err(fetchDataResult.error);
}
const fetchData = fetchDataResult.value;
Logger.debug(fetchData);
if (fetchData.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: fetchData.status, id },
});
}
const data = await fetchData.json;
const result = data.results;
const authors = result.people as
| {
name: string;
}[]
| undefined;
return ok(
new ComicMangaModel({
type: MediaType.ComicManga,
title: result.name,
englishTitle: result.name,
alternateTitles: result.aliases,
plot: result.deck,
year: result.start_year,
dataSource: this.apiName,
url: result.site_detail_url,
id: `4050-${result.id}`,
authors: authors?.map(x => x.name),
chapters: result.count_of_issues,
image: result.image?.original_url,
released: true,
publishers: result.publisher?.name,
publishedFrom: result.start_year,
status: result.status,
userData: {
read: false,
lastRead: '',
personalRating: 0,
},
}),
);
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.ComicVineAPI_disabledMediaTypes;
}
}

View file

@ -0,0 +1,169 @@
// Temporarily removed until they get their API working again.
// import createClient from 'openapi-fetch';
// import type MediaDbPlugin from '../../main';
// import { GameModel } from '../../models/GameModel';
// import type { MediaTypeModel } from '../../models/MediaTypeModel';
// import { MediaType } from '../../utils/MediaType';
// import { obsidianFetch } from '../../utils/Utils';
// import { APIModel } from '../APIModel';
// import type { paths } from '../schemas/GiantBomb';
// export class GiantBombAPI extends APIModel {
// plugin: MediaDbPlugin;
// apiDateFormat: string = 'YYYY-MM-DD';
// constructor(plugin: MediaDbPlugin) {
// super();
// this.plugin = plugin;
// this.apiName = 'GiantBombAPI';
// this.apiDescription = 'A free API for games.';
// this.apiUrl = 'https://www.giantbomb.com/api';
// this.types = [MediaType.Game];
// }
// async searchByTitle(title: string): Promise<MediaTypeModel[]> {
// console.log(`MDB | api "${this.apiName}" queried by Title`);
// const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.GiantBombKeyId);
// if (!key) {
// throw Error(`MDB | API key for ${this.apiName} missing.`);
// }
// const client = createClient<paths>({ baseUrl: 'https://www.giantbomb.com/api/' });
// const response = await client.GET('/games', {
// params: {
// query: {
// api_key: key,
// filter: `name:${title}`,
// format: 'json',
// limit: 20,
// },
// },
// fetch: obsidianFetch,
// });
// if (response.response.status === 401) {
// throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
// }
// if (response.response.status === 429) {
// throw Error(`MDB | Too many requests for ${this.apiName}, you've exceeded your API quota.`);
// }
// if (response.response.status !== 200) {
// throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
// }
// const data = response.data?.results;
// const ret: MediaTypeModel[] = [];
// for (const result of data ?? []) {
// const year = result.original_release_date ? new Date(result.original_release_date).getFullYear().toString() : undefined;
// ret.push(
// new GameModel({
// title: result.name,
// englishTitle: result.name,
// year: year,
// dataSource: this.apiName,
// id: result.guid?.toString(),
// }),
// );
// }
// return ret;
// }
// async getById(id: string): Promise<MediaTypeModel> {
// console.log(`MDB | api "${this.apiName}" queried by ID`);
// const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.GiantBombKeyId);
// if (!key) {
// throw Error(`MDB | API key for ${this.apiName} missing.`);
// }
// const client = createClient<paths>({ baseUrl: 'https://www.giantbomb.com/api/' });
// const response = await client.GET('/game/{guid}', {
// params: {
// path: {
// guid: id,
// },
// query: {
// api_key: key,
// format: 'json',
// },
// },
// fetch: obsidianFetch,
// });
// if (response.response.status === 401) {
// throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
// }
// if (response.response.status === 429) {
// throw Error(`MDB | Too many requests for ${this.apiName}, you've exceeded your API quota.`);
// }
// if (response.response.status !== 200) {
// throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
// }
// const result = response.data?.results;
// if (!result) {
// throw Error(`MDB | No results found for ID ${id} in ${this.apiName}.`);
// }
// console.log(result);
// // sadly the only OpenAPI definition I could find doesn't have the right types
// const year = result.original_release_date ? new Date(result.original_release_date).getFullYear().toString() : undefined;
// const developers = result.developers as
// | {
// name: string;
// }[]
// | undefined;
// const publishers = result.publishers as
// | {
// name: string;
// }[]
// | undefined;
// const genres = result.genres as
// | {
// name: string;
// }[]
// | undefined;
// const image = result.image as
// | {
// small_url: string;
// medium_url: string;
// super_url: string;
// }
// | undefined;
// return new GameModel({
// type: MediaType.Game,
// title: result.name,
// englishTitle: result.name,
// year: year,
// dataSource: this.apiName,
// url: result.site_detail_url,
// id: result.guid?.toString(),
// developers: developers?.map(x => x.name),
// publishers: publishers?.map(x => x.name),
// genres: genres?.map(x => x.name),
// onlineRating: 0,
// image: image?.super_url,
// released: true,
// releaseDate: result.original_release_date,
// userData: {
// played: false,
// personalRating: 0,
// },
// });
// }
// getDisabledMediaTypes(): MediaType[] {
// return this.plugin.settings.GiantBombAPI_disabledMediaTypes;
// }
// }

View file

@ -0,0 +1,264 @@
import { requestUrl } from 'obsidian';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { GameModel } from 'packages/obsidian/src/models/GameModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
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<Result<string, AppError>> {
const currentTime = Date.now();
if (this.accessToken && currentTime < this.tokenExpiry) return ok(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) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | Client ID or Client Secret for ${this.apiName} missing.`,
userMessage: `Client ID or Client Secret for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
Logger.log(`MDB | Refreshing Twitch Auth Token for ${this.apiName}`);
const responseResult = await fromPromise(
requestUrl({
url: `https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`,
method: 'POST',
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying Twitch auth for ${this.apiName}`,
userMessage: `Network error querying Twitch auth for ${this.apiName}`,
context: { apiName: this.apiName },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Auth failed for ${this.apiName}. Check Credentials.`,
userMessage: `Auth failed for ${this.apiName}. Check Credentials.`,
context: { apiName: this.apiName, status: response.status },
});
}
const data = response.json as TwitchAuthResponse;
this.accessToken = data.access_token;
this.tokenExpiry = currentTime + data.expires_in * 1000 - 60000;
return ok(this.accessToken);
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const clientId = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientId);
if (!clientId) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | Client ID for ${this.apiName} missing.`,
userMessage: `Client ID for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const tokenResult = await this.getAuthToken();
if (!tokenResult.ok) {
return err(tokenResult.error);
}
const token = tokenResult.value;
const queryBody = `search "${title}"; fields name, cover.url, first_release_date, summary, total_rating; limit 20;`;
const responseResult = await fromPromise(
requestUrl({
url: `${this.apiUrl}/games`,
method: 'POST',
headers: { 'Client-ID': clientId, Authorization: `Bearer ${token}`, Accept: 'application/json' },
body: queryBody,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, title },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.status },
});
}
const data = response.json as IGDBGame[];
return ok(
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<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const clientId = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientId);
if (!clientId) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | Client ID for ${this.apiName} missing.`,
userMessage: `Client ID for ${this.apiName} missing.`,
context: { apiName: this.apiName, id },
});
}
const tokenResult = await this.getAuthToken();
if (!tokenResult.ok) {
return err(tokenResult.error);
}
const token = tokenResult.value;
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 responseResult = await fromPromise(
requestUrl({
url: `${this.apiUrl}/games`,
method: 'POST',
headers: { 'Client-ID': clientId, Authorization: `Bearer ${token}`, Accept: 'application/json' },
body: queryBody,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.status, id },
});
}
const data = response.json as IGDBGame[];
if (!data || data.length === 0) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No result found for ID ${id}`,
userMessage: `No result found for ID ${id}`,
context: { apiName: this.apiName, 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 ok(
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 ?? [];
}
}

View file

@ -0,0 +1,260 @@
import createClient from 'openapi-fetch';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import { MovieModel } from 'packages/obsidian/src/models/MovieModel';
import { SeriesModel } from 'packages/obsidian/src/models/SeriesModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, ok } from 'packages/obsidian/src/utils/result';
import { isTruthy, obsidianFetch } from 'packages/obsidian/src/utils/Utils';
import type { paths } from 'packages/schemas/src/MALAPI';
export class MALAPI extends APIModel {
plugin: MediaDbPlugin;
typeMappings: Map<string, string>;
apiDateFormat: string = 'YYYY-MM-DDTHH:mm:ssZ'; // ISO
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'MALAPI';
this.apiDescription = 'A free API for Anime. Some results may take a long time to load.';
this.apiUrl = 'https://jikan.moe/';
this.types = [MediaType.Movie, MediaType.Series];
this.typeMappings = new Map<string, string>();
this.typeMappings.set('movie', 'movie');
this.typeMappings.set('special', 'special');
this.typeMappings.set('tv', 'series');
this.typeMappings.set('ova', 'ova');
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
const response = await client.GET('/anime', {
params: {
query: {
q: title,
limit: 20,
sfw: this.plugin.settings.sfwFilter ? true : false,
},
},
fetch: obsidianFetch,
});
if (response.error !== undefined) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.response.status },
});
}
const data = response.data?.data;
const ret: MediaTypeModel[] = [];
for (const result of data ?? []) {
const resType = result.type?.toLowerCase();
const type = resType ? this.typeMappings.get(resType) : undefined;
const year = result.year?.toString() ?? result.aired?.prop?.from?.year?.toString() ?? '';
const id = result.mal_id?.toString();
if (type === undefined) {
ret.push(
new MovieModel({
subType: '',
title: result.title,
englishTitle: result.title_english ?? result.title,
year,
dataSource: this.apiName,
id,
}),
);
}
if (type === 'movie' || type === 'special') {
ret.push(
new MovieModel({
subType: type,
title: result.title,
englishTitle: result.title_english ?? result.title,
year,
dataSource: this.apiName,
id,
}),
);
} else if (type === 'series' || type === 'ova') {
ret.push(
new SeriesModel({
subType: type,
title: result.title,
englishTitle: result.title_english ?? result.title,
year,
dataSource: this.apiName,
id,
}),
);
}
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
const response = await client.GET('/anime/{id}/full', {
params: {
path: {
id: id as unknown as number, // This is fine
},
},
fetch: obsidianFetch,
});
if (response.error !== undefined) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.response.status, id },
});
}
const result = response.data?.data;
if (result === undefined) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data found for ID ${id} in ${this.apiName}.`,
userMessage: `No data found for ID ${id} in ${this.apiName}.`,
context: { apiName: this.apiName, id },
});
}
const resType = result.type?.toLowerCase();
const type = resType ? this.typeMappings.get(resType) : undefined;
const year = result.year?.toString() ?? result.aired?.prop?.from?.year?.toString();
const new_id = result.mal_id?.toString();
if (type === undefined) {
return ok(
new MovieModel({
subType: undefined,
title: result.title,
englishTitle: result.title_english ?? result.title,
japaneseTitle: result.title_japanese,
year: year,
dataSource: this.apiName,
url: result.url,
id: new_id,
plot: result.synopsis,
genres: result.genres?.map(x => x.name).filter(isTruthy),
studio: result.studios?.map(x => x.name).filter(isTruthy),
duration: result.duration,
onlineRating: result.score,
image: result.images?.jpg?.image_url,
released: true,
ageRating: result.rating,
premiere: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat),
streamingServices: result.streaming?.map(x => x.name).filter(isTruthy),
userData: {
watched: false,
lastWatched: '',
personalRating: 0,
},
}),
);
}
if (type === 'movie' || type === 'special') {
return ok(
new MovieModel({
subType: type,
title: result.title,
englishTitle: result.title_english ?? result.title,
japaneseTitle: result.title_japanese,
year: year,
dataSource: this.apiName,
url: result.url,
id: new_id,
plot: result.synopsis,
genres: result.genres?.map(x => x.name).filter(isTruthy),
studio: result.studios?.map(x => x.name).filter(isTruthy),
duration: result.duration,
onlineRating: result.score,
image: result.images?.jpg?.image_url,
released: true,
ageRating: result.rating,
premiere: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat),
streamingServices: result.streaming?.map(x => x.name).filter(isTruthy),
userData: {
watched: false,
lastWatched: '',
personalRating: 0,
},
}),
);
} else if (type === 'series' || type === 'ova') {
return ok(
new SeriesModel({
subType: type,
title: result.title,
englishTitle: result.title_english ?? result.title,
japaneseTitle: result.title_japanese,
year: year,
dataSource: this.apiName,
url: result.url,
id: new_id,
plot: result.synopsis,
genres: result.genres?.map(x => x.name).filter(isTruthy),
studio: result.studios?.map(x => x.name).filter(isTruthy),
episodes: result.episodes,
duration: result.duration,
onlineRating: result.score,
streamingServices: result.streaming?.map(x => x.name).filter(isTruthy),
image: result.images?.jpg?.image_url,
released: true,
ageRating: result.rating,
airedFrom: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat),
airedTo: this.plugin.dateFormatter.format(result.aired?.to, this.apiDateFormat),
airing: result.airing,
userData: {
watched: false,
lastWatched: '',
personalRating: 0,
},
}),
);
}
return err({
kind: AppErrorKind.Unexpected,
message: `MDB | Unknown media type for id ${id}`,
userMessage: `Unknown media type for id ${id}`,
context: { apiName: this.apiName, id },
});
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.MALAPI_disabledMediaTypes;
}
}

View file

@ -0,0 +1,183 @@
import createClient from 'openapi-fetch';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { ComicMangaModel } from 'packages/obsidian/src/models/ComicMangaModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, ok } from 'packages/obsidian/src/utils/result';
import { isTruthy, obsidianFetch } from 'packages/obsidian/src/utils/Utils';
import type { paths } from 'packages/schemas/src/MALAPI';
export class MALAPIManga extends APIModel {
plugin: MediaDbPlugin;
typeMappings: Map<string, string>;
apiDateFormat: string = 'YYYY-MM-DDTHH:mm:ssZ'; // ISO
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'MALAPI Manga';
this.apiDescription = 'A free API for Manga. Some results may take a long time to load.';
this.apiUrl = 'https://jikan.moe/';
this.types = [MediaType.ComicManga];
this.typeMappings = new Map<string, string>();
this.typeMappings.set('manga', 'manga');
this.typeMappings.set('manhwa', 'manhwa');
this.typeMappings.set('doujinshi', 'doujin');
this.typeMappings.set('one-shot', 'oneshot');
this.typeMappings.set('manhua', 'manhua');
this.typeMappings.set('light novel', 'light-novel');
this.typeMappings.set('novel', 'novel');
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
const response = await client.GET('/manga', {
params: {
query: {
q: title,
limit: 20,
sfw: this.plugin.settings.sfwFilter ? true : false,
},
},
fetch: obsidianFetch,
});
if (response.error !== undefined) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.response.status },
});
}
const data = response.data?.data;
const ret: MediaTypeModel[] = [];
for (const result of data ?? []) {
const resType = result.type?.toLowerCase();
const type = resType ? this.typeMappings.get(resType) : undefined;
const year = result.published?.prop?.from?.year?.toString() ?? '';
const id = result.mal_id?.toString();
ret.push(
new ComicMangaModel({
subType: type,
title: result.title,
plot: result.synopsis ?? undefined,
englishTitle: result.title_english ?? result.title,
alternateTitles: result.titles?.map(x => x.title).filter(isTruthy),
year: year,
dataSource: this.apiName,
url: result.url,
id: id,
genres: result.genres?.map(x => x.name).filter(isTruthy),
authors: result.authors?.map(x => x.name).filter(isTruthy),
chapters: result.chapters,
volumes: result.volumes,
onlineRating: result.score,
image: result.images?.jpg?.image_url,
released: true,
publishedFrom: this.plugin.dateFormatter.format(result.published?.from, this.apiDateFormat),
publishedTo: this.plugin.dateFormatter.format(result.published?.to, this.apiDateFormat),
status: result.status,
userData: {
read: false,
lastRead: '',
personalRating: 0,
},
}),
);
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
const response = await client.GET('/manga/{id}/full', {
params: {
path: {
id: id as unknown as number, // This is fine
},
},
fetch: obsidianFetch,
});
if (response.error !== undefined) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.response.status, id },
});
}
const result = response.data?.data;
if (!result) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data found for ID ${id} in ${this.apiName}.`,
userMessage: `No data found for ID ${id} in ${this.apiName}.`,
context: { apiName: this.apiName, id },
});
}
const resType = result.type?.toLowerCase();
const type = resType ? this.typeMappings.get(resType) : undefined;
const year = result.published?.prop?.from?.year?.toString() ?? '';
const new_id = result.mal_id?.toString();
return ok(
new ComicMangaModel({
subType: type,
title: result.title,
plot: result.synopsis ?? undefined,
englishTitle: result.title_english ?? result.title,
alternateTitles: result.titles?.map(x => x.title).filter(isTruthy),
year: year,
dataSource: this.apiName,
url: result.url,
id: new_id,
genres: result.genres?.map(x => x.name).filter(isTruthy),
authors: result.authors?.map(x => x.name).filter(isTruthy),
chapters: result.chapters,
volumes: result.volumes,
onlineRating: result.score,
image: result.images?.jpg?.image_url,
released: true,
publishedFrom: this.plugin.dateFormatter.format(result.published?.from, this.apiDateFormat),
publishedTo: this.plugin.dateFormatter.format(result.published?.to, this.apiDateFormat),
status: result.status,
userData: {
read: false,
lastRead: '',
personalRating: 0,
},
}),
);
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.MALAPIManga_disabledMediaTypes;
}
}

View file

@ -0,0 +1,327 @@
import { requestUrl } from 'obsidian';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import { MusicReleaseModel } from 'packages/obsidian/src/models/MusicReleaseModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
import { contactEmail, getLanguageName, mediaDbVersion, pluginName } from 'packages/obsidian/src/utils/Utils';
// sadly no open api schema available
interface Tag {
name: string;
count: number;
}
interface Genre {
name: string;
count: number;
id: string;
disambiguation: string;
}
interface Release {
id: string;
'status-id': string;
title: string;
status: string;
}
interface ArtistCredit {
name: string;
artist: {
tags: Tag[];
type: string;
id: string;
name: string;
'short-name': string;
country: string;
};
}
interface SearchResponse {
id: string;
'type-id': string;
score: number;
'primary-type-id': string;
'artists-credit-id': string;
count: number;
title: string;
'first-release-date': string;
'primary-type': string;
'artist-credit': ArtistCredit[];
releases: Release[];
tags: Tag[];
}
interface IdResponse {
id: string;
tags: Tag[];
'primary-type-id': string;
'artist-credit': ArtistCredit[];
title: string;
genres: Genre[];
'first-release-date': string;
releases: Release[];
'primary-type': string;
rating: {
value: number;
'votes-count': number;
};
}
interface MediaResponse {
media: {
'track-count': number;
tracks: {
'artist-credit': ArtistCredit[];
length: number | null;
number: string;
position: number;
title: string;
recording: {
length: number;
title: string;
};
}[];
}[];
'text-representation': {
language: string;
script: string;
};
}
export class MusicBrainzAPI extends APIModel {
plugin: MediaDbPlugin;
apiDateFormat: string = 'YYYY-MM-DD';
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'MusicBrainz API';
this.apiDescription = 'Free API for music albums.';
this.apiUrl = 'https://musicbrainz.org/';
this.types = [MediaType.MusicRelease];
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const searchUrl = `https://musicbrainz.org/ws/2/release-group?query=${encodeURIComponent(title)}&limit=20&fmt=json`;
const fetchDataResult = await fromPromise(
requestUrl({
url: searchUrl,
headers: {
'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`,
},
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, title },
}),
);
if (!fetchDataResult.ok) {
return err(fetchDataResult.error);
}
const fetchData = fetchDataResult.value;
// console.debug(fetchData);
if (fetchData.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: fetchData.status },
});
}
const data = (await fetchData.json) as {
'release-groups': SearchResponse[];
};
// console.debug(data);
const ret: MediaTypeModel[] = [];
for (const result of data['release-groups']) {
ret.push(
new MusicReleaseModel({
type: 'musicRelease',
title: result.title,
englishTitle: result.title,
year: new Date(result['first-release-date']).getFullYear().toString(),
releaseDate: this.plugin.dateFormatter.format(result['first-release-date'], this.apiDateFormat) ?? 'unknown',
dataSource: this.apiName,
url: 'https://musicbrainz.org/release-group/' + result.id,
id: result.id,
image: 'https://coverartarchive.org/release-group/' + result.id + '/front-500.jpg',
artists: result['artist-credit'].map(a => a.name),
subType: result['primary-type'],
}),
);
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
// Fetch release group
const groupUrl = `https://musicbrainz.org/ws/2/release-group/${encodeURIComponent(id)}?inc=releases+artists+tags+ratings+genres&fmt=json`;
const groupResponseResult = await fromPromise(
requestUrl({
url: groupUrl,
headers: {
'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`,
},
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!groupResponseResult.ok) {
return err(groupResponseResult.error);
}
const groupResponse = groupResponseResult.value;
if (groupResponse.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${groupResponse.status} from ${this.apiName}.`,
userMessage: `Received status code ${groupResponse.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: groupResponse.status, id },
});
}
const result = (await groupResponse.json) as IdResponse;
// Get ID of the first release
const firstRelease = result.releases?.[0];
if (!firstRelease) {
return err({
kind: AppErrorKind.Api,
message: 'MDB | No releases found in release group.',
userMessage: 'No releases found in release group.',
context: { apiName: this.apiName, id },
});
}
// Fetch recordings for the first release
const releaseUrl = `https://musicbrainz.org/ws/2/release/${firstRelease.id}?inc=recordings+artists&fmt=json`;
Logger.log(`MDB | Fetching release recordings from: ${releaseUrl}`);
const releaseResponseResult = await fromPromise(
requestUrl({
url: releaseUrl,
headers: {
'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`,
},
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id, releaseId: firstRelease.id },
}),
);
if (!releaseResponseResult.ok) {
return err(releaseResponseResult.error);
}
const releaseResponse = releaseResponseResult.value;
if (releaseResponse.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${releaseResponse.status} from ${this.apiName}.`,
userMessage: `Received status code ${releaseResponse.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: releaseResponse.status, id, releaseId: firstRelease.id },
});
}
const releaseData = (await releaseResponse.json) as MediaResponse;
const tracks = extractTracksFromMedia(releaseData.media);
// Calculate total album length for the first release
const totalrawLength =
releaseData.media[0]?.tracks.reduce((sum, track) => {
const len = track.length ?? track.recording?.length;
return typeof len === 'number' && !isNaN(len) ? sum + len : sum;
}, 0) ?? 0;
const albumLengthCalc = millisecondsToMinutes(totalrawLength);
Logger.debug(releaseData);
return ok(
new MusicReleaseModel({
type: 'musicRelease',
title: result.title,
englishTitle: result.title,
year: new Date(result['first-release-date']).getFullYear().toString(),
releaseDate: this.plugin.dateFormatter.format(result['first-release-date'], this.apiDateFormat) ?? 'unknown',
dataSource: this.apiName,
url: 'https://musicbrainz.org/release-group/' + result.id,
id: result.id,
image: 'https://coverartarchive.org/release-group/' + result.id + '/front-500.jpg',
artists: result['artist-credit'].map(a => a.name),
language: releaseData['text-representation'].language ? getLanguageName(releaseData['text-representation'].language) : 'Unknown',
genres: result.genres.map(g => g.name),
subType: result['primary-type'],
albumDuration: albumLengthCalc,
trackCount: releaseData.media[0]?.['track-count'] ?? 0,
tracks: tracks,
rating: result.rating.value * 2,
userData: {
personalRating: 0,
},
}),
);
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.MusicBrainzAPI_disabledMediaTypes;
}
}
function extractTracksFromMedia(media: MediaResponse['media']): {
number: number;
title: string;
duration: string;
featuredArtists: string[];
}[] {
if (!media || media.length === 0 || !media[0].tracks) return [];
return media[0].tracks.map((track, index) => {
const title = track.title ?? track.recording?.title ?? 'Unknown Title';
const rawLength = track.length ?? track.recording?.length;
const duration = rawLength ? millisecondsToMinutes(rawLength) : 'unknown';
const featuredArtists = track['artist-credit']?.map(ac => ac.name) ?? [];
return {
number: index + 1,
title,
duration,
featuredArtists,
};
});
}
function millisecondsToMinutes(milliseconds: number): string {
const minutes = Math.floor(milliseconds / 60000);
const seconds = Math.floor((milliseconds % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}

View file

@ -0,0 +1,388 @@
import { requestUrl } from 'obsidian';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { GameModel } from 'packages/obsidian/src/models/GameModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import { MovieModel } from 'packages/obsidian/src/models/MovieModel';
import { SeriesModel } from 'packages/obsidian/src/models/SeriesModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
interface ErrorResponse {
Response: 'False';
Error: string;
}
type SearchResponse =
| {
Response: 'True';
totalResults: string;
Search: {
Title: string;
Year: string;
Poster: string;
imdbID: string;
Type: string;
}[];
}
| ErrorResponse;
type IdResponse =
| {
Response: 'True';
Title: string;
Year: string;
Rated: string;
Released: string;
Runtime: string;
Genre: string;
Director: string;
Writer: string;
Actors: string;
Plot: string;
Language: string;
Country: string;
Awards: string;
Poster: string;
Metascore: string;
imdbRating: string;
imdbVotes: string;
imdbID: string;
Type: string;
DVD: string;
BoxOffice: string;
Production: string;
Website: string;
}
| ErrorResponse;
export class OMDbAPI extends APIModel {
plugin: MediaDbPlugin;
typeMappings: Map<string, string>;
apiDateFormat: string = 'DD MMM YYYY';
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'OMDbAPI';
this.apiDescription = 'A free API for Movies, Series and Games.';
this.apiUrl = 'https://www.omdbapi.com/';
this.types = [MediaType.Movie, MediaType.Series, MediaType.Game];
this.typeMappings = new Map<string, string>();
this.typeMappings.set('movie', 'movie');
this.typeMappings.set('series', 'series');
this.typeMappings.set('game', 'game');
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.OMDbKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const responseResult = await fromPromise(
requestUrl({
url: `https://www.omdbapi.com/?s=${encodeURIComponent(title)}&apikey=${key}`,
method: 'GET',
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, title },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName },
});
}
if (response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.status },
});
}
const data = response.json as SearchResponse | undefined;
if (!data) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data received from ${this.apiName}.`,
userMessage: `No data received from ${this.apiName}.`,
context: { apiName: this.apiName },
});
}
if (data.Response === 'False') {
if (data.Error === 'Movie not found!') {
return ok([]);
}
return err({
kind: AppErrorKind.Api,
message: `MDB | Received error from ${this.apiName}: ${data.Error}`,
userMessage: `${data.Error}`,
context: { apiName: this.apiName },
});
}
if (!data.Search) {
return ok([]);
}
// console.debug(data.Search);
const ret: MediaTypeModel[] = [];
for (const result of data.Search) {
const type = this.typeMappings.get(result.Type.toLowerCase());
if (type === undefined) {
continue;
}
if (type === 'movie') {
ret.push(
new MovieModel({
type: type,
title: result.Title,
englishTitle: result.Title,
year: result.Year,
dataSource: this.apiName,
id: result.imdbID,
}),
);
} else if (type === 'series') {
ret.push(
new SeriesModel({
type: type,
title: result.Title,
englishTitle: result.Title,
year: result.Year,
dataSource: this.apiName,
id: result.imdbID,
}),
);
} else if (type === 'game') {
ret.push(
new GameModel({
type: type,
title: result.Title,
englishTitle: result.Title,
year: result.Year,
dataSource: this.apiName,
id: result.imdbID,
}),
);
}
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.OMDbKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const responseResult = await fromPromise(
requestUrl({
url: `https://www.omdbapi.com/?i=${encodeURIComponent(id)}&apikey=${key}`,
method: 'GET',
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName },
});
}
if (response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.status, id },
});
}
const result = response.json as IdResponse | undefined;
if (!result) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data received from ${this.apiName}.`,
userMessage: `No data received from ${this.apiName}.`,
context: { apiName: this.apiName, id },
});
}
if (result.Response === 'False') {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received error from ${this.apiName}: ${result.Error}`,
userMessage: `${result.Error}`,
context: { apiName: this.apiName, id },
});
}
const type = this.typeMappings.get(result.Type.toLowerCase());
if (type === undefined) {
return err({
kind: AppErrorKind.Validation,
message: `${result.Type.toLowerCase()} is an unsupported type.`,
userMessage: `${result.Type.toLowerCase()} is an unsupported type.`,
context: { apiName: this.apiName, id, type: result.Type },
});
}
if (type === 'movie') {
return ok(
new MovieModel({
type: type,
title: result.Title,
englishTitle: result.Title,
year: result.Year,
dataSource: this.apiName,
url: `https://www.imdb.com/title/${result.imdbID}/`,
id: result.imdbID,
plot: result.Plot,
genres: result.Genre?.split(', '),
director: result.Director?.split(', '),
writer: result.Writer?.split(', '),
duration: result.Runtime,
onlineRating: Number.parseFloat(result.imdbRating ?? 0),
actors: result.Actors?.split(', '),
image: result.Poster.replace('_SX300', '_SX600'),
released: true,
country: result.Country?.split(', '),
boxOffice: result.BoxOffice,
ageRating: result.Rated,
premiere: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat),
userData: {
watched: false,
lastWatched: '',
personalRating: 0,
},
}),
);
} else if (type === 'series') {
return ok(
new SeriesModel({
type: type,
title: result.Title,
englishTitle: result.Title,
year: result.Year,
dataSource: this.apiName,
url: `https://www.imdb.com/title/${result.imdbID}/`,
id: result.imdbID,
plot: result.Plot,
genres: result.Genre?.split(', '),
writer: result.Writer?.split(', '),
studio: [],
episodes: 0,
duration: result.Runtime,
onlineRating: Number.parseFloat(result.imdbRating ?? 0),
actors: result.Actors?.split(', '),
image: result.Poster.replace('_SX300', '_SX600'),
released: true,
country: result.Country?.split(', '),
ageRating: result.Rated,
airedFrom: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat),
userData: {
watched: false,
lastWatched: '',
personalRating: 0,
},
}),
);
} else if (type === 'game') {
return ok(
new GameModel({
type: type,
title: result.Title,
englishTitle: result.Title,
year: result.Year,
dataSource: this.apiName,
url: `https://www.imdb.com/title/${result.imdbID}/`,
id: result.imdbID,
genres: result.Genre?.split(', '),
onlineRating: Number.parseFloat(result.imdbRating ?? 0),
image: result.Poster.replace('_SX300', '_SX600'),
released: true,
releaseDate: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat),
userData: {
played: false,
personalRating: 0,
},
}),
);
}
return err({
kind: AppErrorKind.Unexpected,
message: `MDB | Unknown media type for id ${id}`,
userMessage: `Unknown media type for id ${id}`,
context: { apiName: this.apiName, id },
});
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.OMDbAPI_disabledMediaTypes;
}
}

View file

@ -0,0 +1,214 @@
import createClient from 'openapi-fetch';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { BookModel } from 'packages/obsidian/src/models/BookModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
import type { paths } from 'packages/schemas/src/OpenLibrary';
interface SearchResponse {
editions: {
docs: {
key?: string;
title?: string;
cover_i?: number;
isbn?: string[];
}[];
};
cover_i?: number;
has_fulltext?: boolean;
edition_count?: number;
title?: string;
author_name?: string[];
first_publish_year?: number;
key: string;
description?: string;
number_of_pages_median?: number;
isbn?: string[];
ratings_average?: number;
}
export class OpenLibraryAPI extends APIModel {
plugin: MediaDbPlugin;
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'OpenLibraryAPI';
this.apiDescription = 'A free API for books';
this.apiUrl = 'https://openlibrary.org/';
this.types = [MediaType.Book];
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const client = createClient<paths>({ baseUrl: 'https://openlibrary.org/' });
const responseResult = await fromPromise(
client.GET('/search.json', {
params: {
query: {
q: title,
},
},
fetch: obsidianFetch,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, title },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.error !== undefined) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.response.status },
});
}
const data = response.data as {
docs: SearchResponse[];
};
// console.debug(data);
const ret: MediaTypeModel[] = [];
for (const result of data.docs) {
ret.push(
new BookModel({
title: result.title,
englishTitle: result.title,
year: result.first_publish_year?.toString() ?? 'unknown',
dataSource: this.apiName,
id: result.key,
author: result.author_name?.join(', '),
}),
);
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const client = createClient<paths>({ baseUrl: 'https://openlibrary.org/' });
const responseResult = await fromPromise(
client.GET('/search.json', {
params: {
query: {
q: `${id}`,
fields: 'key,title,author_name,number_of_pages_median,first_publish_year,isbn,ratings_score,first_sentence,title_suggest,rating*,cover*,editions,description',
},
},
fetch: obsidianFetch,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.error !== undefined) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.response.status, id },
});
}
const data = response.data as {
docs: SearchResponse[];
q?: string;
};
const result = data.docs?.[0];
if (!result) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data found for ID ${id} in ${this.apiName}.`,
userMessage: `No data found for ID ${id}.`,
context: { apiName: this.apiName, id },
});
}
let key = result.key;
let title = result.title;
let cover_i = result.cover_i;
let isbnArr = result.isbn;
// Check if the query is for /isbn/ or /books/ and extract from editions.docs if present
const q = data.q ?? '';
if ((q.includes('/isbn/') || q.includes('/books/')) && result.editions && Array.isArray(result.editions.docs) && result.editions.docs.length > 0) {
const edition = result.editions.docs[0];
key = edition.key ?? key;
title = edition.title ?? title;
cover_i = edition.cover_i ?? cover_i;
isbnArr = edition.isbn ?? isbnArr;
}
const pages = Number(result.number_of_pages_median);
const isbn = Number((isbnArr ?? []).find((el: string) => el.length <= 10));
const isbn13 = Number((isbnArr ?? []).find((el: string) => el.length == 13));
return ok(
new BookModel({
title: title,
year: result.first_publish_year?.toString() ?? 'unknown',
dataSource: this.apiName,
url: `https://openlibrary.org` + key,
id: key,
isbn: Number.isNaN(isbn) ? undefined : isbn,
isbn13: Number.isNaN(isbn13) ? undefined : isbn13,
englishTitle: title,
author: result.author_name?.join(', '),
plot: result.description ?? undefined,
pages: Number.isNaN(pages) ? undefined : pages,
onlineRating: result.ratings_average,
image: cover_i ? `https://covers.openlibrary.org/b/id/` + cover_i + `-L.jpg` : undefined,
released: true,
userData: {
read: false,
lastRead: '',
personalRating: 0,
},
}),
);
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.OpenLibraryAPI_disabledMediaTypes;
}
}

View file

@ -0,0 +1,161 @@
import { requestUrl } from 'obsidian';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { GameModel } from 'packages/obsidian/src/models/GameModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
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<Result<MediaTypeModel[], AppError>> {
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.RAWGAPIKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const responseResult = await fromPromise(
requestUrl({
url: `${this.apiUrl}/games?key=${key}&search=${encodeURIComponent(title)}&page_size=20`,
method: 'GET',
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, title },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Error ${response.status} from ${this.apiName}.`,
userMessage: `Error ${response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.status },
});
}
const data = response.json as RAWGSearchResponse;
return ok(
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<Result<MediaTypeModel, AppError>> {
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.RAWGAPIKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const responseResult = await fromPromise(
requestUrl({
url: `${this.apiUrl}/games/${id}?key=${key}`,
method: 'GET',
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Error ${response.status} from ${this.apiName}.`,
userMessage: `Error ${response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.status, id },
});
}
const result = response.json as RAWGGame;
return ok(
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 ?? [];
}
}

View file

@ -0,0 +1,301 @@
import { requestUrl } from 'obsidian';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { GameModel } from 'packages/obsidian/src/models/GameModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
import { imageUrlExists } from 'packages/obsidian/src/utils/Utils';
interface SearchResponse {
appid: string;
name: string;
icon: string;
logo: string;
}
type IdResponse = Record<
string,
{
success: boolean;
data: GameDetails;
}
>;
interface GameDetails {
type: string;
name: string;
steam_appid: number;
required_age: string;
is_free: boolean;
controller_support: string;
dlc: number[];
detailed_description: string;
about_the_game: string;
short_description: string;
supported_languages: string;
reviews: string;
header_image: string;
capsule_image: string;
capsule_imagev5: string;
website: string;
pc_requirements: Requirements;
mac_requirements: Requirements;
linux_requirements: Requirements;
legal_notice: string;
drm_notice: string;
developers: string[];
publishers: string[];
price_overview: PriceOverview;
packages: number[];
platforms: Platforms;
metacritic?: {
score: number;
url: string;
};
categories: Category[];
genres: Genre[];
recommendations: {
total: number;
};
achievements: {
total: number;
highlighted: Achievement[];
};
release_date: {
coming_soon: boolean;
date: string;
};
support_info: {
url: string;
email: string;
};
background: string;
background_raw: string;
content_descriptors: {
ids: number[];
notes: string;
};
ratings: Ratings;
}
interface Requirements {
minimum: string;
recommended: string;
}
interface PriceOverview {
currency: string;
initial: number;
final: number;
discount_percent: number;
initial_formatted: string;
final_formatted: string;
}
interface Platforms {
windows: boolean;
mac: boolean;
linux: boolean;
}
interface Category {
id: number;
description: string;
}
interface Genre {
id: string;
description: string;
}
interface Achievement {
name: string;
path: string;
}
type Ratings = Record<
string,
{
rating: string;
descriptors: string;
use_age_gate: string;
required_age: string;
rating_id?: string;
banned?: string;
rating_generated?: string;
}
>;
export class SteamAPI extends APIModel {
plugin: MediaDbPlugin;
typeMappings: Map<string, string>;
apiDateFormat: string = 'DD MMM, YYYY';
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'SteamAPI';
this.apiDescription = 'A free API for all Steam games.';
this.apiUrl = 'https://www.steampowered.com/';
this.types = [MediaType.Game];
this.typeMappings = new Map<string, string>();
this.typeMappings.set('game', 'game');
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const searchUrl = `https://steamcommunity.com/actions/SearchApps/${encodeURIComponent(title)}`;
const fetchDataResult = await fromPromise(
requestUrl({
url: searchUrl,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, title },
}),
);
if (!fetchDataResult.ok) {
return err(fetchDataResult.error);
}
const fetchData = fetchDataResult.value;
if (fetchData.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: fetchData.status },
});
}
const data = (await fetchData.json) as SearchResponse[];
// console.debug(data);
const ret: MediaTypeModel[] = [];
for (const result of data) {
ret.push(
new GameModel({
type: MediaType.Game,
title: result.name,
englishTitle: result.name,
year: '',
dataSource: this.apiName,
id: result.appid,
}),
);
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const searchUrl = `https://store.steampowered.com/api/appdetails?appids=${encodeURIComponent(id)}&l=en`;
const fetchDataResult = await fromPromise(
requestUrl({
url: searchUrl,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!fetchDataResult.ok) {
return err(fetchDataResult.error);
}
const fetchData = fetchDataResult.value;
if (fetchData.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: fetchData.status, id },
});
}
// console.debug(await fetchData.json);
const data = (await fetchData.json) as IdResponse;
let result: GameDetails | undefined = undefined;
for (const [key, value] of Object.entries(data)) {
// after some testing I found out that id is somehow a number despite that it's defined as string...
if (key === String(id)) {
result = value.data;
}
}
if (!result) {
return err({
kind: AppErrorKind.Api,
message: 'MDB | API returned invalid data.',
userMessage: 'Steam returned invalid data.',
context: { apiName: this.apiName, id },
});
}
// console.debug(result);
// Check if a poster version of the image exists, else use the header image
const imageUrl = `https://steamcdn-a.akamaihd.net/steam/apps/${result.steam_appid}/library_600x900_2x.jpg`;
const existsResult = await fromPromise(imageUrlExists(imageUrl), cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Failed to validate image URL for ${this.apiName}`,
userMessage: `Failed to validate image URL for ${this.apiName}`,
context: { apiName: this.apiName, id, imageUrl },
}),
);
const exists = existsResult.ok ? existsResult.value : false;
let finalimageurl;
if (exists) {
finalimageurl = imageUrl;
} else {
finalimageurl = result.header_image ?? '';
}
return ok(
new GameModel({
type: MediaType.Game,
title: result.name,
englishTitle: result.name,
year: new Date(result.release_date.date).getFullYear().toString(),
dataSource: this.apiName,
url: `https://store.steampowered.com/app/${result.steam_appid}`,
id: result.steam_appid.toString(),
developers: result.developers,
publishers: result.publishers,
genres: result.genres?.map(x => x.description),
onlineRating: result.metacritic?.score,
image: finalimageurl,
released: !result.release_date?.coming_soon,
releaseDate: this.plugin.dateFormatter.format(result.release_date?.date, this.apiDateFormat),
userData: {
played: false,
personalRating: 0,
},
}),
);
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.SteamAPI_disabledMediaTypes;
}
}

View file

@ -0,0 +1,265 @@
import createClient from 'openapi-fetch';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import { MovieModel } from 'packages/obsidian/src/models/MovieModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
import type { paths } from 'packages/schemas/src/TMDB';
interface TMDBCreditMember {
name?: string | null;
job?: string | null;
}
interface TMDBCreditsResponse {
credits?: {
cast?: TMDBCreditMember[];
crew?: TMDBCreditMember[];
};
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
function getTopCastNames(credits: TMDBCreditsResponse['credits'], size: number): string[] {
return (credits?.cast ?? [])
.map(c => c.name)
.filter(isNonEmptyString)
.slice(0, size);
}
function getCrewNamesByJob(credits: TMDBCreditsResponse['credits'], job: string): string[] {
return (credits?.crew ?? [])
.filter(c => c.job === job)
.map(c => c.name)
.filter(isNonEmptyString);
}
export class TMDBMovieAPI extends APIModel {
plugin: MediaDbPlugin;
typeMappings: Map<string, string>;
apiDateFormat: string = 'YYYY-MM-DD';
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'TMDBMovieAPI';
this.apiDescription = 'A community built Movie DB.';
this.apiUrl = 'https://www.themoviedb.org/';
this.types = [MediaType.Movie];
this.typeMappings = new Map<string, string>();
this.typeMappings.set('movie', 'movie');
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const responseResult = await fromPromise(
client.GET('/3/search/movie', {
headers: {
Authorization: `Bearer ${key}`,
},
params: {
query: {
query: encodeURIComponent(title),
include_adult: this.plugin.settings.sfwFilter ? false : true,
},
},
fetch: obsidianFetch,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, title },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.response.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName },
});
}
if (response.response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.response.status },
});
}
const data = response.data;
if (!data) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data received from ${this.apiName}.`,
userMessage: `No data received from ${this.apiName}.`,
context: { apiName: this.apiName },
});
}
if (data.total_results === 0 || !data.results) {
return ok([]);
}
// console.debug(data.results);
const ret: MediaTypeModel[] = [];
for (const result of data.results) {
ret.push(
new MovieModel({
type: 'movie',
title: result.original_title,
englishTitle: result.title,
year: result.release_date ? new Date(result.release_date).getFullYear().toString() : 'unknown',
dataSource: this.apiName,
id: result.id.toString(),
}),
);
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const responseResult = await fromPromise(
client.GET('/3/movie/{movie_id}', {
headers: {
Authorization: `Bearer ${key}`,
},
params: {
path: { movie_id: parseInt(id) },
query: {
append_to_response: 'credits',
},
},
fetch: obsidianFetch,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.response.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName },
});
}
if (response.response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.response.status, id },
});
}
const result = response.data;
if (!result) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data received from ${this.apiName}.`,
userMessage: `No data received from ${this.apiName}.`,
context: { apiName: this.apiName, id },
});
}
// console.debug(result);
const credits = (result as TMDBCreditsResponse).credits;
return ok(
new MovieModel({
type: 'movie',
title: result.title,
englishTitle: result.title,
year: result.release_date ? new Date(result.release_date).getFullYear().toString() : 'unknown',
premiere: this.plugin.dateFormatter.format(result.release_date, this.apiDateFormat) ?? 'unknown',
dataSource: this.apiName,
url: `https://www.themoviedb.org/movie/${result.id}`,
id: result.id.toString(),
plot: result.overview ?? '',
genres: result.genres?.map(g => g.name).filter(isNonEmptyString) ?? [],
writer: getCrewNamesByJob(credits, 'Screenplay'),
director: getCrewNamesByJob(credits, 'Director'),
studio: result.production_companies?.map(s => s.name).filter(isNonEmptyString) ?? [],
duration: result.runtime?.toString() ?? 'unknown',
onlineRating: result.vote_average,
actors: getTopCastNames(credits, 5),
image: `https://image.tmdb.org/t/p/w780${result.poster_path}`,
released: ['Released'].includes(result.status!),
streamingServices: [],
userData: {
watched: false,
lastWatched: '',
personalRating: 0,
},
}),
);
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.TMDBMovieAPI_disabledMediaTypes;
}
}

View file

@ -0,0 +1,438 @@
import createClient from 'openapi-fetch';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import { SeasonModel } from 'packages/obsidian/src/models/SeasonModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
import type { paths } from 'packages/schemas/src/TMDB';
interface NamedEntity {
name?: string | null;
}
interface CastMember {
name?: string | null;
}
interface CreditsLike {
cast?: CastMember[] | null;
}
function extractNames(items: (NamedEntity | null | undefined)[] | null | undefined): string[] {
if (!Array.isArray(items)) {
return [];
}
return items.map(item => item?.name?.trim() ?? '').filter(name => name.length > 0);
}
function getTopActorNames(credits: CreditsLike | null | undefined, limit: number = 5): string[] {
if (!credits || !Array.isArray(credits.cast)) {
return [];
}
return credits.cast
.map(member => {
const name = member?.name;
return typeof name === 'string' ? name : '';
})
.filter(name => name.length > 0)
.slice(0, limit);
}
export class TMDBSeasonAPI extends APIModel {
plugin: MediaDbPlugin;
typeMappings: Map<string, string>;
apiDateFormat: string = 'YYYY-MM-DD';
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'TMDBSeasonAPI';
this.apiDescription = 'A community built Series DB (seasons).';
this.apiUrl = 'https://www.themoviedb.org/';
this.types = [MediaType.Season];
this.typeMappings = new Map<string, string>();
this.typeMappings.set('tv', 'season');
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const searchResponseResult = await fromPromise(
client.GET('/3/search/tv', {
headers: {
Authorization: `Bearer ${key}`,
},
params: {
query: {
query: encodeURIComponent(title),
include_adult: this.plugin.settings.sfwFilter ? false : true,
},
},
fetch: obsidianFetch,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, title },
}),
);
if (!searchResponseResult.ok) {
return err(searchResponseResult.error);
}
const searchResponse = searchResponseResult.value;
if (searchResponse.response.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName },
});
}
if (searchResponse.response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${searchResponse.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${searchResponse.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: searchResponse.response.status },
});
}
const searchData = searchResponse.data;
if (!searchData?.results || searchData.total_results === 0) {
return ok([]);
}
const topResults = searchData.results.slice(0, 20);
const items = await Promise.all(
topResults.map(async result => {
let totalSeasons = 0;
if (typeof result.id === 'number') {
try {
const detailsResponse = await client.GET('/3/tv/{series_id}', {
headers: {
Authorization: `Bearer ${key}`,
},
params: {
path: { series_id: result.id },
},
fetch: obsidianFetch,
});
if (detailsResponse.response.status === 200 && Array.isArray(detailsResponse.data?.seasons)) {
totalSeasons = detailsResponse.data.seasons.length;
}
} catch {
// Ignore detail errors and use 0 as fallback.
}
}
return new SeasonModel({
title: `${result.name ?? result.original_name ?? ''}`,
englishTitle: result.name ?? result.original_name ?? '',
year: result.first_air_date ? new Date(result.first_air_date).getFullYear().toString() : 'unknown',
dataSource: this.apiName,
id: result.id?.toString() ?? '',
seasonTitle: result.name ?? result.original_name ?? '',
seasonNumber: totalSeasons,
});
}),
);
return ok(items);
}
// Fetch all seasons for a given series
async getSeasonsForSeries(tvId: string): Promise<Result<SeasonModel[], AppError>> {
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName, tvId },
});
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const seriesResponseResult = await fromPromise(
client.GET('/3/tv/{series_id}', {
headers: {
Authorization: `Bearer ${key}`,
},
params: {
path: { series_id: Number.parseInt(tvId, 10) },
},
fetch: obsidianFetch,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, tvId },
}),
);
if (!seriesResponseResult.ok) {
return err(seriesResponseResult.error);
}
const seriesResponse = seriesResponseResult.value;
if (seriesResponse.response.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName, tvId },
});
}
if (seriesResponse.response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${seriesResponse.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${seriesResponse.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: seriesResponse.response.status, tvId },
});
}
const seriesData = seriesResponse.data;
const seriesName = seriesData?.name ?? '';
const ret: SeasonModel[] = [];
if (Array.isArray(seriesData?.seasons)) {
for (const season of seriesData.seasons) {
const seasonNumber = season.season_number ?? 0;
const titleText = `${seriesName} - Season ${seasonNumber}`;
ret.push(
new SeasonModel({
title: titleText,
englishTitle: titleText,
year: season.air_date ? new Date(season.air_date).getFullYear().toString() : 'unknown',
dataSource: this.apiName,
id: `${tvId}/season/${seasonNumber}`,
seasonTitle: season.name ?? titleText,
seasonNumber: seasonNumber,
}),
);
}
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName, id },
});
}
// Expect season ids like "12345/season/2"
const m = /^(\d+)\/season\/(\d+)$/.exec(id);
if (!m) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | Invalid season id "${id}". Expected format "<series_id>/season/<season_number>".`,
userMessage: `Invalid season id "${id}".`,
context: { apiName: this.apiName, id },
});
}
const tvId = Number.parseInt(m[1], 10);
const seasonNumber = Number.parseInt(m[2], 10);
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
// Fetch season details
const seasonResponseResult = await fromPromise(
client.GET('/3/tv/{series_id}/season/{season_number}', {
headers: {
Authorization: `Bearer ${key}`,
},
params: {
path: {
series_id: tvId,
season_number: seasonNumber,
},
},
fetch: obsidianFetch,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!seasonResponseResult.ok) {
return err(seasonResponseResult.error);
}
const seasonResponse = seasonResponseResult.value;
if (seasonResponse.response.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName, id },
});
}
if (seasonResponse.response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${seasonResponse.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${seasonResponse.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: seasonResponse.response.status, id },
});
}
const seasonData = seasonResponse.data;
if (!seasonData) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data received from ${this.apiName}.`,
userMessage: `No data received from ${this.apiName}.`,
context: { apiName: this.apiName, id },
});
}
// Fetch parent series to build consistent titles and inherit fields
const seriesResponseResult = await fromPromise(
client.GET('/3/tv/{series_id}', {
headers: {
Authorization: `Bearer ${key}`,
},
params: {
path: { series_id: tvId },
query: {
append_to_response: 'credits',
},
},
fetch: obsidianFetch,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!seriesResponseResult.ok) {
return err(seriesResponseResult.error);
}
const seriesResponse = seriesResponseResult.value;
if (seriesResponse.response.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName, id },
});
}
if (seriesResponse.response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${seriesResponse.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${seriesResponse.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: seriesResponse.response.status, id },
});
}
const seriesData = seriesResponse.data;
if (!seriesData) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data received from ${this.apiName}.`,
userMessage: `No data received from ${this.apiName}.`,
context: { apiName: this.apiName, id },
});
}
const seriesName = seriesData?.name ?? '';
const airDate = seasonData.air_date ?? '';
const titleText = `${seriesName} - Season ${seasonData.season_number}`;
// Get airedTo as the air_date of the last episode, if available
let airedTo = 'unknown';
if (Array.isArray(seasonData.episodes) && seasonData.episodes.length > 0) {
const lastEp = seasonData.episodes[seasonData.episodes.length - 1];
if (lastEp?.air_date) airedTo = lastEp.air_date;
}
const formattedAiredTo = airedTo === 'unknown' ? 'unknown' : (this.plugin.dateFormatter.format(airedTo, this.apiDateFormat) ?? airedTo);
return ok(
new SeasonModel({
title: titleText,
englishTitle: titleText,
year: airDate ? new Date(airDate).getFullYear().toString() : 'unknown',
dataSource: this.apiName,
url: `https://www.themoviedb.org/tv/${tvId.toString()}/season/${seasonData.season_number}`,
id: `${tvId.toString()}/season/${seasonData.season_number}`,
seasonTitle: seasonData.name ?? titleText,
seasonNumber: seasonData.season_number ?? seasonNumber,
episodes: Array.isArray(seasonData.episodes) ? seasonData.episodes.length : 0,
airedFrom: this.plugin.dateFormatter.format(airDate, this.apiDateFormat) ?? 'unknown',
airedTo: formattedAiredTo,
plot: seasonData.overview ?? '',
image: seasonData.poster_path ? `https://image.tmdb.org/t/p/w780${seasonData.poster_path}` : '',
genres: extractNames(seriesData.genres),
writer: extractNames(seriesData.created_by),
studio: extractNames(seriesData.production_companies),
duration: seriesData.episode_run_time?.[0]?.toString() ?? '',
onlineRating: seasonData.vote_average ?? 0,
actors: getTopActorNames((seriesData as { credits?: CreditsLike }).credits),
released: ['Returning Series', 'Cancelled', 'Ended'].includes(seriesData.status ?? ''),
streamingServices: [],
airing: ['Returning Series'].includes(seriesData.status ?? ''),
userData: { watched: false, lastWatched: '', personalRating: 0 },
}),
);
}
getDisabledMediaTypes(): MediaType[] {
return [];
}
}

View file

@ -0,0 +1,255 @@
import createClient from 'openapi-fetch';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import { SeriesModel } from 'packages/obsidian/src/models/SeriesModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
import type { paths } from 'packages/schemas/src/TMDB';
interface TMDBCreditMember {
name?: string | null;
}
interface TMDBCreditsResponse {
credits?: {
cast?: TMDBCreditMember[];
};
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
function getTopCastNames(credits: TMDBCreditsResponse['credits'], size: number): string[] {
return (credits?.cast ?? [])
.map(c => c.name)
.filter(isNonEmptyString)
.slice(0, size);
}
export class TMDBSeriesAPI extends APIModel {
plugin: MediaDbPlugin;
typeMappings: Map<string, string>;
apiDateFormat: string = 'YYYY-MM-DD';
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'TMDBSeriesAPI';
this.apiDescription = 'A community built Series DB.';
this.apiUrl = 'https://www.themoviedb.org/';
this.types = [MediaType.Series];
this.typeMappings = new Map<string, string>();
this.typeMappings.set('tv', 'series');
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName },
});
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const responseResult = await fromPromise(
client.GET('/3/search/tv', {
headers: {
Authorization: `Bearer ${key}`,
},
params: {
query: {
query: encodeURIComponent(title),
include_adult: this.plugin.settings.sfwFilter ? false : true,
},
},
fetch: obsidianFetch,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, title },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.response.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName },
});
}
if (response.response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.response.status },
});
}
const data = response.data;
if (!data) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data received from ${this.apiName}.`,
userMessage: `No data received from ${this.apiName}.`,
context: { apiName: this.apiName },
});
}
if (data.total_results === 0 || !data.results) {
return ok([]);
}
// console.debug(data.results);
const ret: MediaTypeModel[] = [];
for (const result of data.results) {
ret.push(
new SeriesModel({
type: 'series',
title: result.original_name,
englishTitle: result.name,
year: result.first_air_date ? new Date(result.first_air_date).getFullYear().toString() : 'unknown',
dataSource: this.apiName,
id: result.id.toString(),
}),
);
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
if (!key) {
return err({
kind: AppErrorKind.Validation,
message: `MDB | API key for ${this.apiName} missing.`,
userMessage: `API key for ${this.apiName} missing.`,
context: { apiName: this.apiName, id },
});
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const responseResult = await fromPromise(
client.GET('/3/tv/{series_id}', {
headers: {
Authorization: `Bearer ${key}`,
},
params: {
path: { series_id: parseInt(id) },
query: {
append_to_response: 'credits',
},
},
fetch: obsidianFetch,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!responseResult.ok) {
return err(responseResult.error);
}
const response = responseResult.value;
if (response.response.status === 401) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
context: { apiName: this.apiName, id },
});
}
if (response.response.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: response.response.status, id },
});
}
const result = response.data;
if (!result) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data received from ${this.apiName}.`,
userMessage: `No data received from ${this.apiName}.`,
context: { apiName: this.apiName, id },
});
}
// console.debug(result);
const credits = (result as TMDBCreditsResponse).credits;
return ok(
new SeriesModel({
type: 'series',
title: result.original_name,
englishTitle: result.name,
year: result.first_air_date ? new Date(result.first_air_date).getFullYear().toString() : 'unknown',
dataSource: this.apiName,
url: `https://www.themoviedb.org/tv/${result.id}`,
id: result.id.toString(),
plot: result.overview ?? '',
genres: result.genres?.map(g => g.name).filter(isNonEmptyString) ?? [],
writer: result.created_by?.map(c => c.name).filter(isNonEmptyString) ?? [],
studio: result.production_companies?.map(s => s.name).filter(isNonEmptyString) ?? [],
episodes: result.number_of_episodes,
duration: result.episode_run_time?.[0]?.toString() ?? 'unknown',
onlineRating: result.vote_average,
actors: getTopCastNames(credits, 5),
image: result.poster_path ? `https://image.tmdb.org/t/p/w780${result.poster_path}` : null,
released: ['Returning Series', 'Cancelled', 'Ended'].includes(result.status!),
streamingServices: [],
airing: ['Returning Series'].includes(result.status!),
airedFrom: this.plugin.dateFormatter.format(result.first_air_date, this.apiDateFormat) ?? 'unknown',
airedTo: ['Returning Series'].includes(result.status!) ? 'unknown' : (this.plugin.dateFormatter.format(result.last_air_date, this.apiDateFormat) ?? 'unknown'),
userData: {
watched: false,
lastWatched: '',
personalRating: 0,
},
}),
);
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.TMDBSeriesAPI_disabledMediaTypes;
}
}

View file

@ -0,0 +1,334 @@
import { requestUrl } from 'obsidian';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { GameModel } from 'packages/obsidian/src/models/GameModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
enum VNDevStatus {
Finished,
InDevelopment,
Cancelled,
}
enum TagSpoiler {
None,
Minor,
Major,
}
enum TagCategory {
Content = 'cont',
Sexual = 'ero',
Technical = 'tech',
}
/**
* A partial `POST /vn` response payload; desired fields should be listed in the request body.
*/
interface VNJSONResponse {
more: boolean;
results: [
{
id: string;
title: string;
titles: [
{
title: string;
lang: string;
},
];
devstatus: VNDevStatus;
released: string | 'TBA' | null; // eslint-disable-line @typescript-eslint/no-redundant-type-constituents
image: {
url: string;
sexual: number;
} | null;
rating: number | null;
tags: [
{
id: string;
name: string;
category: TagCategory;
rating: number;
spoiler: TagSpoiler;
},
];
developers: [
{
id: string;
name: string;
},
];
},
];
}
/**
* A partial `POST /release` response payload; desired fields should be listed in the request body.
*/
interface ReleaseJSONResponse {
more: boolean;
results: [
{
id: string;
producers: [
{
id: string;
name: string;
developer: boolean;
publisher: boolean;
},
];
},
];
}
export class VNDBAPI extends APIModel {
plugin: MediaDbPlugin;
apiDateFormat: string = 'YYYY-MM-DD'; // Can also return YYYY-MM or YYYY
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'VNDB API';
this.apiDescription = 'A free API for visual novels.';
this.apiUrl = 'https://api.vndb.org/kana';
this.types = [MediaType.Game];
}
/**
* Make a `POST` request to the VNDB API.
* @param endpoint The API endpoint to query. E.g. "/vn".
* @param body A JSON object defining the query, following the VNDB API structure.
* @returns A JSON object representing the query response.
* @see {@link https://api.vndb.org/kana#api-structure}
*/
private async postQuery(endpoint: string, body: string): Promise<Result<unknown, AppError>> {
const fetchDataResult = await fromPromise(
requestUrl({
url: `${this.apiUrl}${endpoint}`,
method: 'POST',
contentType: 'application/json',
body: body,
throw: false,
}),
cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Network error querying ${this.apiName}`,
userMessage: `Network error querying ${this.apiName}`,
context: { apiName: this.apiName, endpoint },
}),
);
if (!fetchDataResult.ok) {
return err(fetchDataResult.error);
}
const fetchData = fetchDataResult.value;
if (fetchData.status !== 200) {
switch (fetchData.status) {
case 400:
return err({
kind: AppErrorKind.Validation,
message: `MDB | Invalid request body or query [${fetchData.text}].`,
userMessage: 'Invalid VNDB request.',
context: { apiName: this.apiName, endpoint, status: fetchData.status },
});
case 404:
return err({
kind: AppErrorKind.Api,
message: 'MDB | Invalid API path or HTTP method.',
userMessage: 'VNDB endpoint not found.',
context: { apiName: this.apiName, endpoint, status: fetchData.status },
});
case 429:
return err({
kind: AppErrorKind.Api,
message: 'MDB | VNDB throttled the request.',
userMessage: 'VNDB throttled the request. Please try again later.',
context: { apiName: this.apiName, endpoint, status: fetchData.status },
});
case 500:
return err({
kind: AppErrorKind.Api,
message: 'MDB | VNDB server error.',
userMessage: 'VNDB server error.',
context: { apiName: this.apiName, endpoint, status: fetchData.status },
});
case 502:
return err({
kind: AppErrorKind.Api,
message: 'MDB | VNDB server is down.',
userMessage: 'VNDB server is down.',
context: { apiName: this.apiName, endpoint, status: fetchData.status },
});
default:
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
context: { apiName: this.apiName, endpoint, status: fetchData.status },
});
}
}
return ok(fetchData.json);
}
/**
* Make a `POST` request to the `/vn` endpoint.
* Queries visual novel entries.
* @see {@link https://api.vndb.org/kana#post-vn}
*/
private async postVNQuery(body: string): Promise<Result<VNJSONResponse, AppError>> {
const result = await this.postQuery('/vn', body);
return result.ok ? ok(result.value as VNJSONResponse) : err(result.error);
}
/**
* Make a `POST` request to the `/release` endpoint.
* Queries release entries.
* @see {@link https://api.vndb.org/kana#post-release}
*/
private async postReleaseQuery(body: string): Promise<Result<ReleaseJSONResponse, AppError>> {
const result = await this.postQuery('/release', body);
return result.ok ? ok(result.value as ReleaseJSONResponse) : err(result.error);
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
/* SFW Filter: has ANY official&&complete&&standalone&&SFW release
OR has NO official&&standalone&&NSFW release
OR has the `In-game Sexual Content Toggle` (g2708) tag */
// prettier-ignore
const vnDataResult = await this.postVNQuery(`{
"filters": ["and" ${!this.plugin.settings.sfwFilter ? `` :
`, ["or"
, ["release", "=", ["and"
, ["official", "=", "1"]
, ["rtype", "=", "complete"]
, ["patch", "!=", "1"]
, ["has_ero", "!=", "1"]
]]
, ["release", "!=", ["and"
, ["official", "=", "1"]
, ["patch", "!=", "1"]
, ["has_ero", "=", "1"]
]]
, ["tag", "=", "g2708"]
]`}
, ["search", "=", "${title}"]
],
"fields": "title, titles{title, lang}, released",
"sort": "searchrank",
"results": 20
}`);
if (!vnDataResult.ok) {
return err(vnDataResult.error);
}
const vnData = vnDataResult.value;
const ret: MediaTypeModel[] = [];
for (const vn of vnData.results) {
ret.push(
new GameModel({
type: MediaType.Game,
title: vn.title,
englishTitle: vn.titles.find(t => t.lang === 'en')?.title ?? vn.title,
year: vn.released && vn.released !== 'TBA' ? new Date(vn.released).getFullYear().toString() : 'TBA',
dataSource: this.apiName,
id: vn.id,
}),
);
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const vnDataResult = await this.postVNQuery(`{
"filters": ["id", "=", "${id}"],
"fields": "title, titles{title, lang}, devstatus, released, image{url, sexual}, rating, tags{name, category, rating, spoiler}, developers{name}"
}`);
if (!vnDataResult.ok) {
return err(vnDataResult.error);
}
const vnData = vnDataResult.value;
if (vnData.results.length !== 1) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Expected 1 result from query, got ${vnData.results.length}.`,
userMessage: 'Unexpected VNDB response.',
context: { apiName: this.apiName, id },
});
}
const vn = vnData.results[0];
const releasedIsDate = vn.released !== null && vn.released !== 'TBA';
vn.released ??= 'Unknown';
const releaseDataResult = await this.postReleaseQuery(`{
"filters": ["and"
, ["vn", "="
, ["id", "=", "${id}"]
]
, ["official", "=", 1]
],
"fields": "producers.name, producers.publisher, producers.developer",
"results": 100
}`);
if (!releaseDataResult.ok) {
return err(releaseDataResult.error);
}
const releaseData = releaseDataResult.value;
return ok(
new GameModel({
type: MediaType.Game,
title: vn.title,
englishTitle: vn.titles.find(t => t.lang === 'en')?.title ?? vn.title,
year: releasedIsDate ? new Date(vn.released).getFullYear().toString() : vn.released,
dataSource: this.apiName,
url: `https://vndb.org/${vn.id}`,
id: vn.id,
developers: vn.developers.map(d => d.name),
publishers: releaseData.results
.flatMap(r => r.producers)
.filter(p => p.publisher)
.sort((p1, p2) => Number(p2.developer) - Number(p1.developer)) // Place developer-publishers first in publisher list
.map(p => p.name)
.unique(),
genres: vn.tags
.filter(t => t.category === TagCategory.Content && t.spoiler === TagSpoiler.None && t.rating >= 2)
.sort((t1, t2) => t2.rating - t1.rating)
.map(t => t.name),
onlineRating: vn.rating ?? NaN,
// TODO: Ideally we should simply flag a sensitive image, then let the user handle it non-destructively
image: this.plugin.settings.sfwFilter && (vn.image?.sexual ?? 0) > 0.5 ? 'NSFW' : vn.image?.url,
released: vn.devstatus === VNDevStatus.Finished,
releaseDate: releasedIsDate ? (this.plugin.dateFormatter.format(vn.released, this.apiDateFormat) ?? vn.released) : vn.released,
userData: {
played: false,
personalRating: 0,
},
}),
);
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.VNDBAPI_disabledMediaTypes;
}
}

View file

@ -0,0 +1,170 @@
import { requestUrl } from 'obsidian';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import { WikiModel } from 'packages/obsidian/src/models/WikiModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
interface SearchResponse {
query: {
search: {
title: string;
pageid: number;
}[];
};
}
interface IdResponse {
query: {
pages: Record<string, WikipediaPage>;
};
}
interface WikipediaPage {
pageid: number;
title: string;
contentmodel: string;
pagelanguage: string;
pagelanguagehtmlcode: string;
pagelanguagedir: string;
touched: string; // ISO date string
lastrevid: number;
length: number;
fullurl: string;
editurl: string;
canonicalurl: string;
}
export class WikipediaAPI extends APIModel {
plugin: MediaDbPlugin;
apiDateFormat: string = 'YYYY-MM-DDTHH:mm:ssZ'; // ISO
constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'Wikipedia API';
this.apiDescription = 'The API behind Wikipedia';
this.apiUrl = 'https://www.wikipedia.com';
this.types = [MediaType.Wiki];
}
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(title)}&srlimit=20&utf8=&format=json&origin=*`;
const fetchData = await requestUrl({
url: searchUrl,
method: 'GET',
throw: false,
});
// console.debug(fetchData);
if (fetchData.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: fetchData.status },
});
}
const response = fetchData as { status: number; json(): Promise<unknown> };
const dataResult = await fromPromise(response.json(), cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Failed to parse response from ${this.apiName}`,
userMessage: `Failed to parse response from ${this.apiName}`,
context: { apiName: this.apiName },
}),
);
if (!dataResult.ok) {
return err(dataResult.error);
}
const data = dataResult.value as SearchResponse;
Logger.debug(data);
const ret: MediaTypeModel[] = [];
for (const result of data.query.search) {
ret.push(
new WikiModel({
type: 'wiki',
title: result.title,
englishTitle: result.title,
year: '',
dataSource: this.apiName,
id: result.pageid.toString(),
}),
);
}
return ok(ret);
}
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&prop=info&pageids=${encodeURIComponent(id)}&inprop=url&format=json&origin=*`;
const fetchData = await requestUrl({
url: searchUrl,
method: 'GET',
throw: false,
});
if (fetchData.status !== 200) {
return err({
kind: AppErrorKind.Api,
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
context: { apiName: this.apiName, status: fetchData.status, id },
});
}
const response = fetchData as { status: number; json(): Promise<unknown> };
const dataResult = await fromPromise(response.json(), cause =>
toAppError(cause, {
kind: AppErrorKind.Network,
message: `MDB | Failed to parse response from ${this.apiName}`,
userMessage: `Failed to parse response from ${this.apiName}`,
context: { apiName: this.apiName, id },
}),
);
if (!dataResult.ok) {
return err(dataResult.error);
}
const data = dataResult.value as IdResponse;
// console.debug(data);
const result = Object.values(data?.query?.pages)[0];
if (!result) {
return err({
kind: AppErrorKind.Api,
message: `MDB | No data received from ${this.apiName}.`,
userMessage: `No data received from ${this.apiName}.`,
context: { apiName: this.apiName, id },
});
}
return ok(
new WikiModel({
title: result.title,
englishTitle: result.title,
dataSource: this.apiName,
url: result.fullurl,
id: result.pageid.toString(),
wikiUrl: result.fullurl,
lastUpdated: this.plugin.dateFormatter.format(result.touched, this.apiDateFormat),
length: result.length,
userData: {},
}),
);
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.WikipediaAPI_disabledMediaTypes;
}
}