add obsidian eslint plugin, solve major issues
This commit is contained in:
parent
84214a1c3c
commit
498e2611ae
105 changed files with 5702 additions and 5308 deletions
217
packages/obsidian/src/api/apis/BoardGameGeekAPI.ts
Normal file
217
packages/obsidian/src/api/apis/BoardGameGeekAPI.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
170
packages/obsidian/src/api/apis/ComicVineAPI.ts
Normal file
170
packages/obsidian/src/api/apis/ComicVineAPI.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
169
packages/obsidian/src/api/apis/GiantBombAPI.ts
Normal file
169
packages/obsidian/src/api/apis/GiantBombAPI.ts
Normal 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;
|
||||
// }
|
||||
// }
|
||||
264
packages/obsidian/src/api/apis/IGDBAPI.ts
Normal file
264
packages/obsidian/src/api/apis/IGDBAPI.ts
Normal 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 ?? [];
|
||||
}
|
||||
}
|
||||
260
packages/obsidian/src/api/apis/MALAPI.ts
Normal file
260
packages/obsidian/src/api/apis/MALAPI.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
183
packages/obsidian/src/api/apis/MALAPIManga.ts
Normal file
183
packages/obsidian/src/api/apis/MALAPIManga.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
327
packages/obsidian/src/api/apis/MusicBrainzAPI.ts
Normal file
327
packages/obsidian/src/api/apis/MusicBrainzAPI.ts
Normal 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')}`;
|
||||
}
|
||||
388
packages/obsidian/src/api/apis/OMDbAPI.ts
Normal file
388
packages/obsidian/src/api/apis/OMDbAPI.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
214
packages/obsidian/src/api/apis/OpenLibraryAPI.ts
Normal file
214
packages/obsidian/src/api/apis/OpenLibraryAPI.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
161
packages/obsidian/src/api/apis/RAWGAPI.ts
Normal file
161
packages/obsidian/src/api/apis/RAWGAPI.ts
Normal 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 ?? [];
|
||||
}
|
||||
}
|
||||
301
packages/obsidian/src/api/apis/SteamAPI.ts
Normal file
301
packages/obsidian/src/api/apis/SteamAPI.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
265
packages/obsidian/src/api/apis/TMDBMovieAPI.ts
Normal file
265
packages/obsidian/src/api/apis/TMDBMovieAPI.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
438
packages/obsidian/src/api/apis/TMDBSeasonAPI.ts
Normal file
438
packages/obsidian/src/api/apis/TMDBSeasonAPI.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
255
packages/obsidian/src/api/apis/TMDBSeriesAPI.ts
Normal file
255
packages/obsidian/src/api/apis/TMDBSeriesAPI.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
334
packages/obsidian/src/api/apis/VNDBAPI.ts
Normal file
334
packages/obsidian/src/api/apis/VNDBAPI.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
170
packages/obsidian/src/api/apis/WikipediaAPI.ts
Normal file
170
packages/obsidian/src/api/apis/WikipediaAPI.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue