Merge branch 'master' into feat/vndb

This commit is contained in:
Moritz Jung 2026-01-28 19:00:31 +01:00 committed by GitHub
commit 61789308e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 51328 additions and 1050 deletions

1
.gitignore vendored
View file

@ -36,3 +36,4 @@ exampleVault/.obsidian/plugins/obsidian-media-db-plugin/*
!exampleVault/.obsidian/plugins/obsidian-media-db-plugin/.hotreload !exampleVault/.obsidian/plugins/obsidian-media-db-plugin/.hotreload
exampleVault/Media DB/* exampleVault/Media DB/*
meta.txt

View file

@ -1,12 +1,16 @@
## Obsidian Media DB Plugin ## Obsidian Media DB Plugin
A plugin that can query multiple APIs for movies, series, anime, manga, games, music and wiki articles, and import them into your vault. A plugin that can query multiple APIs for movies, series, anime, manga, books, comics, games, music, and wiki articles, and import them into your vault.
> [!WARNING]
> Please make sure you are looking at the README on the [release branch](https://github.com/mProjectsCode/obsidian-media-db-plugin/blob/release/README.md).
> The README on the master branch refers to the current in-development version of the plugin.
### Features ### Features
#### Search by Title #### Search by Title
Search a movie, series, anime, game, music release or wiki article by its name across multiple APIs. Search for movies, series, anime, manga, books, comics, games, music releases, or wiki articles by their name across multiple APIs.
#### Search by ID #### Search by ID
@ -15,54 +19,54 @@ Allows you to search by an ID that varies from API to API. Concrete information
#### Templates #### Templates
The plugin allows you to set a template note that gets added to the end of any note created by this plugin. The plugin allows you to set a template note that gets added to the end of any note created by this plugin.
The plugin also offers simple "template tgs". E.g. if the template includes `{{ title }}`, it will be replaced by the title of the movie, show or game. The plugin also offers simple template tags, for example `{{ title }}`, which will be replaced by the title of the media being imported.
Note that "template tags" are surrounded with two curly braces and that the spaces inside the curly braces are important. Note that template tags are surrounded by two curly braces and spaces. The spaces inside the curly braces are important!
For arrays there are two special ways of displaying them. For arrays, there are two special ways of displaying them:
- using `{{ LIST:variable_name }}` will result in - using `{{ LIST:variable_name }}` will result in:
``` ```
- element 1 - element 1
- element 2 - element 2
- element 3 - element 3
- ... - ...
``` ```
- using `{{ ENUM:variable_name }}` will result in - using `{{ ENUM:variable_name }}` will result in:
``` ```
element 1, element 2, element 3, ... element 1, element 2, element 3, ...
``` ```
Available variables that can be used in template tags are the same variables from the metadata of the note. Available variables that can be used in template tags are any front-matter properties.
I also published my own templates [here](https://github.com/mProjectsCode/obsidian-media-db-templates). I also published my own templates [here](https://github.com/mProjectsCode/obsidian-media-db-templates).
#### Download poster images
The plugin offers a setting to automatically download the poster images for a new media, ensuring offline access. The images are saved as `type_title (year)` e.g. `movie_The Perfect Storm (2000)`, in a user-chosen folder.
#### Metadata field customization #### Metadata field customization
Allows you to rename the metadata fields this plugin generates through mappings. Allows you to rename the metadata fields this plugin generates through mappings. The mappings can be set in the plugin's settings.
The three options for mapping are:
A mapping has to follow this syntax `[origional property name] -> [new property name]`. - `default`: Keep the original name
Multiple mappings are separated by a new line. - `remap`: Rename the property
So e.g.: - `remove`: Removes the property entirely
```
title -> name
year -> releaseYear
```
#### Bulk Import #### Bulk Import
The plugin allows you to import your preexisting media collection and upgrade them to Media DB entries. The plugin allows you to import your preexisting media collection and upgrade it to Media DB entries.
##### Prerequisites ##### Prerequisites
The preexisting media notes must be inside a folder in your vault. The preexisting media notes must be inside a folder in your vault.
For the plugin to be able to query them they need one metadata field that is used as the title the piece of media is searched by. For the plugin to be able to query them, they need one metadata field that is used as the title the piece of media is searched by.
This can be achieved by for example using a `csv` import plugin to import an existing list from outside of obsidian. This can be achieved by, for example, using a `csv` import plugin to import an existing list from outside of Obsidian.
##### Importing ##### Importing
To start the import process, right-click on the folder and select the `Import folder as Media DB entries` option. To start the import process, right-click on the folder and select the `Import folder as Media DB entries` option.
Then specify the API to search, if the current note content and metadata should be appended to the Media DB entry and the name of the metadata field that contains the title of the piece of media. Then specify the API to search, if the current note content and metadata should be appended to the Media DB entry, and the name of the metadata field that contains the title of the piece of media.
Then the plugin will go through every file in the folder and prompt you to select from the search results. Then the plugin will go through every file in the folder and prompt you to select from the search results.
@ -72,7 +76,7 @@ After all files have been imported or the import was canceled, you will find the
### How to install ### How to install
**The plugin is now released, so it can be installed directly through obsidian's plugin installer.** **The plugin is now released, so it can be installed directly through Obsidian's plugin installer.**
Alternatively, you can manually download the zip archive from the latest release here on GitHub. Alternatively, you can manually download the zip archive from the latest release here on GitHub.
After downloading, extract the archive into the `.obsidian/plugins` folder in your vault. After downloading, extract the archive into the `.obsidian/plugins` folder in your vault.
@ -91,20 +95,19 @@ The folder structure should look like this:
### How to use ### How to use
(pictures are coming)
Once you have installed this plugin, you will find a database icon in the left ribbon. Once you have installed this plugin, you will find a database icon in the left ribbon.
When using this or the `Add new Media DB entry` command, a popup will open. When using this or the `Add new Media DB entry` command, a pop-up will open.
Here you can enter the title of what you want to search for and then select in which APIs to search. Here, you can enter the title of what you want to search for and then select which APIs to search.
After clicking search, a new popup will open prompting you to select from the search results. After clicking search, a new pop-up will open, prompting you to select from the search results.
Now you select the result you want and the plugin will cast it's magic and create a new note in your vault, that contains the metadata of the selected search result. Now you select the result you want, and the plugin will cast its magic, creating a new note in your vault that contains the metadata of the selected search result.
### Currently supported media types ### Currently supported media types
- movies (including specials) - movies (including specials)
- series (including OVAs) - series (including OVAs)
- games - videogames
- boardgames
- music releases - music releases
- wiki articles - wiki articles
- books - books
@ -116,21 +119,23 @@ Now you select the result you want and the plugin will cast it's magic and creat
| Name | Description | Supported formats | Authentification | Rate limiting | SFW filter support | | Name | Description | Supported formats | Authentification | Rate limiting | SFW filter support |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| [Jikan](https://jikan.moe/) | Jikan is an API that uses [My Anime List](https://myanimelist.net) and offers metadata for anime. | series, movies, specials, OVAs, manga, manwha, novels | No | 60 per minute and 3 per second | Yes | | [Jikan](https://jikan.moe/) | Jikan is an API that uses [My Anime List](https://myanimelist.net) and offers metadata for anime. | series, movies, specials, OVAs, manga, manwha, novels | No | 60 per minute and 3 per second | Yes |
| [OMDb](https://www.omdbapi.com/) | OMDb is an API that offers metadata for movie, series and games. | series, movies, games | Yes, you can get a free key here [here](https://www.omdbapi.com/apikey.aspx) | 1000 per day | No | | [OMDb](https://www.omdbapi.com/) | OMDb is an API that offers metadata for movies, series, and games. | series, movies, games | Yes, you can get a free key here [here](https://www.omdbapi.com/apikey.aspx) | 1000 per day | No |
| [TMDB](https://www.themoviedb.org/) | TMDB is a API that offers community editable metadata for movies and series. | series, movies | Yes, by making an account [here](https://www.themoviedb.org/signup) and getting your `API Key` (**not** `API Read Access Token`) [here](https://www.themoviedb.org/settings/api) | 50 per second | Yes |
| [MusicBrainz](https://musicbrainz.org/) | MusicBrainz is an API that offers information about music releases. | music releases | No | 50 per second | No | | [MusicBrainz](https://musicbrainz.org/) | MusicBrainz is an API that offers information about music releases. | music releases | No | 50 per second | No |
| [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) | The Wikipedia API allows access to all Wikipedia articles. | wiki articles | No | None | No | | [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) | The Wikipedia API allows access to all Wikipedia articles. | wiki articles | No | None | No |
| [Steam](https://store.steampowered.com/) | The Steam API offers information on all steam games. | games | No | 10000 per day | No | | [Steam](https://store.steampowered.com/) | The Steam API offers information on all Steam games. | games | No | 10000 per day | No |
| [Open Library](https://openlibrary.org) | The OpenLibrary API offers metadata for books | books | No | Cover access is rate-limited when not using CoverID or OLID by max 100 requests/IP every 5 minutes. This plugin uses OLID so there shouldn't be a rate limit. | No | | [Open Library](https://openlibrary.org) | The OpenLibrary API offers metadata for books | books | No | Cover access is rate-limited when not using CoverID or OLID by max 100 requests/IP every 5 minutes. This plugin uses OLID, so there shouldn't be a rate limit. | No |
| [Moby Games](https://www.mobygames.com) | The Moby Games API offers metadata for games for all platforms | games | Yes, by making an account [here](https://www.mobygames.com/user/register/). NOTE: As of September 2024 the API key is no longer free so consider using Giant Bomb or steam instead | API requests are limited to 360 per hour (one every ten seconds). In addition, requests should be made no more frequently than one per second. | No | | [Moby Games](https://www.mobygames.com) | The Moby Games API offers metadata for games for all platforms | games | Yes, by making an account [here](https://www.mobygames.com/user/register/). NOTE: As of September 2024 the API key is no longer free so consider using Giant Bomb or steam instead | API requests are limited to 360 per hour (one every ten seconds). In addition, requests should be made no more frequently than one per second. | No |
| [Giant Bomb](https://www.giantbomb.com) | The Giant Bomb API offers metadata for games for all platforms | games | Yes, by making an account [here](https://www.giantbomb.com/login-signup/) | API requests are limited to 200 requests per resource, per hour. In addition, they implement velocity detection to prevent malicious use. If too many requests are made per second, you may receive temporary blocks to resources. | No | | [Giant Bomb](https://www.giantbomb.com) | The Giant Bomb API offers metadata for games for all platforms | games | Yes, by making an account [here](https://www.giantbomb.com/login-signup/) | API requests are limited to 200 requests per resource, per hour. In addition, they implement velocity detection to prevent malicious use. If too many requests are made per second, you may receive temporary blocks to resources. | No |
| Comic Vine | The Comic Vine API offers metadata for comic books | comicbooks | Yes, by making an account [here](https://comicvine.gamespot.com/login-signup/) and going to the [api section](https://comicvine.gamespot.com/api/) of the site | 200 requests per resource, per hour. There is also a velocity detection to prevent malicious use. If too many requests are made per second, you may receive temporary blocks to resources. | No | | Comic Vine | The Comic Vine API offers metadata for comic books | comicbooks | Yes, by making an account [here](https://comicvine.gamespot.com/login-signup/) and going to the [api section](https://comicvine.gamespot.com/api/) of the site | 200 requests per resource, per hour. There is also a velocity detection to prevent malicious use. If too many requests are made per second, you may receive temporary blocks to resources. | No |
| [VNDB](https://vndb.org/) | The VNDB API offers metadata for visual novels | games | No | 200 requests per 5 minutes | Yes | | [VNDB](https://vndb.org/) | The VNDB API offers metadata for visual novels | games | No | 200 requests per 5 minutes | Yes |
| [Boardgame Geek](https://boardgamegeek.com) | The Boardgame Geek API offers metadata for boardgames | boardgames | Yes, by making an account [here](https://boardgamegeek.com/join/) and then [requesting an application token](https://boardgamegeek.com/applications) | Exact usage limits are still undetermined | No |
#### Notes #### Notes
- [Jikan](https://jikan.moe/) - [Jikan](https://jikan.moe/)
- sometimes the api is very slow, this is normal - sometimes the api is very slow; this is normal
- you need to use the title the anime has on [My Anime List](https://myanimelist.net), which is in most cases the japanese title - you need to use the title the anime has on [My Anime List](https://myanimelist.net), which is in most cases the Japanese title
- e.g. instead of "Demon Slayer" you have to search "Kimetsu no Yaiba" - e.g. instead of "Demon Slayer" you have to search "Kimetsu no Yaiba"
#### Search by ID #### Search by ID
@ -148,16 +153,22 @@ Now you select the result you want and the plugin will cast it's magic and creat
- you can find this ID in the URL - you can find this ID in the URL
- e.g. for "Rogue One" the URL looks like this `https://www.imdb.com/title/tt3748528/` so the ID is `tt3748528` - e.g. for "Rogue One" the URL looks like this `https://www.imdb.com/title/tt3748528/` so the ID is `tt3748528`
- [MusicBrainz](https://musicbrainz.org/) - [MusicBrainz](https://musicbrainz.org/)
- the id of a release is not easily accessible, you are better off just searching by title - the id of a release is not easily accessible; you are better off just searching by title
- the search is generally for albums but you can have a more granular search like so:
- search for albums by a specific `artist:"Lady Gaga" AND primarytype:"album"`
- search for a specific album by a specific artist `artist:"Lady Gaga" AND primarytype:"album" AND releasegroup:"The Fame"`
- search for a specific entry (song or album) by a specific `artist:"Lady Gaga" AND releasegroup:"Poker face"`
- [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) - [Wikipedia](https://en.wikipedia.org/wiki/Main_Page)
- [here](https://en.wikipedia.org/wiki/Wikipedia:Finding_a_Wikidata_ID) is a guide to finding the Wikipedia ID for an article - [here](https://en.wikipedia.org/wiki/Wikipedia:Finding_a_Wikidata_ID) is a guide to finding the Wikipedia ID for an article
- [Steam](https://store.steampowered.com/) - [Steam](https://store.steampowered.com/)
- you can find this ID in the URL - you can find this ID in the URL
- e.g. for "Factorio" the URL looks like this `https://store.steampowered.com/app/427520/Factorio/` so the ID is `427520` - e.g. for "Factorio" the URL looks like this `https://store.steampowered.com/app/427520/Factorio/` so the ID is `427520`
- [Open Library](https://openlibrary.org) - [Open Library](https://openlibrary.org)
- The ID you need is the "work" ID and not the "book" ID, it needs to start with `/works/`. You can find this ID in the URL - The ID can either be the "/work/" ID, the "/book/" ID, or the "/isbn/" ID it needs to start with `/works/`. You can find this ID in the URL
- e.g. for "Fantastic Mr. Fox" the URL looks like this `https://openlibrary.org/works/OL45804W` so the ID is `/works/OL45804W` - e.g. for "Fantastic Mr. Fox" the "/works/" URL looks like this `https://openlibrary.org/works/OL45804W` so the ID is `/works/OL45804W`
- This URL is located near the top of the page above the title, see `An edition of Fantastic Mr Fox (1970) ` - This URL is located near the top of the page above the title, see `An edition of Fantastic Mr Fox (1970) `
- For a specific edition of "Fantastic Mr. Fox" the "/books/" URL looks like this `https://openlibrary.org/books/OL3567303M/` so the ID is `/books/OL3567303M`
- This URL is located in the editions section`
- [Moby Games](https://www.mobygames.com) - [Moby Games](https://www.mobygames.com)
- you can find this ID in the URL - you can find this ID in the URL
- e.g. for "Bioshock 2" the URL looks like this `https://www.mobygames.com/game/45089/bioshock-2/` so the ID is `45089` - e.g. for "Bioshock 2" the URL looks like this `https://www.mobygames.com/game/45089/bioshock-2/` so the ID is `45089`
@ -172,7 +183,7 @@ Now you select the result you want and the plugin will cast it's magic and creat
- Located in the novel's VNDB URL path - Located in the novel's VNDB URL path
- e.g. The ID for [Katawa Shoujo](https://vndb.org/v945) (`https://vndb.org/v945`) is `v945` - e.g. The ID for [Katawa Shoujo](https://vndb.org/v945) (`https://vndb.org/v945`) is `v945`
### Problems, unexpected behavior or improvement suggestions? ### Problems, unexpected behavior, or improvement suggestions?
You are more than welcome to open an issue on [GitHub](https://github.com/mProjectsCode/obsidian-media-db-plugin/issues). You are more than welcome to open an issue on [GitHub](https://github.com/mProjectsCode/obsidian-media-db-plugin/issues).
@ -187,4 +198,3 @@ Contributions are always welcome. If you have an idea, feel free to open a featu
Credits go to: Credits go to:
- https://github.com/anpigon/obsidian-book-search-plugin for some inspiration and the idea to make this plugin - https://github.com/anpigon/obsidian-book-search-plugin for some inspiration and the idea to make this plugin
- https://github.com/liamcain/obsidian-periodic-notes for 99% of `Suggest.ts` and `FolderSuggest.ts`

View file

@ -0,0 +1,20 @@
import { $ } from 'utils/shellUtils';
async function fetchSchema() {
// https://docs.api.jikan.moe/
await $('bun openapi-typescript https://raw.githubusercontent.com/jikan-me/jikan-rest/master/storage/api-docs/api-docs.json -o ./src/api/schemas/MALAPI.ts');
// https://www.giantbomb.com/forums/api-developers-3017/giant-bomb-openapi-specification-1901269/
await $('bun openapi-typescript ./src/api/schemas/GiantBomb.json -o ./src/api/schemas/GiantBomb.ts');
// https://www.omdbapi.com/swagger.json
// await $('bun openapi-typescript ./src/api/schemas/OMDb.json -o ./src/api/schemas/OMDb.ts');
// https://github.com/internetarchive/openlibrary-api/blob/main/swagger.yaml
await $('bun openapi-typescript ./src/api/schemas/OpenLibrary.json -o ./src/api/schemas/OpenLibrary.ts');
// https://developer.themoviedb.org/openapi
await $('bun openapi-typescript https://developer.themoviedb.org/openapi/tmdb-api.json -o ./src/api/schemas/TMDB.ts');
}
await fetchSchema();

BIN
bun.lockb

Binary file not shown.

View file

@ -7,7 +7,7 @@ import * as plugin_import from 'eslint-plugin-import';
export default tseslint.config( export default tseslint.config(
{ {
ignores: ['npm/', 'node_modules/', 'exampleVault/', 'automation/', 'main.js', '*.svelte'], ignores: ['npm/', 'node_modules/', 'exampleVault/', 'automation/', 'main.js', '*.svelte', 'src/api/schemas/'],
}, },
{ {
files: ['src/**/*.ts'], files: ['src/**/*.ts'],

View file

@ -1,7 +1,7 @@
{ {
"id": "obsidian-media-db-plugin", "id": "obsidian-media-db-plugin",
"name": "Media DB", "name": "Media DB",
"version": "0.8.0", "version": "0.8.0-canary.20251229T143140",
"minAppVersion": "1.5.0", "minAppVersion": "1.5.0",
"description": "A plugin that can query multiple APIs for movies, series, anime, games, music and wiki articles, and import them into your vault.", "description": "A plugin that can query multiple APIs for movies, series, anime, games, music and wiki articles, and import them into your vault.",
"author": "Moritz Jung", "author": "Moritz Jung",

View file

@ -1,7 +1,7 @@
{ {
"name": "obsidian-media-db-plugin", "name": "obsidian-media-db-plugin",
"version": "0.8.0", "version": "0.8.0",
"description": "A plugin that can query multiple APIs for movies, series, anime, games, music and wiki articles, and import them into your vault.", "description": "A plugin that can query multiple APIs for movies, series, anime, manga, books, comics, games, music and wiki articles, and import them into your vault.",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "bun run automation/build/esbuild.dev.config.ts", "dev": "bun run automation/build/esbuild.dev.config.ts",
@ -14,8 +14,8 @@
"lint": "eslint --max-warnings=0 src/**", "lint": "eslint --max-warnings=0 src/**",
"lint:fix": "eslint --max-warnings=0 --fix src/**", "lint:fix": "eslint --max-warnings=0 --fix src/**",
"svelte-check": "svelte-check --compiler-warnings \"unused-export-let:ignore\"", "svelte-check": "svelte-check --compiler-warnings \"unused-export-let:ignore\"",
"check": "bun run format:check && bun run tsc && bun run test", "check": "bun run format:check && bun run tsc && bun run lint && bun run svelte-check",
"check:fix": "bun run format && bun run tsc && bun run test", "check:fix": "bun run format && bun run tsc && bun run lint:fix && bun run svelte-check",
"release": "bun run automation/release.ts", "release": "bun run automation/release.ts",
"stats": "bun run automation/stats.ts" "stats": "bun run automation/stats.ts"
}, },
@ -23,26 +23,29 @@
"author": "Moritz Jung", "author": "Moritz Jung",
"license": "GPL-3.0", "license": "GPL-3.0",
"devDependencies": { "devDependencies": {
"@popperjs/core": "^2.11.8", "@happy-dom/global-registrator": "^18.0.1",
"@lemons_dev/parsinom": "^0.0.12", "@lemons_dev/parsinom": "^0.0.12",
"@happy-dom/global-registrator": "^14.12.3", "@popperjs/core": "^2.11.8",
"@types/bun": "^1.1.16", "@types/bun": "^1.2.19",
"builtin-modules": "^4.0.0", "builtin-modules": "^5.0.0",
"esbuild": "^0.24.2", "esbuild": "^0.25.8",
"esbuild-plugin-copy-watch": "^2.3.1", "esbuild-plugin-copy-watch": "^2.3.1",
"esbuild-svelte": "^0.8.2", "esbuild-svelte": "^0.9.3",
"eslint": "^9.18.0", "eslint": "^9.32.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-only-warn": "^1.1.0", "eslint-plugin-only-warn": "^1.1.0",
"iso-639-2": "^3.0.2",
"obsidian": "latest", "obsidian": "latest",
"prettier": "^3.4.2", "openapi-fetch": "^0.14.0",
"prettier-plugin-svelte": "^3.3.3", "openapi-typescript": "^7.8.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"string-argv": "^0.3.2", "string-argv": "^0.3.2",
"svelte": "^5.17.5", "svelte": "^5.38.0",
"svelte-check": "^4.1.4", "svelte-check": "^4.3.1",
"svelte-preprocess": "^6.0.3", "svelte-preprocess": "^6.0.3",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.7.3", "typescript": "^5.9.2",
"typescript-eslint": "^8.20.0" "typescript-eslint": "^8.39.0"
} }
} }

View file

@ -10,7 +10,7 @@ export abstract class APIModel {
plugin!: MediaDbPlugin; plugin!: MediaDbPlugin;
/** /**
* This function should query the api and return a list of matches. The matches should be caped at 20. * This function should query the api and return a list of matches. The matches should be capped at 20.
* *
* @param title the title to query for * @param title the title to query for
*/ */
@ -18,14 +18,11 @@ export abstract class APIModel {
abstract getById(id: string): Promise<MediaTypeModel>; abstract getById(id: string): Promise<MediaTypeModel>;
abstract getDisabledMediaTypes(): MediaType[];
hasType(type: MediaType): boolean { hasType(type: MediaType): boolean {
// if ( const disabledMediaTypes = this.getDisabledMediaTypes();
// this.types.contains(type) && return this.types.includes(type) && !disabledMediaTypes.includes(type);
// (Boolean((this.plugin.settings.apiToggle as any)?.[this.apiName]?.[type]) === true || (this.plugin.settings.apiToggle as any)?.[this.apiName]?.[type] === undefined)
// ) {
// return true;
// }
return this.types.contains(type);
} }
hasTypeOverlap(types: MediaType[]): boolean { hasTypeOverlap(types: MediaType[]): boolean {

View file

@ -5,6 +5,8 @@ import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { MediaType } from '../../utils/MediaType'; import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel'; import { APIModel } from '../APIModel';
// sadly no open api schema available
export class BoardGameGeekAPI extends APIModel { export class BoardGameGeekAPI extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
@ -14,7 +16,7 @@ export class BoardGameGeekAPI extends APIModel {
this.plugin = plugin; this.plugin = plugin;
this.apiName = 'BoardGameGeekAPI'; this.apiName = 'BoardGameGeekAPI';
this.apiDescription = 'A free API for BoardGameGeek things.'; this.apiDescription = 'A free API for BoardGameGeek things.';
this.apiUrl = 'https://api.geekdo.com/xmlapi'; this.apiUrl = 'https://boardgamegeek.com/xmlapi/';
this.types = [MediaType.BoardGame]; this.types = [MediaType.BoardGame];
} }
@ -24,8 +26,15 @@ export class BoardGameGeekAPI extends APIModel {
const searchUrl = `${this.apiUrl}/search?search=${encodeURIComponent(title)}`; const searchUrl = `${this.apiUrl}/search?search=${encodeURIComponent(title)}`;
const fetchData = await requestUrl({ const fetchData = await requestUrl({
url: searchUrl, url: searchUrl,
headers: {
Authorization: `Bearer ${this.plugin.settings.BoardgameGeekKey}`,
},
}); });
if (fetchData.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
}
if (fetchData.status !== 200) { if (fetchData.status !== 200) {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`);
} }
@ -62,8 +71,15 @@ export class BoardGameGeekAPI extends APIModel {
const searchUrl = `${this.apiUrl}/boardgame/${encodeURIComponent(id)}?stats=1`; const searchUrl = `${this.apiUrl}/boardgame/${encodeURIComponent(id)}?stats=1`;
const fetchData = await requestUrl({ const fetchData = await requestUrl({
url: searchUrl, url: searchUrl,
headers: {
Authorization: `Bearer ${this.plugin.settings.BoardgameGeekKey}`,
},
}); });
if (fetchData.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
}
if (fetchData.status !== 200) { if (fetchData.status !== 200) {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`);
} }
@ -117,4 +133,7 @@ export class BoardGameGeekAPI extends APIModel {
}, },
}); });
} }
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.BoardgameGeekAPI_disabledMediaTypes;
}
} }

View file

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
import { requestUrl } from 'obsidian'; import { requestUrl } from 'obsidian';
import { ComicMangaModel } from 'src/models/ComicMangaModel'; import { ComicMangaModel } from 'src/models/ComicMangaModel';
import type MediaDbPlugin from '../../main'; import type MediaDbPlugin from '../../main';
@ -5,6 +7,8 @@ import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { MediaType } from '../../utils/MediaType'; import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel'; import { APIModel } from '../APIModel';
// sadly no open api schema available
export class ComicVineAPI extends APIModel { export class ComicVineAPI extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
@ -41,7 +45,7 @@ export class ComicVineAPI extends APIModel {
year: result.start_year, year: result.start_year,
dataSource: this.apiName, dataSource: this.apiName,
id: `4050-${result.id}`, id: `4050-${result.id}`,
publishers: result.publisher?.name ?? [], publishers: result.publisher?.name,
}), }),
); );
} }
@ -64,28 +68,32 @@ export class ComicVineAPI extends APIModel {
} }
const data = await fetchData.json; const data = await fetchData.json;
// console.debug(data);
const result = data.results; const result = data.results;
const authors = result.people as
| {
name: string;
}[]
| undefined;
return new ComicMangaModel({ return new ComicMangaModel({
type: MediaType.ComicManga, type: MediaType.ComicManga,
title: result.name, title: result.name,
englishTitle: result.name, englishTitle: result.name,
alternateTitles: result.aliases, alternateTitles: result.aliases,
plot: result.deck, plot: result.deck,
year: result.start_year ?? '', year: result.start_year,
dataSource: this.apiName, dataSource: this.apiName,
url: result.site_detail_url, url: result.site_detail_url,
id: `4050-${result.id}`, id: `4050-${result.id}`,
authors: result.people?.map((x: any) => x.name) ?? [], authors: authors?.map(x => x.name),
chapters: result.count_of_issues, chapters: result.count_of_issues,
image: result.image?.original_url ?? '', image: result.image?.original_url,
released: true, released: true,
publishers: result.publisher?.name ?? [], publishers: result.publisher?.name,
publishedFrom: result.start_year ?? 'unknown', publishedFrom: result.start_year,
publishedTo: 'unknown',
status: result.status, status: result.status,
userData: { userData: {
@ -95,4 +103,7 @@ export class ComicVineAPI extends APIModel {
}, },
}); });
} }
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.ComicVineAPI_disabledMediaTypes;
}
} }

View file

@ -1,9 +1,11 @@
import { requestUrl } from 'obsidian'; import createClient from 'openapi-fetch';
import { obsidianFetch } from 'src/utils/Utils';
import type MediaDbPlugin from '../../main'; import type MediaDbPlugin from '../../main';
import { GameModel } from '../../models/GameModel'; import { GameModel } from '../../models/GameModel';
import type { MediaTypeModel } from '../../models/MediaTypeModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { MediaType } from '../../utils/MediaType'; import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel'; import { APIModel } from '../APIModel';
import type { paths } from '../schemas/GiantBomb';
export class GiantBombAPI extends APIModel { export class GiantBombAPI extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
@ -18,6 +20,7 @@ export class GiantBombAPI extends APIModel {
this.apiUrl = 'https://www.giantbomb.com/api'; this.apiUrl = 'https://www.giantbomb.com/api';
this.types = [MediaType.Game]; this.types = [MediaType.Game];
} }
async searchByTitle(title: string): Promise<MediaTypeModel[]> { async searchByTitle(title: string): Promise<MediaTypeModel[]> {
console.log(`MDB | api "${this.apiName}" queried by Title`); console.log(`MDB | api "${this.apiName}" queried by Title`);
@ -25,35 +28,42 @@ export class GiantBombAPI extends APIModel {
throw Error(`MDB | API key for ${this.apiName} missing.`); throw Error(`MDB | API key for ${this.apiName} missing.`);
} }
const searchUrl = `${this.apiUrl}/games?api_key=${this.plugin.settings.GiantBombKey}&filter=name:${encodeURIComponent(title)}&format=json`; const client = createClient<paths>({ baseUrl: 'https://www.giantbomb.com/api/' });
const fetchData = await requestUrl({ const response = await client.GET('/games', {
url: searchUrl, params: {
query: {
api_key: this.plugin.settings.GiantBombKey,
filter: `name:${title}`,
format: 'json',
limit: 20,
},
},
fetch: obsidianFetch,
}); });
// console.debug(fetchData); if (response.response.status === 401) {
if (fetchData.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
} }
if (fetchData.status === 429) { if (response.response.status === 429) {
throw Error(`MDB | Too many requests for ${this.apiName}, you've exceeded your API quota.`); throw Error(`MDB | Too many requests for ${this.apiName}, you've exceeded your API quota.`);
} }
if (fetchData.status !== 200) { if (response.response.status !== 200) {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
} }
const data = await fetchData.json; const data = response.data?.results;
// console.debug(data);
const ret: MediaTypeModel[] = []; const ret: MediaTypeModel[] = [];
for (const result of data.results) { for (const result of data ?? []) {
const year = result.original_release_date ? new Date(result.original_release_date).getFullYear().toString() : undefined;
ret.push( ret.push(
new GameModel({ new GameModel({
type: MediaType.Game,
title: result.name, title: result.name,
englishTitle: result.name, englishTitle: result.name,
year: new Date(result.original_release_date).getFullYear().toString(), year: year,
dataSource: this.apiName, dataSource: this.apiName,
id: result.guid, id: result.guid?.toString(),
}), }),
); );
} }
@ -68,36 +78,79 @@ export class GiantBombAPI extends APIModel {
throw Error(`MDB | API key for ${this.apiName} missing.`); throw Error(`MDB | API key for ${this.apiName} missing.`);
} }
const searchUrl = `${this.apiUrl}/game/${encodeURIComponent(id)}/?api_key=${this.plugin.settings.GiantBombKey}&format=json`; const client = createClient<paths>({ baseUrl: 'https://www.giantbomb.com/api/' });
const fetchData = await requestUrl({ const response = await client.GET('/game/{guid}', {
url: searchUrl, params: {
path: {
guid: id,
},
query: {
api_key: this.plugin.settings.GiantBombKey,
format: 'json',
},
},
fetch: obsidianFetch,
}); });
console.debug(fetchData);
if (fetchData.status !== 200) { if (response.response.status === 401) {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 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 = await fetchData.json; const result = response.data?.results;
// console.debug(data);
const result = 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({ return new GameModel({
type: MediaType.Game, type: MediaType.Game,
title: result.name, title: result.name,
englishTitle: result.name, englishTitle: result.name,
year: new Date(result.original_release_date).getFullYear().toString(), year: year,
dataSource: this.apiName, dataSource: this.apiName,
url: result.site_detail_url, url: result.site_detail_url,
id: result.guid, id: result.guid?.toString(),
developers: result.developers?.map((x: any) => x.name) ?? [], developers: developers?.map(x => x.name),
publishers: result.publishers?.map((x: any) => x.name) ?? [], publishers: publishers?.map(x => x.name),
genres: result.genres?.map((x: any) => x.name) ?? [], genres: genres?.map(x => x.name),
onlineRating: 0, onlineRating: 0,
image: result.image?.super_url ?? '', image: image?.super_url,
released: true, released: true,
releaseDate: result.original_release_date ?? 'unknown', releaseDate: result.original_release_date,
userData: { userData: {
played: false, played: false,
@ -106,4 +159,7 @@ export class GiantBombAPI extends APIModel {
}, },
}); });
} }
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.GiantBombAPI_disabledMediaTypes;
}
} }

View file

@ -1,9 +1,12 @@
import createClient from 'openapi-fetch';
import { isTruthy, obsidianFetch } from 'src/utils/Utils';
import type MediaDbPlugin from '../../main'; import type MediaDbPlugin from '../../main';
import type { MediaTypeModel } from '../../models/MediaTypeModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { MovieModel } from '../../models/MovieModel'; import { MovieModel } from '../../models/MovieModel';
import { SeriesModel } from '../../models/SeriesModel'; import { SeriesModel } from '../../models/SeriesModel';
import { MediaType } from '../../utils/MediaType'; import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel'; import { APIModel } from '../APIModel';
import type { paths } from '../schemas/MALAPI';
export class MALAPI extends APIModel { export class MALAPI extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
@ -28,30 +31,42 @@ export class MALAPI extends APIModel {
async searchByTitle(title: string): Promise<MediaTypeModel[]> { async searchByTitle(title: string): Promise<MediaTypeModel[]> {
console.log(`MDB | api "${this.apiName}" queried by Title`); console.log(`MDB | api "${this.apiName}" queried by Title`);
const searchUrl = `https://api.jikan.moe/v4/anime?q=${encodeURIComponent(title)}&limit=20${this.plugin.settings.sfwFilter ? '&sfw' : ''}`; const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
const fetchData = await fetch(searchUrl); const response = await client.GET('/anime', {
// console.debug(fetchData); params: {
if (fetchData.status !== 200) { query: {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); q: title,
limit: 20,
sfw: this.plugin.settings.sfwFilter ? true : false,
},
},
fetch: obsidianFetch,
});
if (response.error !== undefined) {
throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
} }
const data = await fetchData.json();
// console.debug(data); const data = response.data?.data;
const ret: MediaTypeModel[] = []; const ret: MediaTypeModel[] = [];
for (const result of data.data) { for (const result of data ?? []) {
const type = this.typeMappings.get(result.type?.toLowerCase()); 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) { if (type === undefined) {
ret.push( ret.push(
new MovieModel({ new MovieModel({
subType: '', subType: '',
title: result.title, title: result.title,
englishTitle: result.title_english ?? result.title, englishTitle: result.title_english ?? result.title,
year: result.year ?? result.aired?.prop?.from?.year ?? '', year,
dataSource: this.apiName, dataSource: this.apiName,
id: result.mal_id, id,
}), }),
); );
} }
@ -61,9 +76,9 @@ export class MALAPI extends APIModel {
subType: type, subType: type,
title: result.title, title: result.title,
englishTitle: result.title_english ?? result.title, englishTitle: result.title_english ?? result.title,
year: result.year ?? result.aired?.prop?.from?.year ?? '', year,
dataSource: this.apiName, dataSource: this.apiName,
id: result.mal_id, id,
}), }),
); );
} else if (type === 'series' || type === 'ova') { } else if (type === 'series' || type === 'ova') {
@ -72,9 +87,9 @@ export class MALAPI extends APIModel {
subType: type, subType: type,
title: result.title, title: result.title,
englishTitle: result.title_english ?? result.title, englishTitle: result.title_english ?? result.title,
year: result.year ?? result.aired?.prop?.from?.year ?? '', year,
dataSource: this.apiName, dataSource: this.apiName,
id: result.mal_id, id,
}), }),
); );
} }
@ -86,41 +101,53 @@ export class MALAPI extends APIModel {
async getById(id: string): Promise<MediaTypeModel> { async getById(id: string): Promise<MediaTypeModel> {
console.log(`MDB | api "${this.apiName}" queried by ID`); console.log(`MDB | api "${this.apiName}" queried by ID`);
const searchUrl = `https://api.jikan.moe/v4/anime/${encodeURIComponent(id)}/full`; const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
const fetchData = await fetch(searchUrl);
if (fetchData.status !== 200) { const response = await client.GET('/anime/{id}/full', {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); params: {
path: {
id: id as unknown as number, // This is fine
},
},
fetch: obsidianFetch,
});
if (response.error !== undefined) {
throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
} }
const data = await fetchData.json(); const result = response.data?.data;
// console.debug(data);
const result = data.data; if (result === undefined) {
throw Error(`MDB | No data found for ID ${id} in ${this.apiName}.`);
}
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();
const type = this.typeMappings.get(result.type?.toLowerCase());
if (type === undefined) { if (type === undefined) {
return new MovieModel({ return new MovieModel({
subType: '', subType: undefined,
title: result.title, title: result.title,
englishTitle: result.title_english ?? result.title, englishTitle: result.title_english ?? result.title,
year: result.year ?? result.aired?.prop?.from?.year ?? '', year: year,
dataSource: this.apiName, dataSource: this.apiName,
url: result.url, url: result.url,
id: result.mal_id, id: new_id,
plot: result.synopsis, plot: result.synopsis,
genres: result.genres?.map((x: any) => x.name) ?? [], genres: result.genres?.map(x => x.name).filter(isTruthy),
director: [], studio: result.studios?.map(x => x.name).filter(isTruthy),
writer: [], duration: result.duration,
studio: result.studios?.map((x: any) => x.name).join(', ') ?? 'unknown', onlineRating: result.score,
duration: result.duration ?? 'unknown', image: result.images?.jpg?.image_url,
onlineRating: result.score ?? 0,
actors: [],
image: result.images?.jpg?.image_url ?? '',
released: true, released: true,
premiere: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat) ?? 'unknown', ageRating: result.rating,
streamingServices: result.streaming?.map((x: any) => x.name) ?? [], premiere: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat),
streamingServices: result.streaming?.map(x => x.name).filter(isTruthy),
userData: { userData: {
watched: false, watched: false,
@ -135,24 +162,22 @@ export class MALAPI extends APIModel {
subType: type, subType: type,
title: result.title, title: result.title,
englishTitle: result.title_english ?? result.title, englishTitle: result.title_english ?? result.title,
year: result.year ?? result.aired?.prop?.from?.year ?? '', year: year,
dataSource: this.apiName, dataSource: this.apiName,
url: result.url, url: result.url,
id: result.mal_id, id: new_id,
plot: result.synopsis, plot: result.synopsis,
genres: result.genres?.map((x: any) => x.name) ?? [], genres: result.genres?.map(x => x.name).filter(isTruthy),
director: [], studio: result.studios?.map(x => x.name).filter(isTruthy),
writer: [], duration: result.duration,
studio: result.studios?.map((x: any) => x.name).join(', ') ?? 'unknown', onlineRating: result.score,
duration: result.duration ?? 'unknown', image: result.images?.jpg?.image_url,
onlineRating: result.score ?? 0,
actors: [],
image: result.images?.jpg?.image_url ?? '',
released: true, released: true,
premiere: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat) ?? 'unknown', ageRating: result.rating,
streamingServices: result.streaming?.map((x: any) => x.name) ?? [], premiere: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat),
streamingServices: result.streaming?.map(x => x.name).filter(isTruthy),
userData: { userData: {
watched: false, watched: false,
@ -165,24 +190,24 @@ export class MALAPI extends APIModel {
subType: type, subType: type,
title: result.title, title: result.title,
englishTitle: result.title_english ?? result.title, englishTitle: result.title_english ?? result.title,
year: result.year ?? result.aired?.prop?.from?.year ?? '', year: year,
dataSource: this.apiName, dataSource: this.apiName,
url: result.url, url: result.url,
id: result.mal_id, id: new_id,
plot: result.synopsis, plot: result.synopsis,
genres: result.genres?.map((x: any) => x.name) ?? [], genres: result.genres?.map(x => x.name).filter(isTruthy),
writer: [], studio: result.studios?.map(x => x.name).filter(isTruthy),
studio: result.studios?.map((x: any) => x.name) ?? [],
episodes: result.episodes, episodes: result.episodes,
duration: result.duration ?? 'unknown', duration: result.duration,
onlineRating: result.score ?? 0, onlineRating: result.score,
streamingServices: result.streaming?.map((x: any) => x.name) ?? [], streamingServices: result.streaming?.map(x => x.name).filter(isTruthy),
image: result.images?.jpg?.image_url ?? '', image: result.images?.jpg?.image_url,
released: true, released: true,
airedFrom: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat) ?? 'unknown', ageRating: result.rating,
airedTo: this.plugin.dateFormatter.format(result.aired?.to, this.apiDateFormat) ?? 'unknown', airedFrom: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat),
airedTo: this.plugin.dateFormatter.format(result.aired?.to, this.apiDateFormat),
airing: result.airing, airing: result.airing,
userData: { userData: {
@ -195,4 +220,7 @@ export class MALAPI extends APIModel {
throw new Error(`MDB | Unknown media type for id ${id}`); throw new Error(`MDB | Unknown media type for id ${id}`);
} }
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.MALAPI_disabledMediaTypes;
}
} }

View file

@ -1,12 +1,16 @@
import createClient from 'openapi-fetch';
import { isTruthy, obsidianFetch } from 'src/utils/Utils';
import type MediaDbPlugin from '../../main'; import type MediaDbPlugin from '../../main';
import { ComicMangaModel } from '../../models/ComicMangaModel'; import { ComicMangaModel } from '../../models/ComicMangaModel';
import type { MediaTypeModel } from '../../models/MediaTypeModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { MediaType } from '../../utils/MediaType'; import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel'; import { APIModel } from '../APIModel';
import type { paths } from '../schemas/MALAPI';
export class MALAPIManga extends APIModel { export class MALAPIManga extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
typeMappings: Map<string, string>; typeMappings: Map<string, string>;
apiDateFormat: string = 'YYYY-MM-DDTHH:mm:ssZ'; // ISO
constructor(plugin: MediaDbPlugin) { constructor(plugin: MediaDbPlugin) {
super(); super();
@ -29,43 +33,55 @@ export class MALAPIManga extends APIModel {
async searchByTitle(title: string): Promise<MediaTypeModel[]> { async searchByTitle(title: string): Promise<MediaTypeModel[]> {
console.log(`MDB | api "${this.apiName}" queried by Title`); console.log(`MDB | api "${this.apiName}" queried by Title`);
const searchUrl = `https://api.jikan.moe/v4/manga?q=${encodeURIComponent(title)}&limit=20${this.plugin.settings.sfwFilter ? '&sfw' : ''}`; const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
const fetchData = await fetch(searchUrl); const response = await client.GET('/manga', {
// console.debug(fetchData); params: {
if (fetchData.status !== 200) { query: {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); q: title,
limit: 20,
sfw: this.plugin.settings.sfwFilter ? true : false,
},
},
fetch: obsidianFetch,
});
if (response.error !== undefined) {
throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
} }
const data = await fetchData.json();
// console.debug(data); const data = response.data?.data;
const ret: MediaTypeModel[] = []; const ret: MediaTypeModel[] = [];
for (const result of data.data) { for (const result of data ?? []) {
const type = this.typeMappings.get(result.type?.toLowerCase()); 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( ret.push(
new ComicMangaModel({ new ComicMangaModel({
subType: type, subType: type,
title: result.title, title: result.title,
plot: result.synopsis, plot: result.synopsis ?? undefined,
englishTitle: result.title_english ?? result.title, englishTitle: result.title_english ?? result.title,
alternateTitles: result.titles?.map((x: any) => x.title) ?? [], alternateTitles: result.titles?.map(x => x.title).filter(isTruthy),
year: result.year ?? result.published?.prop?.from?.year ?? '', year: year,
dataSource: this.apiName, dataSource: this.apiName,
url: result.url, url: result.url,
id: result.mal_id, id: id,
genres: result.genres?.map((x: any) => x.name) ?? [], genres: result.genres?.map(x => x.name).filter(isTruthy),
authors: result.authors?.map((x: any) => x.name) ?? [], authors: result.authors?.map(x => x.name).filter(isTruthy),
chapters: result.chapters, chapters: result.chapters,
volumes: result.volumes, volumes: result.volumes,
onlineRating: result.score ?? 0, onlineRating: result.score,
image: result.images?.jpg?.image_url ?? '', image: result.images?.jpg?.image_url,
released: true, released: true,
publishedFrom: new Date(result.published?.from).toLocaleDateString() ?? 'unknown', publishedFrom: this.plugin.dateFormatter.format(result.published?.from, this.apiDateFormat),
publishedTo: new Date(result.published?.to).toLocaleDateString() ?? 'unknown', publishedTo: this.plugin.dateFormatter.format(result.published?.to, this.apiDateFormat),
status: result.status, status: result.status,
userData: { userData: {
@ -83,40 +99,53 @@ export class MALAPIManga extends APIModel {
async getById(id: string): Promise<MediaTypeModel> { async getById(id: string): Promise<MediaTypeModel> {
console.log(`MDB | api "${this.apiName}" queried by ID`); console.log(`MDB | api "${this.apiName}" queried by ID`);
const searchUrl = `https://api.jikan.moe/v4/manga/${encodeURIComponent(id)}/full`; const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
const fetchData = await fetch(searchUrl);
if (fetchData.status !== 200) { const response = await client.GET('/manga/{id}/full', {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); params: {
path: {
id: id as unknown as number, // This is fine
},
},
fetch: obsidianFetch,
});
if (response.error !== undefined) {
throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
} }
const data = await fetchData.json(); const result = response.data?.data;
// console.debug(data);
const result = data.data; if (!result) {
throw Error(`MDB | No data found for ID ${id} in ${this.apiName}.`);
}
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();
const type = this.typeMappings.get(result.type?.toLowerCase());
return new ComicMangaModel({ return new ComicMangaModel({
subType: type, subType: type,
title: result.title, title: result.title,
plot: result.synopsis ?? undefined,
englishTitle: result.title_english ?? result.title, englishTitle: result.title_english ?? result.title,
alternateTitles: result.titles?.map((x: any) => x.title) ?? [], alternateTitles: result.titles?.map(x => x.title).filter(isTruthy),
year: result.year ?? result.published?.prop?.from?.year ?? '', year: year,
dataSource: this.apiName, dataSource: this.apiName,
url: result.url, url: result.url,
id: result.mal_id, id: new_id,
plot: (result.synopsis ?? 'unknown').replace(/"/g, "'") ?? 'unknown', genres: result.genres?.map(x => x.name).filter(isTruthy),
genres: result.genres?.map((x: any) => x.name) ?? [], authors: result.authors?.map(x => x.name).filter(isTruthy),
authors: result.authors?.map((x: any) => x.name) ?? [],
chapters: result.chapters, chapters: result.chapters,
volumes: result.volumes, volumes: result.volumes,
onlineRating: result.score ?? 0, onlineRating: result.score,
image: result.images?.jpg?.image_url ?? '', image: result.images?.jpg?.image_url,
released: true, released: true,
publishers: result.serializations?.map((x: any) => x.name) ?? [], publishedFrom: this.plugin.dateFormatter.format(result.published?.from, this.apiDateFormat),
publishedFrom: new Date(result.published?.from).toLocaleDateString() ?? 'unknown', publishedTo: this.plugin.dateFormatter.format(result.published?.to, this.apiDateFormat),
publishedTo: new Date(result.published?.to).toLocaleDateString() ?? 'unknown',
status: result.status, status: result.status,
userData: { userData: {
@ -126,4 +155,7 @@ export class MALAPIManga extends APIModel {
}, },
}); });
} }
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.MALAPIManga_disabledMediaTypes;
}
} }

View file

@ -1,4 +1,5 @@
import { Notice } from 'obsidian'; /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument */
import { requestUrl } from 'obsidian'; import { requestUrl } from 'obsidian';
import type MediaDbPlugin from '../../main'; import type MediaDbPlugin from '../../main';
import { GameModel } from '../../models/GameModel'; import { GameModel } from '../../models/GameModel';
@ -6,6 +7,10 @@ import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { MediaType } from '../../utils/MediaType'; import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel'; import { APIModel } from '../APIModel';
// sadly no open api schema available
// TODO: maybe we should remove this API, as it can no longer be tested without paying for an API key
export class MobyGamesAPI extends APIModel { export class MobyGamesAPI extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
apiDateFormat: string = 'YYYY-DD-MM'; apiDateFormat: string = 'YYYY-DD-MM';
@ -19,6 +24,7 @@ export class MobyGamesAPI extends APIModel {
this.apiUrl = 'https://api.mobygames.com/v1'; this.apiUrl = 'https://api.mobygames.com/v1';
this.types = [MediaType.Game]; this.types = [MediaType.Game];
} }
async searchByTitle(title: string): Promise<MediaTypeModel[]> { async searchByTitle(title: string): Promise<MediaTypeModel[]> {
console.log(`MDB | api "${this.apiName}" queried by Title`); console.log(`MDB | api "${this.apiName}" queried by Title`);
@ -55,7 +61,7 @@ export class MobyGamesAPI extends APIModel {
year: new Date(result.platforms[0].first_release_date).getFullYear().toString(), year: new Date(result.platforms[0].first_release_date).getFullYear().toString(),
dataSource: this.apiName, dataSource: this.apiName,
id: result.game_id, id: result.game_id,
} as GameModel), }),
); );
} }
@ -107,4 +113,7 @@ export class MobyGamesAPI extends APIModel {
}, },
}); });
} }
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.MobyGamesAPI_disabledMediaTypes;
}
} }

View file

@ -3,11 +3,95 @@ import type MediaDbPlugin from '../../main';
import type { MediaTypeModel } from '../../models/MediaTypeModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { MusicReleaseModel } from '../../models/MusicReleaseModel'; import { MusicReleaseModel } from '../../models/MusicReleaseModel';
import { MediaType } from '../../utils/MediaType'; import { MediaType } from '../../utils/MediaType';
import { contactEmail, mediaDbVersion, pluginName } from '../../utils/Utils'; import { contactEmail, getLanguageName, mediaDbVersion, pluginName } from '../../utils/Utils';
import { APIModel } from '../APIModel'; import { APIModel } from '../APIModel';
// 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 { export class MusicBrainzAPI extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
apiDateFormat: string = 'YYYY-MM-DD';
constructor(plugin: MediaDbPlugin) { constructor(plugin: MediaDbPlugin) {
super(); super();
@ -37,7 +121,9 @@ export class MusicBrainzAPI extends APIModel {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`);
} }
const data = await fetchData.json; const data = (await fetchData.json) as {
'release-groups': SearchResponse[];
};
// console.debug(data); // console.debug(data);
const ret: MediaTypeModel[] = []; const ret: MediaTypeModel[] = [];
@ -48,12 +134,13 @@ export class MusicBrainzAPI extends APIModel {
title: result.title, title: result.title,
englishTitle: result.title, englishTitle: result.title,
year: new Date(result['first-release-date']).getFullYear().toString(), year: new Date(result['first-release-date']).getFullYear().toString(),
releaseDate: this.plugin.dateFormatter.format(result['first-release-date'], this.apiDateFormat) ?? 'unknown',
dataSource: this.apiName, dataSource: this.apiName,
url: 'https://musicbrainz.org/release-group/' + result.id, url: 'https://musicbrainz.org/release-group/' + result.id,
id: result.id, id: result.id,
image: 'https://coverartarchive.org/release-group/' + result.id + '/front', image: 'https://coverartarchive.org/release-group/' + result.id + '/front-500.jpg',
artists: result['artist-credit'].map((a: any) => a.name), artists: result['artist-credit'].map(a => a.name),
subType: result['primary-type'], subType: result['primary-type'],
}), }),
); );
@ -65,33 +152,73 @@ export class MusicBrainzAPI extends APIModel {
async getById(id: string): Promise<MediaTypeModel> { async getById(id: string): Promise<MediaTypeModel> {
console.log(`MDB | api "${this.apiName}" queried by ID`); console.log(`MDB | api "${this.apiName}" queried by ID`);
const searchUrl = `https://musicbrainz.org/ws/2/release-group/${encodeURIComponent(id)}?inc=releases+artists+tags+ratings+genres&fmt=json`; // Fetch release group
const fetchData = await requestUrl({ const groupUrl = `https://musicbrainz.org/ws/2/release-group/${encodeURIComponent(id)}?inc=releases+artists+tags+ratings+genres&fmt=json`;
url: searchUrl, const groupResponse = await requestUrl({
url: groupUrl,
headers: { headers: {
'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`, 'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`,
}, },
}); });
if (fetchData.status !== 200) { if (groupResponse.status !== 200) {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); throw Error(`MDB | Received status code ${groupResponse.status} from ${this.apiName}.`);
} }
const result = await fetchData.json; const result = (await groupResponse.json) as IdResponse;
// Get ID of the first release
const firstRelease = result.releases?.[0];
if (!firstRelease) {
throw Error('MDB | No releases found in release group.');
}
// Fetch recordings for the first release
const releaseUrl = `https://musicbrainz.org/ws/2/release/${firstRelease.id}?inc=recordings+artists&fmt=json`;
console.log(`MDB | Fetching release recordings from: ${releaseUrl}`);
const releaseResponse = await requestUrl({
url: releaseUrl,
headers: {
'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`,
},
});
if (releaseResponse.status !== 200) {
throw Error(`MDB | Received status code ${releaseResponse.status} from ${this.apiName}.`);
}
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);
console.log(releaseData);
return new MusicReleaseModel({ return new MusicReleaseModel({
type: 'musicRelease', type: 'musicRelease',
title: result.title, title: result.title,
englishTitle: result.title, englishTitle: result.title,
year: new Date(result['first-release-date']).getFullYear().toString(), year: new Date(result['first-release-date']).getFullYear().toString(),
releaseDate: this.plugin.dateFormatter.format(result['first-release-date'], this.apiDateFormat) ?? 'unknown',
dataSource: this.apiName, dataSource: this.apiName,
url: 'https://musicbrainz.org/release-group/' + result.id, url: 'https://musicbrainz.org/release-group/' + result.id,
id: result.id, id: result.id,
image: 'https://coverartarchive.org/release-group/' + result.id + '/front', image: 'https://coverartarchive.org/release-group/' + result.id + '/front-500.jpg',
artists: result['artist-credit'].map((a: any) => a.name), artists: result['artist-credit'].map(a => a.name),
genres: result.genres.map((g: any) => g.name), language: releaseData['text-representation'].language ? getLanguageName(releaseData['text-representation'].language) : 'Unknown',
genres: result.genres.map(g => g.name),
subType: result['primary-type'], subType: result['primary-type'],
albumDuration: albumLengthCalc,
trackCount: releaseData.media[0]?.['track-count'] ?? 0,
tracks: tracks,
rating: result.rating.value * 2, rating: result.rating.value * 2,
userData: { userData: {
@ -99,4 +226,36 @@ export class MusicBrainzAPI extends APIModel {
}, },
}); });
} }
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.MusicBrainzAPI_disabledMediaTypes;
}
}
function extractTracksFromMedia(media: MediaResponse['media']): {
number: number;
title: string;
duration: string;
featuredArtists: string[];
}[] {
if (!media || media.length === 0 || !media[0].tracks) return [];
return media[0].tracks.map((track, index) => {
const title = track.title ?? track.recording?.title ?? 'Unknown Title';
const rawLength = track.length ?? track.recording?.length;
const duration = rawLength ? millisecondsToMinutes(rawLength) : 'unknown';
const featuredArtists = track['artist-credit']?.map(ac => ac.name) ?? [];
return {
number: index + 1,
title,
duration,
featuredArtists,
};
});
}
function millisecondsToMinutes(milliseconds: number): string {
const minutes = Math.floor(milliseconds / 60000);
const seconds = Math.floor((milliseconds % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
} }

View file

@ -1,4 +1,4 @@
import { Notice } from 'obsidian'; import { requestUrl } from 'obsidian';
import type MediaDbPlugin from '../../main'; import type MediaDbPlugin from '../../main';
import { GameModel } from '../../models/GameModel'; import { GameModel } from '../../models/GameModel';
import type { MediaTypeModel } from '../../models/MediaTypeModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel';
@ -7,6 +7,54 @@ import { SeriesModel } from '../../models/SeriesModel';
import { MediaType } from '../../utils/MediaType'; import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel'; import { APIModel } from '../APIModel';
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 { export class OMDbAPI extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
typeMappings: Map<string, string>; typeMappings: Map<string, string>;
@ -33,24 +81,30 @@ export class OMDbAPI extends APIModel {
throw new Error(`MDB | API key for ${this.apiName} missing.`); throw new Error(`MDB | API key for ${this.apiName} missing.`);
} }
const searchUrl = `https://www.omdbapi.com/?s=${encodeURIComponent(title)}&apikey=${this.plugin.settings.OMDbKey}`; const response = await requestUrl({
const fetchData = await fetch(searchUrl); url: `https://www.omdbapi.com/?s=${encodeURIComponent(title)}&apikey=${this.plugin.settings.OMDbKey}`,
method: 'GET',
});
if (fetchData.status === 401) { if (response.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
} }
if (fetchData.status !== 200) { if (response.status !== 200) {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); throw Error(`MDB | Received status code ${response.status} from ${this.apiName}.`);
} }
const data = await fetchData.json(); const data = response.json as SearchResponse | undefined;
if (!data) {
throw Error(`MDB | No data received from ${this.apiName}.`);
}
if (data.Response === 'False') { if (data.Response === 'False') {
if (data.Error === 'Movie not found!') { if (data.Error === 'Movie not found!') {
return []; return [];
} }
throw Error(`MDB | Received error from ${this.apiName}: \n${JSON.stringify(data, undefined, 4)}`); throw Error(`MDB | Received error from ${this.apiName}: ${data.Error}`);
} }
if (!data.Search) { if (!data.Search) {
return []; return [];
@ -111,18 +165,23 @@ export class OMDbAPI extends APIModel {
throw Error(`MDB | API key for ${this.apiName} missing.`); throw Error(`MDB | API key for ${this.apiName} missing.`);
} }
const searchUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(id)}&apikey=${this.plugin.settings.OMDbKey}`; const response = await requestUrl({
const fetchData = await fetch(searchUrl); url: `https://www.omdbapi.com/?i=${encodeURIComponent(id)}&apikey=${this.plugin.settings.OMDbKey}`,
method: 'GET',
});
if (fetchData.status === 401) { if (response.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
} }
if (fetchData.status !== 200) { if (response.status !== 200) {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); throw Error(`MDB | Received status code ${response.status} from ${this.apiName}.`);
} }
const result = await fetchData.json(); const result = response.json as IdResponse | undefined;
// console.debug(result);
if (!result) {
throw Error(`MDB | No data received from ${this.apiName}.`);
}
if (result.Response === 'False') { if (result.Response === 'False') {
throw Error(`MDB | Received error from ${this.apiName}: ${result.Error}`); throw Error(`MDB | Received error from ${this.apiName}: ${result.Error}`);
@ -130,7 +189,7 @@ export class OMDbAPI extends APIModel {
const type = this.typeMappings.get(result.Type.toLowerCase()); const type = this.typeMappings.get(result.Type.toLowerCase());
if (type === undefined) { if (type === undefined) {
throw Error(`${result.type.toLowerCase()} is an unsupported type.`); throw Error(`${result.Type.toLowerCase()} is an unsupported type.`);
} }
if (type === 'movie') { if (type === 'movie') {
@ -143,19 +202,20 @@ export class OMDbAPI extends APIModel {
url: `https://www.imdb.com/title/${result.imdbID}/`, url: `https://www.imdb.com/title/${result.imdbID}/`,
id: result.imdbID, id: result.imdbID,
plot: result.Plot ?? '', plot: result.Plot,
genres: result.Genre?.split(', ') ?? [], genres: result.Genre?.split(', '),
director: result.Director?.split(', ') ?? [], director: result.Director?.split(', '),
writer: result.Writer?.split(', ') ?? [], writer: result.Writer?.split(', '),
studio: ['N/A'], duration: result.Runtime,
duration: result.Runtime ?? 'unknown',
onlineRating: Number.parseFloat(result.imdbRating ?? 0), onlineRating: Number.parseFloat(result.imdbRating ?? 0),
actors: result.Actors?.split(', ') ?? [], actors: result.Actors?.split(', '),
image: result.Poster ?? '', image: result.Poster.replace('_SX300', '_SX600'),
released: true, released: true,
streamingServices: [], country: result.Country?.split(', '),
premiere: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat) ?? 'unknown', boxOffice: result.BoxOffice,
ageRating: result.Rated,
premiere: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat),
userData: { userData: {
watched: false, watched: false,
@ -173,21 +233,20 @@ export class OMDbAPI extends APIModel {
url: `https://www.imdb.com/title/${result.imdbID}/`, url: `https://www.imdb.com/title/${result.imdbID}/`,
id: result.imdbID, id: result.imdbID,
plot: result.Plot ?? '', plot: result.Plot,
genres: result.Genre?.split(', ') ?? [], genres: result.Genre?.split(', '),
writer: result.Writer?.split(', ') ?? [], writer: result.Writer?.split(', '),
studio: [], studio: [],
episodes: 0, episodes: 0,
duration: result.Runtime ?? 'unknown', duration: result.Runtime,
onlineRating: Number.parseFloat(result.imdbRating ?? 0), onlineRating: Number.parseFloat(result.imdbRating ?? 0),
actors: result.Actors?.split(', ') ?? [], actors: result.Actors?.split(', '),
image: result.Poster ?? '', image: result.Poster.replace('_SX300', '_SX600'),
released: true, released: true,
streamingServices: [], country: result.Country?.split(', '),
airing: false, ageRating: result.Rated,
airedFrom: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat) ?? 'unknown', airedFrom: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat),
airedTo: 'unknown',
userData: { userData: {
watched: false, watched: false,
@ -205,14 +264,12 @@ export class OMDbAPI extends APIModel {
url: `https://www.imdb.com/title/${result.imdbID}/`, url: `https://www.imdb.com/title/${result.imdbID}/`,
id: result.imdbID, id: result.imdbID,
developers: [], genres: result.Genre?.split(', '),
publishers: [],
genres: result.Genre?.split(', ') ?? [],
onlineRating: Number.parseFloat(result.imdbRating ?? 0), onlineRating: Number.parseFloat(result.imdbRating ?? 0),
image: result.Poster ?? '', image: result.Poster.replace('_SX300', '_SX600'),
released: true, released: true,
releaseDate: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat) ?? 'unknown', releaseDate: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat),
userData: { userData: {
played: false, played: false,
@ -223,4 +280,8 @@ export class OMDbAPI extends APIModel {
throw new Error(`MDB | Unknown media type for id ${id}`); throw new Error(`MDB | Unknown media type for id ${id}`);
} }
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.OMDbAPI_disabledMediaTypes;
}
} }

View file

@ -1,8 +1,34 @@
import createClient from 'openapi-fetch';
import { BookModel } from 'src/models/BookModel'; import { BookModel } from 'src/models/BookModel';
import { obsidianFetch } from 'src/utils/Utils';
import type MediaDbPlugin from '../../main'; import type MediaDbPlugin from '../../main';
import type { MediaTypeModel } from '../../models/MediaTypeModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { MediaType } from '../../utils/MediaType'; import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel'; import { APIModel } from '../APIModel';
import type { paths } from '../schemas/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 { export class OpenLibraryAPI extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
@ -20,14 +46,24 @@ export class OpenLibraryAPI extends APIModel {
async searchByTitle(title: string): Promise<MediaTypeModel[]> { async searchByTitle(title: string): Promise<MediaTypeModel[]> {
console.log(`MDB | api "${this.apiName}" queried by Title`); console.log(`MDB | api "${this.apiName}" queried by Title`);
const searchUrl = `https://openlibrary.org/search.json?title=${encodeURIComponent(title)}`; const client = createClient<paths>({ baseUrl: 'https://openlibrary.org/' });
const fetchData = await fetch(searchUrl); const response = await client.GET('/search.json', {
// console.debug(fetchData); params: {
if (fetchData.status !== 200) { query: {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); q: title,
},
},
fetch: obsidianFetch,
});
if (response.error !== undefined) {
throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
} }
const data = await fetchData.json();
const data = response.data as {
docs: SearchResponse[];
};
// console.debug(data); // console.debug(data);
@ -37,11 +73,11 @@ export class OpenLibraryAPI extends APIModel {
ret.push( ret.push(
new BookModel({ new BookModel({
title: result.title, title: result.title,
englishTitle: result.title_english ?? result.title, englishTitle: result.title,
year: result.first_publish_year, year: result.first_publish_year?.toString() ?? 'unknown',
dataSource: this.apiName, dataSource: this.apiName,
id: result.key, id: result.key,
author: result.author_name ?? 'unknown', author: result.author_name?.join(', '),
}), }),
); );
} }
@ -52,33 +88,63 @@ export class OpenLibraryAPI extends APIModel {
async getById(id: string): Promise<MediaTypeModel> { async getById(id: string): Promise<MediaTypeModel> {
console.log(`MDB | api "${this.apiName}" queried by ID`); console.log(`MDB | api "${this.apiName}" queried by ID`);
const searchUrl = `https://openlibrary.org/search.json?q=key:${encodeURIComponent(id)}`; const client = createClient<paths>({ baseUrl: 'https://openlibrary.org/' });
const fetchData = await fetch(searchUrl);
// console.debug(fetchData);
if (fetchData.status !== 200) { const response = await client.GET('/search.json', {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 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,
});
if (response.error !== undefined) {
throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
} }
const data = await fetchData.json(); const data = response.data as {
// console.debug(data); docs: SearchResponse[];
q?: string;
};
const result = data.docs[0]; const result = data.docs[0];
return new BookModel({ let key = result.key;
title: result.title, let title = result.title;
year: result.first_publish_year, let cover_i = result.cover_i;
dataSource: this.apiName, let isbnArr = result.isbn;
url: `https://openlibrary.org` + result.key,
id: result.key,
isbn: (result.isbn ?? []).find((el: string | any[]) => el.length <= 10) ?? 'unknown',
isbn13: (result.isbn ?? []).find((el: string | any[]) => el.length == 13) ?? 'unknown',
englishTitle: result.title_english ?? result.title,
author: result.author_name ?? 'unknown', // Check if the query is for /isbn/ or /books/ and extract from editions.docs if present
plot: result.description ?? 'unknown', const q = data.q ?? '';
pages: result.number_of_pages_median ?? 'unknown', if ((q.includes('/isbn/') || q.includes('/books/')) && result.editions && Array.isArray(result.editions.docs) && result.editions.docs.length > 0) {
onlineRating: Number.parseFloat(Number(result.ratings_average ?? 0).toFixed(2)), const edition = result.editions.docs[0];
image: `https://covers.openlibrary.org/b/OLID/` + result.cover_edition_key + `-L.jpg`, 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 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, released: true,
@ -89,4 +155,7 @@ export class OpenLibraryAPI extends APIModel {
}, },
}); });
} }
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.OpenLibraryAPI_disabledMediaTypes;
}
} }

View file

@ -3,8 +3,129 @@ import type MediaDbPlugin from '../../main';
import { GameModel } from '../../models/GameModel'; import { GameModel } from '../../models/GameModel';
import type { MediaTypeModel } from '../../models/MediaTypeModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { MediaType } from '../../utils/MediaType'; import { MediaType } from '../../utils/MediaType';
import { imageUrlExists } from '../../utils/Utils';
import { APIModel } from '../APIModel'; import { APIModel } from '../APIModel';
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 { export class SteamAPI extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
typeMappings: Map<string, string>; typeMappings: Map<string, string>;
@ -34,7 +155,7 @@ export class SteamAPI extends APIModel {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`);
} }
const data = await fetchData.json; const data = (await fetchData.json) as SearchResponse[];
// console.debug(data); // console.debug(data);
@ -69,14 +190,13 @@ export class SteamAPI extends APIModel {
} }
// console.debug(await fetchData.json); // console.debug(await fetchData.json);
const data = (await fetchData.json) as IdResponse;
let result: any; let result: GameDetails | undefined = undefined;
for (const [key, value] of Object.entries(await fetchData.json)) { for (const [key, value] of Object.entries(data)) {
// console.log(typeof key, key)
// console.log(typeof id, id)
// after some testing I found out that id is somehow a number despite that it's defined as string... // after some testing I found out that id is somehow a number despite that it's defined as string...
if (key === String(id)) { if (key === String(id)) {
result = (value as any).data; result = value.data;
} }
} }
if (!result) { if (!result) {
@ -85,6 +205,16 @@ export class SteamAPI extends APIModel {
// console.debug(result); // 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 exists = await imageUrlExists(imageUrl);
let finalimageurl;
if (exists) {
finalimageurl = imageUrl;
} else {
finalimageurl = result.header_image ?? '';
}
return new GameModel({ return new GameModel({
type: MediaType.Game, type: MediaType.Game,
title: result.name, title: result.name,
@ -92,16 +222,16 @@ export class SteamAPI extends APIModel {
year: new Date(result.release_date.date).getFullYear().toString(), year: new Date(result.release_date.date).getFullYear().toString(),
dataSource: this.apiName, dataSource: this.apiName,
url: `https://store.steampowered.com/app/${result.steam_appid}`, url: `https://store.steampowered.com/app/${result.steam_appid}`,
id: result.steam_appid, id: result.steam_appid.toString(),
developers: result.developers, developers: result.developers,
publishers: result.publishers, publishers: result.publishers,
genres: result.genres?.map((x: any) => x.description) ?? [], genres: result.genres?.map(x => x.description),
onlineRating: Number.parseFloat(result.metacritic?.score ?? 0), onlineRating: result.metacritic?.score,
image: result.header_image ?? '', image: finalimageurl,
released: !result.release_date?.coming_soon, released: !result.release_date?.coming_soon,
releaseDate: this.plugin.dateFormatter.format(result.release_date?.date, this.apiDateFormat) ?? 'unknown', releaseDate: this.plugin.dateFormatter.format(result.release_date?.date, this.apiDateFormat),
userData: { userData: {
played: false, played: false,
@ -109,4 +239,7 @@ export class SteamAPI extends APIModel {
}, },
}); });
} }
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.SteamAPI_disabledMediaTypes;
}
} }

View file

@ -0,0 +1,158 @@
import createClient from 'openapi-fetch';
import type MediaDbPlugin from '../../main';
import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { MovieModel } from '../../models/MovieModel';
import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel';
import type { paths } from '../schemas/TMDB';
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<MediaTypeModel[]> {
console.log(`MDB | api "${this.apiName}" queried by Title`);
if (!this.plugin.settings.TMDBKey) {
throw new Error(`MDB | API key for ${this.apiName} missing.`);
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const response = await client.GET('/3/search/movie', {
headers: {
Authorization: `Bearer ${this.plugin.settings.TMDBKey}`,
},
params: {
query: {
query: encodeURIComponent(title),
include_adult: this.plugin.settings.sfwFilter ? false : true,
},
},
fetch: fetch,
});
if (response.response.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
}
if (response.response.status !== 200) {
throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
}
const data = response.data;
if (!data) {
throw Error(`MDB | No data received from ${this.apiName}.`);
}
if (data.total_results === 0 || !data.results) {
return [];
}
// 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 ret;
}
async getById(id: string): Promise<MediaTypeModel> {
console.log(`MDB | api "${this.apiName}" queried by ID`);
if (!this.plugin.settings.TMDBKey) {
throw Error(`MDB | API key for ${this.apiName} missing.`);
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const response = await client.GET('/3/movie/{movie_id}', {
headers: {
Authorization: `Bearer ${this.plugin.settings.TMDBKey}`,
},
params: {
path: { movie_id: parseInt(id) },
query: {
append_to_response: 'credits',
},
},
fetch: fetch,
});
if (response.response.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
}
if (response.response.status !== 200) {
throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
}
const result = response.data;
if (!result) {
throw Error(`MDB | No data received from ${this.apiName}.`);
}
// console.debug(result);
return 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: any) => g.name) ?? [],
// TMDB's spec allows for 'append_to_response' but doesn't seem to account for it in the type
// @ts-ignore
writer: result.credits.crew?.filter((c: any) => c.job === 'Screenplay').map((c: any) => c.name) ?? [],
// @ts-ignore
director: result.credits.crew?.filter((c: any) => c.job === 'Director').map((c: any) => c.name) ?? [],
studio: result.production_companies?.map((s: any) => s.name) ?? [],
duration: result.runtime?.toString() ?? 'unknown',
onlineRating: result.vote_average,
// @ts-ignore
actors: result.credits.cast.map((c: any) => c.name).slice(0, 5) ?? [],
image: `https://image.tmdb.org/t/p/w780${result.poster_path}`,
released: ['Released'].includes(result.status!),
streamingServices: [],
userData: {
watched: false,
lastWatched: '',
personalRating: 0,
},
});
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.TMDBMovieAPI_disabledMediaTypes;
}
}

View file

@ -0,0 +1,269 @@
import createClient from 'openapi-fetch';
import type MediaDbPlugin from '../../main';
import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel';
import { SeasonModel } from '../../models/SeasonModel';
import type { paths } from '../schemas/TMDB';
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<MediaTypeModel[]> {
console.log(`MDB | api "${this.apiName}" queried by Title`);
if (!this.plugin.settings.TMDBKey) {
throw new Error(`MDB | API key for ${this.apiName} missing.`);
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const searchResponse = await client.GET('/3/search/tv', {
headers: {
Authorization: `Bearer ${this.plugin.settings.TMDBKey}`,
},
params: {
query: {
query: encodeURIComponent(title),
include_adult: this.plugin.settings.sfwFilter ? false : true,
},
},
fetch: fetch,
});
if (searchResponse.response.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
}
if (searchResponse.response.status !== 200) {
throw Error(`MDB | Received status code ${searchResponse.response.status} from ${this.apiName}.`);
}
const searchData = searchResponse.data;
if (!searchData?.results || searchData.total_results === 0) {
return [];
}
const ret: MediaTypeModel[] = [];
for (const result of searchData.results) {
if (ret.length >= 20) break;
// Fetch series details to get the total number of seasons
let totalSeasons = 0;
try {
const detailsResponse = await client.GET('/3/tv/{series_id}', {
headers: {
Authorization: `Bearer ${this.plugin.settings.TMDBKey}`,
},
params: {
path: { series_id: result.id ?? 0 },
},
fetch: fetch,
});
if (detailsResponse.response.status === 200 && detailsResponse.data) {
const detailsData = detailsResponse.data;
if (Array.isArray(detailsData.seasons)) {
totalSeasons = detailsData.seasons.length;
}
}
} catch {}
ret.push(
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 ret;
}
// Fetch all seasons for a given series
async getSeasonsForSeries(tvId: string): Promise<SeasonModel[]> {
if (!this.plugin.settings.TMDBKey) {
throw new Error(`MDB | API key for ${this.apiName} missing.`);
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const seriesResponse = await client.GET('/3/tv/{series_id}', {
headers: {
Authorization: `Bearer ${this.plugin.settings.TMDBKey}`,
},
params: {
path: { series_id: parseInt(tvId) },
},
fetch: fetch,
});
if (seriesResponse.response.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
}
if (seriesResponse.response.status !== 200) {
throw Error(`MDB | Received status code ${seriesResponse.response.status} from ${this.apiName}.`);
}
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 ret;
}
async getById(id: string): Promise<MediaTypeModel> {
console.log(`MDB | api "${this.apiName}" queried by ID`);
if (!this.plugin.settings.TMDBKey) {
throw Error(`MDB | API key for ${this.apiName} missing.`);
}
// Expect season ids like "12345/season/2"
const m = /^(\d+)\/season\/(\d+)$/.exec(id);
if (!m) {
throw Error(`MDB | Invalid season id "${id}". Expected format "<series_id>/season/<season_number>".`);
}
const tvId = m[1];
const seasonNumber = m[2];
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
// Fetch season details
const seasonResponse = await client.GET('/3/tv/{series_id}/season/{season_number}', {
headers: {
Authorization: `Bearer ${this.plugin.settings.TMDBKey}`,
},
params: {
path: {
series_id: parseInt(tvId),
season_number: parseInt(seasonNumber),
},
},
fetch: fetch,
});
if (seasonResponse.response.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
}
if (seasonResponse.response.status !== 200) {
throw Error(`MDB | Received status code ${seasonResponse.response.status} from ${this.apiName}.`);
}
const seasonData = seasonResponse.data;
if (!seasonData) {
throw Error(`MDB | No data received from ${this.apiName}.`);
}
// Fetch parent series to build consistent titles and inherit fields
const seriesResponse = await client.GET('/3/tv/{series_id}', {
headers: {
Authorization: `Bearer ${this.plugin.settings.TMDBKey}`,
},
params: {
path: { series_id: parseInt(tvId) },
query: {
append_to_response: 'credits',
},
},
fetch: fetch,
});
if (seriesResponse.response.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
}
if (seriesResponse.response.status !== 200) {
throw Error(`MDB | Received status code ${seriesResponse.response.status} from ${this.apiName}.`);
}
const seriesData = seriesResponse.data;
if (!seriesData) {
throw Error(`MDB | No data received from ${this.apiName}.`);
}
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;
}
return new SeasonModel({
title: titleText,
englishTitle: titleText,
year: airDate ? new Date(airDate).getFullYear().toString() : 'unknown',
dataSource: this.apiName,
url: `https://www.themoviedb.org/tv/${tvId}/season/${seasonData.season_number}`,
id: `${tvId}/season/${seasonData.season_number}`,
seasonTitle: seasonData.name ?? titleText,
seasonNumber: seasonData.season_number ?? Number(seasonNumber),
episodes: Array.isArray(seasonData.episodes) ? seasonData.episodes.length : 0,
airedFrom: this.plugin.dateFormatter.format(airDate, this.apiDateFormat) ?? 'unknown',
airedTo: airedTo,
plot: seasonData.overview ?? '',
image: seasonData.poster_path ? `https://image.tmdb.org/t/p/w780${seasonData.poster_path}` : '',
genres: seriesData.genres?.map(g => g.name ?? '').filter(name => name !== '') ?? [],
writer: seriesData.created_by?.map(c => c.name ?? '').filter(name => name !== '') ?? [],
studio: seriesData.production_companies?.map(s => s.name ?? '').filter(name => name !== '') ?? [],
duration: seriesData.episode_run_time?.[0]?.toString() ?? '',
onlineRating: seasonData.vote_average ?? 0,
// @ts-ignore - append_to_response credits not reflected in base schema
actors: seriesData.credits?.cast?.map((c: any) => c.name).slice(0, 5) ?? [],
released: ['Returning Series', 'Cancelled', 'Ended'].includes(seriesData.status ?? ''),
streamingServices: [],
airing: ['Returning Series'].includes(seriesData.status ?? ''),
userData: { watched: false, lastWatched: '', personalRating: 0 },
});
}
getDisabledMediaTypes(): MediaType[] {
return [];
}
}

View file

@ -0,0 +1,157 @@
import createClient from 'openapi-fetch';
import type MediaDbPlugin from '../../main';
import type { MediaTypeModel } from '../../models/MediaTypeModel';
import { SeriesModel } from '../../models/SeriesModel';
import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel';
import type { paths } from '../schemas/TMDB';
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<MediaTypeModel[]> {
console.log(`MDB | api "${this.apiName}" queried by Title`);
if (!this.plugin.settings.TMDBKey) {
throw new Error(`MDB | API key for ${this.apiName} missing.`);
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const response = await client.GET('/3/search/tv', {
headers: {
Authorization: `Bearer ${this.plugin.settings.TMDBKey}`,
},
params: {
query: {
query: encodeURIComponent(title),
include_adult: this.plugin.settings.sfwFilter ? false : true,
},
},
fetch: fetch,
});
if (response.response.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
}
if (response.response.status !== 200) {
throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
}
const data = response.data;
if (!data) {
throw Error(`MDB | No data received from ${this.apiName}.`);
}
if (data.total_results === 0 || !data.results) {
return [];
}
// 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 ret;
}
async getById(id: string): Promise<MediaTypeModel> {
console.log(`MDB | api "${this.apiName}" queried by ID`);
if (!this.plugin.settings.TMDBKey) {
throw Error(`MDB | API key for ${this.apiName} missing.`);
}
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
const response = await client.GET('/3/tv/{series_id}', {
headers: {
Authorization: `Bearer ${this.plugin.settings.TMDBKey}`,
},
params: {
path: { series_id: parseInt(id) },
query: {
append_to_response: 'credits',
},
},
fetch: fetch,
});
if (response.response.status === 401) {
throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
}
if (response.response.status !== 200) {
throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
}
const result = response.data;
if (!result) {
throw Error(`MDB | No data received from ${this.apiName}.`);
}
// console.debug(result);
return 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: any) => g.name) ?? [],
writer: result.created_by?.map((c: any) => c.name) ?? [],
studio: result.production_companies?.map((s: any) => s.name) ?? [],
episodes: result.number_of_episodes,
duration: result.episode_run_time?.[0]?.toString() ?? 'unknown',
onlineRating: result.vote_average,
// TMDB's spec allows for 'append_to_response' but doesn't seem to account for it in the type
// @ts-ignore
actors: result.credits?.cast.map((c: any) => c.name).slice(0, 5) ?? [],
image: result.poster_path ? `https://image.tmdb.org/t/p/w780${result.poster_path}` : null,
released: ['Returning Series', 'Cancelled', 'Ended'].includes(result.status!),
streamingServices: [],
airing: ['Returning Series'].includes(result.status!),
airedFrom: this.plugin.dateFormatter.format(result.first_air_date, this.apiDateFormat) ?? 'unknown',
airedTo: ['Returning Series'].includes(result.status!) ? 'unknown' : (this.plugin.dateFormatter.format(result.last_air_date, this.apiDateFormat) ?? 'unknown'),
userData: {
watched: false,
lastWatched: '',
personalRating: 0,
},
});
}
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.TMDBSeriesAPI_disabledMediaTypes;
}
}

View file

@ -4,6 +4,35 @@ import { WikiModel } from '../../models/WikiModel';
import { MediaType } from '../../utils/MediaType'; import { MediaType } from '../../utils/MediaType';
import { APIModel } from '../APIModel'; import { APIModel } from '../APIModel';
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 { export class WikipediaAPI extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
apiDateFormat: string = 'YYYY-MM-DDTHH:mm:ssZ'; // ISO apiDateFormat: string = 'YYYY-MM-DDTHH:mm:ssZ'; // ISO
@ -29,7 +58,7 @@ export class WikipediaAPI extends APIModel {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`);
} }
const data = await fetchData.json(); const data = (await fetchData.json()) as SearchResponse;
console.debug(data); console.debug(data);
const ret: MediaTypeModel[] = []; const ret: MediaTypeModel[] = [];
@ -41,7 +70,7 @@ export class WikipediaAPI extends APIModel {
englishTitle: result.title, englishTitle: result.title,
year: '', year: '',
dataSource: this.apiName, dataSource: this.apiName,
id: result.pageid, id: result.pageid.toString(),
}), }),
); );
} }
@ -59,24 +88,25 @@ export class WikipediaAPI extends APIModel {
throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`);
} }
const data = await fetchData.json(); const data = (await fetchData.json()) as IdResponse;
// console.debug(data); // console.debug(data);
const result: any = Object.entries(data?.query?.pages)[0][1]; const result = Object.values(data?.query?.pages)[0];
return new WikiModel({ return new WikiModel({
type: 'wiki',
title: result.title, title: result.title,
englishTitle: result.title, englishTitle: result.title,
year: '',
dataSource: this.apiName, dataSource: this.apiName,
url: result.fullurl, url: result.fullurl,
id: result.pageid, id: result.pageid.toString(),
wikiUrl: result.fullurl, wikiUrl: result.fullurl,
lastUpdated: this.plugin.dateFormatter.format(result.touched, this.apiDateFormat) ?? undefined, lastUpdated: this.plugin.dateFormatter.format(result.touched, this.apiDateFormat),
length: result.length, length: result.length,
userData: {}, userData: {},
}); });
} }
getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.WikipediaAPI_disabledMediaTypes;
}
} }

11226
src/api/schemas/GiantBomb.json Normal file

File diff suppressed because it is too large Load diff

6454
src/api/schemas/GiantBomb.ts Normal file

File diff suppressed because it is too large Load diff

6761
src/api/schemas/MALAPI.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,602 @@
{
"components": {
"schemas": {
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"title": "Detail",
"type": "array"
}
},
"title": "HTTPValidationError",
"type": "object"
},
"ValidationError": {
"properties": {
"loc": {
"items": {
"type": "string"
},
"title": "Location",
"type": "array"
},
"msg": {
"title": "Message",
"type": "string"
},
"type": {
"title": "Error Type",
"type": "string"
}
},
"required": ["loc", "msg", "type"],
"title": "ValidationError",
"type": "object"
}
}
},
"info": {
"description": "- These are still in development and may not be perfect\n- Contribute by proposing edits to [openapi.json](https://github.com/internetarchive/openlibrary/blob/master/static/openapi.json)\n- Please do not use our APIs for bulk downloads, see [dev center](https://openlibrary.org/developers/api)",
"title": "Open Library API",
"version": "0.1.0"
},
"openapi": "3.0.2",
"paths": {
"/api/books": {
"get": {
"operationId": "read_api_books_api_books_get",
"parameters": [
{
"examples": {
"isbn": {
"value": "ISBN:0201558025"
},
"multiple": {
"value": "ISBN:9781408113479,OCLC:420517"
},
"oclc": {
"value": "OCLC:263296519"
}
},
"in": "query",
"name": "bibkeys",
"required": true,
"schema": {
"title": "Bibkeys",
"type": "string"
}
},
{
"description": "Specifies the response format. Possible values are json and javascript. When not specified the format is javascript.",
"in": "query",
"name": "format",
"required": false,
"schema": {
"default": "json",
"title": "Format",
"type": "string"
}
},
{
"description": "The name of the JavaScript function to call with the result. This is considered only when the format is javascript.",
"in": "query",
"name": "callback",
"required": false,
"schema": {
"title": "Callback"
}
},
{
"description": "Decides what information to provide for each matched bib_key. Possible values are viewapi and data. The default value is viewapi.",
"in": "query",
"name": "jscmd",
"required": false,
"schema": {
"default": "viewapi",
"title": "Jscmd",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Read Api Books",
"tags": ["books"]
}
},
"/api/volumes/brief/{key_type}/{value}.json": {
"get": {
"operationId": "read_api_volumes_brief_api_volumes_brief__key_type___value__json_get",
"parameters": [
{
"in": "path",
"name": "key_type",
"required": true,
"schema": {
"title": "Key Type"
}
},
{
"in": "path",
"name": "value",
"required": true,
"schema": {
"title": "Value"
}
},
{
"in": "query",
"name": "callback",
"required": false,
"schema": {
"title": "Callback"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Read Api Volumes Brief",
"tags": ["books"]
}
},
"/authors/{olid}.json": {
"get": {
"operationId": "read_authors_authors__olid__json_get",
"parameters": [
{
"in": "path",
"name": "olid",
"required": true,
"schema": {
"title": "Olid"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Read Authors",
"tags": ["authors"]
}
},
"/authors/{olid}/works.json": {
"get": {
"operationId": "read_authors_works_authors__olid__works_json_get",
"parameters": [
{
"in": "path",
"name": "olid",
"required": true,
"schema": {
"title": "Olid"
}
},
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"title": "Limit",
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Read Authors Works",
"tags": ["authors"]
}
},
"/books/{olid}": {
"get": {
"operationId": "read_books_books__olid__get",
"parameters": [
{
"in": "path",
"name": "olid",
"required": true,
"schema": {
"example": "OL53924W",
"title": "Olid"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Read Books",
"tags": ["books"]
}
},
"/covers/{key_type}/{value}-{size}.jpg": {
"get": {
"operationId": "read_covers_key_type_value_size_jpeg_covers__key_type___value___size__jpg_get",
"parameters": [
{
"in": "path",
"name": "key_type",
"required": true,
"schema": {
"title": "Key Type"
}
},
{
"in": "path",
"name": "value",
"required": true,
"schema": {
"title": "Value"
}
},
{
"in": "path",
"name": "size",
"required": true,
"schema": {
"title": "Size"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Read Covers Key Type Value Size Jpeg",
"tags": ["covers"]
}
},
"/isbn/{isbn}": {
"get": {
"operationId": "read_isbn_isbn__isbn__get",
"parameters": [
{
"in": "path",
"name": "isbn",
"required": true,
"schema": {
"title": "Isbn"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Read Isbn",
"tags": ["books"]
}
},
"/search.json": {
"get": {
"operationId": "read_search_json_search_json_get",
"parameters": [
{
"in": "query",
"name": "q",
"required": true,
"schema": {
"title": "Q"
}
},
{
"in": "query",
"name": "page",
"required": false,
"schema": {
"title": "Page",
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Read Search Json",
"tags": ["search"]
}
},
"/search/authors.json": {
"get": {
"operationId": "read_search_authors_json_search_authors_json_get",
"parameters": [
{
"in": "query",
"name": "q",
"required": true,
"schema": {
"title": "Q"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Read Search Authors Json",
"tags": ["search"]
}
},
"/subjects/{subject}.json": {
"get": {
"operationId": "read_subjects_subjects__subject__json_get",
"parameters": [
{
"in": "path",
"name": "subject",
"required": true,
"schema": {
"title": "Subject"
}
},
{
"in": "query",
"name": "details",
"required": false,
"schema": {
"default": false,
"title": "Details",
"type": "boolean"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Read Subjects",
"tags": ["subjects"]
}
},
"/works/{olid}": {
"get": {
"operationId": "read_works_works__olid__get",
"parameters": [
{
"in": "path",
"name": "olid",
"required": true,
"schema": {
"title": "Olid"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Read Works",
"tags": ["books"]
}
}
},
"tags": [
{
"description": "Retrieve a specific work or edition by identifier",
"externalDocs": {
"description": "Find out more",
"url": "https://openlibrary.org/dev/docs/api/books"
},
"name": "books"
},
{
"description": "Retrieve an author and their works by author identifier",
"externalDocs": {
"description": "Find out more",
"url": "https://openlibrary.org/dev/docs/api/authors"
},
"name": "authors"
},
{
"description": "Search results for books, authors, and more",
"externalDocs": {
"description": "Find out more",
"url": "https://openlibrary.org/dev/docs/api/search"
},
"name": "search"
},
{
"description": "Fetch book covers by ISBN or Open Library identifier",
"externalDocs": {
"description": "Find out more",
"url": "https://openlibrary.org/dev/docs/api/covers"
},
"name": "covers"
},
{
"description": "Fetch books by subject name ",
"externalDocs": {
"description": "Find out more",
"url": "https://openlibrary.org/dev/docs/api/subjects"
},
"name": "subjects"
}
]
}

View file

@ -0,0 +1,578 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
'/api/books': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Api Books */
get: operations['read_api_books_api_books_get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/volumes/brief/{key_type}/{value}.json': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Api Volumes Brief */
get: operations['read_api_volumes_brief_api_volumes_brief__key_type___value__json_get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/authors/{olid}.json': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Authors */
get: operations['read_authors_authors__olid__json_get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/authors/{olid}/works.json': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Authors Works */
get: operations['read_authors_works_authors__olid__works_json_get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/books/{olid}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Books */
get: operations['read_books_books__olid__get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/covers/{key_type}/{value}-{size}.jpg': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Covers Key Type Value Size Jpeg */
get: operations['read_covers_key_type_value_size_jpeg_covers__key_type___value___size__jpg_get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/isbn/{isbn}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Isbn */
get: operations['read_isbn_isbn__isbn__get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/search.json': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Search Json */
get: operations['read_search_json_search_json_get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/search/authors.json': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Search Authors Json */
get: operations['read_search_authors_json_search_authors_json_get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/subjects/{subject}.json': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Subjects */
get: operations['read_subjects_subjects__subject__json_get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/works/{olid}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Works */
get: operations['read_works_works__olid__get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
detail?: components['schemas']['ValidationError'][];
};
/** ValidationError */
ValidationError: {
/** Location */
loc: string[];
/** Message */
msg: string;
/** Error Type */
type: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
read_api_books_api_books_get: {
parameters: {
query: {
bibkeys: string;
/** @description Specifies the response format. Possible values are json and javascript. When not specified the format is javascript. */
format?: string;
/** @description The name of the JavaScript function to call with the result. This is considered only when the format is javascript. */
callback?: unknown;
/** @description Decides what information to provide for each matched bib_key. Possible values are viewapi and data. The default value is viewapi. */
jscmd?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
read_api_volumes_brief_api_volumes_brief__key_type___value__json_get: {
parameters: {
query?: {
callback?: unknown;
};
header?: never;
path: {
key_type: unknown;
value: unknown;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
read_authors_authors__olid__json_get: {
parameters: {
query?: never;
header?: never;
path: {
olid: unknown;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
read_authors_works_authors__olid__works_json_get: {
parameters: {
query?: {
limit?: number;
};
header?: never;
path: {
olid: unknown;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
read_books_books__olid__get: {
parameters: {
query?: never;
header?: never;
path: {
olid: unknown;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
read_covers_key_type_value_size_jpeg_covers__key_type___value___size__jpg_get: {
parameters: {
query?: never;
header?: never;
path: {
key_type: unknown;
value: unknown;
size: unknown;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
read_isbn_isbn__isbn__get: {
parameters: {
query?: never;
header?: never;
path: {
isbn: unknown;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
read_search_json_search_json_get: {
parameters: {
query: {
q: unknown;
page?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
read_search_authors_json_search_authors_json_get: {
parameters: {
query: {
q: unknown;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
read_subjects_subjects__subject__json_get: {
parameters: {
query?: {
details?: boolean;
};
header?: never;
path: {
subject: unknown;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
read_works_works__olid__get: {
parameters: {
query?: never;
header?: never;
path: {
olid: unknown;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
}

22832
src/api/schemas/TMDB.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,10 @@
import { MarkdownView, Notice, parseYaml, Plugin, stringifyYaml, TFile, TFolder } from 'obsidian'; import type { TFile } from 'obsidian';
import { MarkdownView, Notice, parseYaml, Plugin, stringifyYaml, TFolder } from 'obsidian';
import { requestUrl, normalizePath } from 'obsidian';
import type { MediaType } from 'src/utils/MediaType'; import type { MediaType } from 'src/utils/MediaType';
import { APIManager } from './api/APIManager'; import { APIManager } from './api/APIManager';
import { BoardGameGeekAPI } from './api/apis/BoardGameGeekAPI'; import { BoardGameGeekAPI } from './api/apis/BoardGameGeekAPI';
import { ComicVineAPI } from './api/apis/ComicVineAPI';
import { GiantBombAPI } from './api/apis/GiantBombAPI'; import { GiantBombAPI } from './api/apis/GiantBombAPI';
import { MALAPI } from './api/apis/MALAPI'; import { MALAPI } from './api/apis/MALAPI';
import { MALAPIManga } from './api/apis/MALAPIManga'; import { MALAPIManga } from './api/apis/MALAPIManga';
@ -10,21 +13,27 @@ import { MusicBrainzAPI } from './api/apis/MusicBrainzAPI';
import { OMDbAPI } from './api/apis/OMDbAPI'; import { OMDbAPI } from './api/apis/OMDbAPI';
import { OpenLibraryAPI } from './api/apis/OpenLibraryAPI'; import { OpenLibraryAPI } from './api/apis/OpenLibraryAPI';
import { SteamAPI } from './api/apis/SteamAPI'; import { SteamAPI } from './api/apis/SteamAPI';
import { TMDBSeriesAPI } from './api/apis/TMDBSeriesAPI';
import { TMDBSeasonAPI } from './api/apis/TMDBSeasonAPI';
import { TMDBMovieAPI } from './api/apis/TMDBMovieAPI';
import { WikipediaAPI } from './api/apis/WikipediaAPI'; import { WikipediaAPI } from './api/apis/WikipediaAPI';
import { ComicVineAPI } from './api/apis/ComicVineAPI'; import { ComicVineAPI } from './api/apis/ComicVineAPI';
import { VNDBAPI } from './api/apis/VNDBAPI'; import { VNDBAPI } from './api/apis/VNDBAPI';
import { MediaDbFolderImportModal } from './modals/MediaDbFolderImportModal'; import { MediaDbFolderImportModal } from './modals/MediaDbFolderImportModal';
import { ConfirmOverwriteModal } from './modals/ConfirmOverwriteModal';
import { MediaDbSeasonSelectModal } from './modals/MediaDbSeasonSelectModal';
import type { MediaTypeModel } from './models/MediaTypeModel'; import type { MediaTypeModel } from './models/MediaTypeModel';
import { PropertyMapper } from './settings/PropertyMapper'; import { PropertyMapper } from './settings/PropertyMapper';
import { PropertyMapping, PropertyMappingModel } from './settings/PropertyMapping'; import { PropertyMapping, PropertyMappingModel } from './settings/PropertyMapping';
import type { MediaDbPluginSettings } from './settings/Settings'; import type { MediaDbPluginSettings } from './settings/Settings';
import { getDefaultSettings, MediaDbSettingTab } from './settings/Settings'; import { getDefaultSettings, MediaDbSettingTab } from './settings/Settings';
import { BulkImportHelper } from './utils/BulkImportHelper';
import { DateFormatter } from './utils/DateFormatter'; import { DateFormatter } from './utils/DateFormatter';
import { MEDIA_TYPES, MediaTypeManager } from './utils/MediaTypeManager'; import { MEDIA_TYPES, MediaTypeManager } from './utils/MediaTypeManager';
import type { SearchModalOptions } from './utils/ModalHelper'; import type { SearchModalOptions } from './utils/ModalHelper';
import { ModalHelper, ModalResultCode } from './utils/ModalHelper'; import { ModalHelper } from './utils/ModalHelper';
import type { CreateNoteOptions } from './utils/Utils'; import type { CreateNoteOptions } from './utils/Utils';
import { dateTimeToString, markdownTable, replaceIllegalFileNameCharactersInString, unCamelCase, hasTemplaterPlugin, useTemplaterPluginInFile } from './utils/Utils'; import { replaceIllegalFileNameCharactersInString, unCamelCase, hasTemplaterPlugin, useTemplaterPluginInFile } from './utils/Utils';
export type Metadata = Record<string, unknown>; export type Metadata = Record<string, unknown>;
@ -40,6 +49,7 @@ export default class MediaDbPlugin extends Plugin {
mediaTypeManager!: MediaTypeManager; mediaTypeManager!: MediaTypeManager;
modelPropertyMapper!: PropertyMapper; modelPropertyMapper!: PropertyMapper;
modalHelper!: ModalHelper; modalHelper!: ModalHelper;
bulkImportHelper!: BulkImportHelper;
dateFormatter!: DateFormatter; dateFormatter!: DateFormatter;
frontMatterRexExpPattern: string = '^(---)\\n[\\s\\S]*?\\n---'; frontMatterRexExpPattern: string = '^(---)\\n[\\s\\S]*?\\n---';
@ -53,17 +63,20 @@ export default class MediaDbPlugin extends Plugin {
this.apiManager.registerAPI(new WikipediaAPI(this)); this.apiManager.registerAPI(new WikipediaAPI(this));
this.apiManager.registerAPI(new MusicBrainzAPI(this)); this.apiManager.registerAPI(new MusicBrainzAPI(this));
this.apiManager.registerAPI(new SteamAPI(this)); this.apiManager.registerAPI(new SteamAPI(this));
this.apiManager.registerAPI(new TMDBSeriesAPI(this));
this.apiManager.registerAPI(new TMDBSeasonAPI(this));
this.apiManager.registerAPI(new TMDBMovieAPI(this));
this.apiManager.registerAPI(new BoardGameGeekAPI(this)); this.apiManager.registerAPI(new BoardGameGeekAPI(this));
this.apiManager.registerAPI(new OpenLibraryAPI(this)); this.apiManager.registerAPI(new OpenLibraryAPI(this));
this.apiManager.registerAPI(new ComicVineAPI(this)); this.apiManager.registerAPI(new ComicVineAPI(this));
this.apiManager.registerAPI(new MobyGamesAPI(this)); this.apiManager.registerAPI(new MobyGamesAPI(this));
this.apiManager.registerAPI(new GiantBombAPI(this)); this.apiManager.registerAPI(new GiantBombAPI(this));
this.apiManager.registerAPI(new VNDBAPI(this)); this.apiManager.registerAPI(new VNDBAPI(this));
// this.apiManager.registerAPI(new LocGovAPI(this)); // TODO: parse data
this.mediaTypeManager = new MediaTypeManager(); this.mediaTypeManager = new MediaTypeManager();
this.modelPropertyMapper = new PropertyMapper(this); this.modelPropertyMapper = new PropertyMapper(this);
this.modalHelper = new ModalHelper(this); this.modalHelper = new ModalHelper(this);
this.bulkImportHelper = new BulkImportHelper(this);
this.dateFormatter = new DateFormatter(); this.dateFormatter = new DateFormatter();
await this.loadSettings(); await this.loadSettings();
@ -84,7 +97,7 @@ export default class MediaDbPlugin extends Plugin {
menu.addItem(item => { menu.addItem(item => {
item.setTitle('Import folder as Media DB entries') item.setTitle('Import folder as Media DB entries')
.setIcon('database') .setIcon('database')
.onClick(() => this.createEntriesFromFolder(file)); .onClick(() => this.bulkImportHelper.import(file));
}); });
} }
}), }),
@ -123,7 +136,7 @@ export default class MediaDbPlugin extends Plugin {
return false; return false;
} }
if (!checking) { if (!checking) {
this.updateActiveNote(false); void this.updateActiveNote(false);
} }
return true; return true;
}, },
@ -136,7 +149,7 @@ export default class MediaDbPlugin extends Plugin {
return false; return false;
} }
if (!checking) { if (!checking) {
this.updateActiveNote(true); void this.updateActiveNote(true);
} }
return true; return true;
}, },
@ -150,19 +163,13 @@ export default class MediaDbPlugin extends Plugin {
return false; return false;
} }
if (!checking) { if (!checking) {
this.createLinkWithSearchModal(); void this.createLinkWithSearchModal();
} }
return true; return true;
}, },
}); });
} }
/**
* first very simple approach
* TODO:
* - replace the detail query
* - maybe custom link syntax
*/
async createLinkWithSearchModal(): Promise<void> { async createLinkWithSearchModal(): Promise<void> {
const apiSearchResults = await this.modalHelper.openAdvancedSearchModal({}, async advancedSearchModalData => { const apiSearchResults = await this.modalHelper.openAdvancedSearchModal({}, async advancedSearchModalData => {
return await this.apiManager.query(advancedSearchModalData.query, advancedSearchModalData.apis); return await this.apiManager.query(advancedSearchModalData.query, advancedSearchModalData.apis);
@ -213,25 +220,110 @@ export default class MediaDbPlugin extends Plugin {
apiSearchResults = apiSearchResults.filter(x => types.contains(x.type)); apiSearchResults = apiSearchResults.filter(x => types.contains(x.type));
let selectResults: MediaTypeModel[]; let selectResults: MediaTypeModel[];
let proceed: boolean = false; const proceed: boolean = false;
while (!proceed) { while (!proceed) {
selectResults = if (types.length === 1 && types[0] === 'season') {
(await this.modalHelper.openSelectModal({ elements: apiSearchResults }, async selectModalData => { selectResults =
return await this.queryDetails(selectModalData.selected); (await this.modalHelper.openSelectModal(
})) ?? []; {
if (!selectResults) { elements: apiSearchResults,
description: 'Select one search result to proceed.',
submitButtonText: 'Ok',
},
async selectModalData => {
return selectModalData.selected;
},
)) ?? [];
} else {
selectResults =
(await this.modalHelper.openSelectModal(
{
elements: apiSearchResults,
},
async selectModalData => {
return await this.queryDetails(selectModalData.selected);
},
)) ?? [];
}
if (!selectResults || selectResults.length < 1) {
return; return;
} }
proceed = await this.modalHelper.openPreviewModal({ elements: selectResults }, async previewModalData => { // Only show the season select modal if the user searches for seasons
if (await this.handleSeasonSelectModal(types, selectResults)) {
return;
}
const confirmed = await this.modalHelper.openPreviewModal({ elements: selectResults }, async previewModalData => {
return previewModalData.confirmed; return previewModalData.confirmed;
}); });
if (!confirmed) {
return;
}
break;
} }
await this.createMediaDbNotes(selectResults!); await this.createMediaDbNotes(selectResults!);
} }
// Season select modal
private async handleSeasonSelectModal(types: string[], selectResults: MediaTypeModel[]): Promise<boolean> {
if (types.length === 1 && types[0] === 'season' && selectResults.length === 1 && selectResults[0].dataSource === 'TMDBSeasonAPI') {
// Use static import for the modal
const tmdbSeasonAPI = this.apiManager.getApiByName('TMDBSeasonAPI') as import('./api/apis/TMDBSeasonAPI').TMDBSeasonAPI;
if (!tmdbSeasonAPI) {
new Notice('TMDBSeasonAPI not found.');
return true;
}
// Fetch all seasons for the selected series
const allSeasons = await tmdbSeasonAPI.getSeasonsForSeries(selectResults[0].id);
if (!allSeasons || allSeasons.length === 0) {
new Notice('No seasons found for this series.');
return true;
}
// Pass the original series title from the search result
const seriesName = selectResults[0]?.englishTitle || selectResults[0]?.title || '';
const modal = new MediaDbSeasonSelectModal(
this,
allSeasons.map(s => ({
season_number: s.seasonNumber,
name: s.seasonTitle || s.title,
episode_count: s.episodes || 0,
air_date: s.year,
poster_path: s.image,
})),
true,
seriesName,
);
const selectedSeasons: any[] = await new Promise(resolve => {
modal.setSubmitCallback(resolve);
modal.open();
});
if (!selectedSeasons || selectedSeasons.length === 0) {
return true;
}
// Fetch full metadata for each selected season and create the note
await Promise.all(
selectedSeasons.map(async season => {
const orig = allSeasons.find(s => s.seasonNumber === season.season_number);
if (orig) {
// Fetch full metadata using getById
const tmdbSeasonAPI = this.apiManager.getApiByName('TMDBSeasonAPI') as import('./api/apis/TMDBSeasonAPI').TMDBSeasonAPI;
if (tmdbSeasonAPI) {
const fullMeta = await tmdbSeasonAPI.getById(orig.id);
await this.createMediaDbNotes([fullMeta]);
} else {
await this.createMediaDbNotes([orig]);
}
}
}),
);
return true;
}
return false;
}
async createEntryWithAdvancedSearchModal(): Promise<void> { async createEntryWithAdvancedSearchModal(): Promise<void> {
const apiSearchResults = await this.modalHelper.openAdvancedSearchModal({}, async advancedSearchModalData => { const apiSearchResults = await this.modalHelper.openAdvancedSearchModal({}, async advancedSearchModalData => {
return await this.apiManager.query(advancedSearchModalData.query, advancedSearchModalData.apis); return await this.apiManager.query(advancedSearchModalData.query, advancedSearchModalData.apis);
@ -243,20 +335,24 @@ export default class MediaDbPlugin extends Plugin {
} }
let selectResults: MediaTypeModel[]; let selectResults: MediaTypeModel[];
let proceed: boolean = false; const proceed: boolean = false;
while (!proceed) { while (!proceed) {
selectResults = selectResults =
(await this.modalHelper.openSelectModal({ elements: apiSearchResults }, async selectModalData => { (await this.modalHelper.openSelectModal({ elements: apiSearchResults }, async selectModalData => {
return await this.queryDetails(selectModalData.selected); return await this.queryDetails(selectModalData.selected);
})) ?? []; })) ?? [];
if (!selectResults) { if (!selectResults || selectResults.length < 1) {
return; return;
} }
proceed = await this.modalHelper.openPreviewModal({ elements: selectResults }, async previewModalData => { const confirmed = await this.modalHelper.openPreviewModal({ elements: selectResults }, async previewModalData => {
return previewModalData.confirmed; return previewModalData.confirmed;
}); });
if (!confirmed) {
return;
}
break;
} }
await this.createMediaDbNotes(selectResults!); await this.createMediaDbNotes(selectResults!);
@ -308,11 +404,13 @@ export default class MediaDbPlugin extends Plugin {
options.openNote = this.settings.openNoteInNewTab; options.openNote = this.settings.openNoteInNewTab;
if (this.settings.imageDownload) {
await this.downloadImageForMediaModel(mediaTypeModel);
}
const fileContent = await this.generateMediaDbNoteContents(mediaTypeModel, options); const fileContent = await this.generateMediaDbNoteContents(mediaTypeModel, options);
if (!options.folder) { options.folder ??= await this.mediaTypeManager.getFolder(mediaTypeModel, this.app);
options.folder = await this.mediaTypeManager.getFolder(mediaTypeModel, this.app);
}
const targetFile = await this.createNote(this.mediaTypeManager.getFileName(mediaTypeModel), fileContent, options); const targetFile = await this.createNote(this.mediaTypeManager.getFileName(mediaTypeModel), fileContent, options);
@ -325,6 +423,40 @@ export default class MediaDbPlugin extends Plugin {
} }
} }
/**
* Tries to download the image for a media model.
*
* @param mediaTypeModel
* @returns true if the image was downloaded, false otherwise
*/
private async downloadImageForMediaModel(mediaTypeModel: MediaTypeModel): Promise<boolean> {
if (mediaTypeModel.image && typeof mediaTypeModel.image === 'string' && mediaTypeModel.image.startsWith('http')) {
try {
const imageUrl = mediaTypeModel.image;
const imageExt = imageUrl.split('.').pop()?.split(/#|\?/)[0] ?? 'jpg';
const imageFileName = `${replaceIllegalFileNameCharactersInString(`${mediaTypeModel.type}_${mediaTypeModel.title} (${mediaTypeModel.year})`)}.${imageExt}`;
const imagePath = normalizePath(`${this.settings.imageFolder}/${imageFileName}`);
if (!this.app.vault.getAbstractFileByPath(this.settings.imageFolder)) {
await this.app.vault.createFolder(this.settings.imageFolder);
}
if (!this.app.vault.getAbstractFileByPath(imagePath)) {
const response = await requestUrl({ url: imageUrl, method: 'GET' });
await this.app.vault.createBinary(imagePath, response.arrayBuffer);
}
// Update model to use local image path
mediaTypeModel.image = `[[${imagePath}]]`;
return true;
} catch (e) {
console.warn('MDB | Failed to download image:', e);
}
}
return false;
}
generateMediaDbNoteFrontmatterPreview(mediaTypeModel: MediaTypeModel): string { generateMediaDbNoteFrontmatterPreview(mediaTypeModel: MediaTypeModel): string {
const fileMetadata = this.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject()); const fileMetadata = this.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject());
return stringifyYaml(fileMetadata); return stringifyYaml(fileMetadata);
@ -337,18 +469,7 @@ export default class MediaDbPlugin extends Plugin {
* @param options * @param options
*/ */
async generateMediaDbNoteContents(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise<string> { async generateMediaDbNoteContents(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise<string> {
const template = await this.mediaTypeManager.getTemplate(mediaTypeModel, this.app); let template = await this.mediaTypeManager.getTemplate(mediaTypeModel, this.app);
return this.generateContentWithDefaultFrontMatter(mediaTypeModel, options, template);
// if (this.settings.useDefaultFrontMatter || !template) {
// return this.generateContentWithDefaultFrontMatter(mediaTypeModel, options, template);
// } else {
// return this.generateContentWithCustomFrontMatter(mediaTypeModel, options, template);
// }
}
async generateContentWithDefaultFrontMatter(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions, template?: string): Promise<string> {
let fileMetadata: Record<string, unknown>; let fileMetadata: Record<string, unknown>;
if (this.settings.useDefaultFrontMatter) { if (this.settings.useDefaultFrontMatter) {
@ -377,62 +498,12 @@ export default class MediaDbPlugin extends Plugin {
return fileContent; return fileContent;
} }
async generateContentWithCustomFrontMatter(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions, template: string): Promise<string> {
const regExp = new RegExp(this.frontMatterRexExpPattern);
const frontMatter = this.getMetaDataFromFileContent(template);
let fileContent: string = template.replace(regExp, '');
// Updating a previous file
if (options.attachFile) {
const previousMetadata = this.app.metadataCache.getFileCache(options.attachFile)?.frontmatter ?? {};
// Use contents (below front matter) from previous file
fileContent = await this.app.vault.read(options.attachFile);
fileContent = fileContent.replace(regExp, '');
fileContent = fileContent.startsWith('\n') ? fileContent.substring(1) : fileContent;
// Update updated front matter with entries from the old front matter, if it isn't defined in the new front matter
Object.keys(previousMetadata).forEach(key => {
const value = previousMetadata[key];
if (!frontMatter[key] && value) {
frontMatter[key] = value;
}
});
}
// Ensure that id, type, and dataSource are defined
if (!frontMatter.id) {
frontMatter.id = mediaTypeModel.id;
}
if (!frontMatter.type) {
frontMatter.type = mediaTypeModel.type;
}
if (!frontMatter.dataSource) {
frontMatter.dataSource = mediaTypeModel.dataSource;
}
if (this.settings.enableTemplaterIntegration && hasTemplaterPlugin(this.app)) {
// Only support stringifyYaml for templater plugin
// Include the media variable in all templater commands by using a top level JavaScript execution command.
fileContent = `---\n<%* const media = ${JSON.stringify(mediaTypeModel)} %>\n${stringifyYaml(frontMatter)}---\n${fileContent}`;
} else {
fileContent = `---\n${stringifyYaml(frontMatter)}---\n${fileContent}`;
}
return fileContent;
}
async attachFile(fileMetadata: Metadata, fileContent: string, fileToAttach?: TFile): Promise<{ fileMetadata: Metadata; fileContent: string }> { async attachFile(fileMetadata: Metadata, fileContent: string, fileToAttach?: TFile): Promise<{ fileMetadata: Metadata; fileContent: string }> {
if (!fileToAttach) { if (!fileToAttach) {
return { fileMetadata: fileMetadata, fileContent: fileContent }; return { fileMetadata: fileMetadata, fileContent: fileContent };
} }
const attachFileMetadata: any = this.getMetadataFromFileCache(fileToAttach); const attachFileMetadata = this.getMetadataFromFileCache(fileToAttach);
// TODO: better object merging // TODO: better object merging
fileMetadata = Object.assign(attachFileMetadata, fileMetadata); fileMetadata = Object.assign(attachFileMetadata, fileMetadata);
@ -450,7 +521,7 @@ export default class MediaDbPlugin extends Plugin {
return { fileMetadata: fileMetadata, fileContent: fileContent }; return { fileMetadata: fileMetadata, fileContent: fileContent };
} }
const templateMetadata: Metadata = this.getMetaDataFromFileContent(template); const templateMetadata = this.getMetaDataFromFileContent(template);
// TODO: better object merging // TODO: better object merging
fileMetadata = Object.assign(templateMetadata, fileMetadata); fileMetadata = Object.assign(templateMetadata, fileMetadata);
@ -476,7 +547,7 @@ export default class MediaDbPlugin extends Plugin {
frontMatter = frontMatter.substring(4); frontMatter = frontMatter.substring(4);
frontMatter = frontMatter.substring(0, frontMatter.length - 3); frontMatter = frontMatter.substring(0, frontMatter.length - 3);
metadata = parseYaml(frontMatter); metadata = parseYaml(frontMatter) as Metadata;
if (!metadata) { if (!metadata) {
metadata = {}; metadata = {};
@ -510,9 +581,17 @@ export default class MediaDbPlugin extends Plugin {
fileName = replaceIllegalFileNameCharactersInString(fileName); fileName = replaceIllegalFileNameCharactersInString(fileName);
const filePath = `${folder.path}/${fileName}.md`; const filePath = `${folder.path}/${fileName}.md`;
// find and delete file with the same name // look if file already exists and ask if it should be overwritten
const file = this.app.vault.getAbstractFileByPath(filePath); const file = this.app.vault.getAbstractFileByPath(filePath);
if (file) { if (file) {
const shouldOverwrite = await new Promise<boolean>(resolve => {
new ConfirmOverwriteModal(this.app, fileName, resolve).open();
});
if (!shouldOverwrite) {
throw new Error('MDB | file creation cancelled by user');
}
await this.app.vault.delete(file); await this.app.vault.delete(file);
} }
@ -520,7 +599,7 @@ export default class MediaDbPlugin extends Plugin {
const targetFile = await this.app.vault.create(filePath, fileContent); const targetFile = await this.app.vault.create(filePath, fileContent);
console.debug(`MDB | created new file at ${filePath}`); console.debug(`MDB | created new file at ${filePath}`);
// open newly crated file // open newly created file
if (options.openNote) { if (options.openNote) {
const activeLeaf = this.app.workspace.getUnpinnedLeaf(); const activeLeaf = this.app.workspace.getUnpinnedLeaf();
if (!activeLeaf) { if (!activeLeaf) {
@ -573,95 +652,9 @@ export default class MediaDbPlugin extends Plugin {
} }
} }
async createEntriesFromFolder(folder: TFolder): Promise<void> {
const erroredFiles: { filePath: string; error: string }[] = [];
let canceled: boolean = false;
const { selectedAPI, titleFieldName, appendContent } = await new Promise<{ selectedAPI: string; titleFieldName: string; appendContent: boolean }>(resolve => {
new MediaDbFolderImportModal(this.app, this, (selectedAPI: string, titleFieldName: string, appendContent: boolean) => {
resolve({ selectedAPI, titleFieldName, appendContent });
}).open();
});
for (const child of folder.children) {
if (child instanceof TFile) {
const file: TFile = child;
if (canceled) {
erroredFiles.push({ filePath: file.path, error: 'user canceled' });
continue;
}
const metadata: any = this.getMetadataFromFileCache(file);
const title = metadata[titleFieldName];
if (!title) {
erroredFiles.push({ filePath: file.path, error: `metadata field '${titleFieldName}' not found or empty` });
continue;
}
let results: MediaTypeModel[] = [];
try {
results = await this.apiManager.query(title, [selectedAPI]);
} catch (e) {
erroredFiles.push({ filePath: file.path, error: `${e}` });
continue;
}
if (!results || results.length === 0) {
erroredFiles.push({ filePath: file.path, error: `no search results` });
continue;
}
const { selectModalResult, selectModal } = await this.modalHelper.createSelectModal({ elements: results, skipButton: true, modalTitle: `Results for '${title}'` });
if (selectModalResult.code === ModalResultCode.ERROR) {
erroredFiles.push({ filePath: file.path, error: selectModalResult.error.message });
selectModal.close();
continue;
}
if (selectModalResult.code === ModalResultCode.CLOSE) {
erroredFiles.push({ filePath: file.path, error: 'user canceled' });
selectModal.close();
canceled = true;
continue;
}
if (selectModalResult.code === ModalResultCode.SKIP) {
erroredFiles.push({ filePath: file.path, error: 'user skipped' });
selectModal.close();
continue;
}
if (selectModalResult.data.selected.length === 0) {
erroredFiles.push({ filePath: file.path, error: `no search results selected` });
continue;
}
const detailedResults = await this.queryDetails(selectModalResult.data.selected);
await this.createMediaDbNotes(detailedResults, appendContent ? file : undefined);
selectModal.close();
}
}
if (erroredFiles.length > 0) {
await this.createErroredFilesReport(erroredFiles);
}
}
async createErroredFilesReport(erroredFiles: { filePath: string; error: string }[]): Promise<void> {
const title = `MDB - bulk import error report ${dateTimeToString(new Date())}`;
const filePath = `${title}.md`;
const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error]));
const fileContent = `# ${title}\n\n${markdownTable(table)}`;
await this.app.vault.create(filePath, fileContent);
}
async loadSettings(): Promise<void> { async loadSettings(): Promise<void> {
// console.log(DEFAULT_SETTINGS); // console.log(DEFAULT_SETTINGS);
const diskSettings: MediaDbPluginSettings = await this.loadData(); const diskSettings: MediaDbPluginSettings = (await this.loadData()) as MediaDbPluginSettings;
const defaultSettings: MediaDbPluginSettings = getDefaultSettings(this); const defaultSettings: MediaDbPluginSettings = getDefaultSettings(this);
const loadedSettings: MediaDbPluginSettings = Object.assign({}, defaultSettings, diskSettings); const loadedSettings: MediaDbPluginSettings = Object.assign({}, defaultSettings, diskSettings);
@ -683,7 +676,9 @@ export default class MediaDbPlugin extends Plugin {
newProperties.push(defaultProperty); newProperties.push(defaultProperty);
} else { } else {
// newProperty is just an object and take locked status from default property // newProperty is just an object and take locked status from default property
newProperties.push(new PropertyMapping(newProperty.property, newProperty.newProperty, newProperty.mapping, defaultProperty.locked)); newProperties.push(
new PropertyMapping(newProperty.property, newProperty.newProperty, newProperty.mapping, defaultProperty.locked, newProperty.wikilink ?? false),
);
} }
} }

View file

@ -0,0 +1,44 @@
import type { App } from 'obsidian';
import { Modal, Setting } from 'obsidian';
export class ConfirmOverwriteModal extends Modal {
result: boolean = false;
onSubmit: (result: boolean) => void;
fileName: string;
constructor(app: App, fileName: string, onSubmit: (result: boolean) => void) {
super(app);
this.fileName = fileName;
this.onSubmit = onSubmit;
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl('h2', { text: 'File already exists' });
contentEl.createEl('p', { text: `The file "${this.fileName}" already exists. Do you want to overwrite it?` });
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
const bottomSettingRow = new Setting(contentEl);
bottomSettingRow.addButton(btn => {
btn.setButtonText('No');
btn.onClick(() => this.close());
btn.buttonEl.addClass('media-db-plugin-button');
});
bottomSettingRow.addButton(btn => {
btn.setButtonText('Yes');
btn.setCta();
btn.onClick(() => {
this.result = true;
this.close();
});
btn.buttonEl.addClass('media-db-plugin-button');
});
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
this.onSubmit(this.result);
}
}

View file

@ -1,7 +1,6 @@
import type { ButtonComponent } from 'obsidian'; import type { ButtonComponent } from 'obsidian';
import { Modal, Notice, Setting, TextComponent, ToggleComponent } from 'obsidian'; import { Modal, Notice, Setting, TextComponent, ToggleComponent } from 'obsidian';
import type MediaDbPlugin from '../main'; import type MediaDbPlugin from '../main';
import type { MediaTypeModel } from '../models/MediaTypeModel';
import type { AdvancedSearchModalData, AdvancedSearchModalOptions } from '../utils/ModalHelper'; import type { AdvancedSearchModalData, AdvancedSearchModalOptions } from '../utils/ModalHelper';
import { ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper'; import { ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper';
@ -39,7 +38,7 @@ export class MediaDbAdvancedSearchModal extends Modal {
keyPressCallback(event: KeyboardEvent): void { keyPressCallback(event: KeyboardEvent): void {
if (event.key === 'Enter') { if (event.key === 'Enter') {
this.search(); void this.search();
} }
} }
@ -119,7 +118,7 @@ export class MediaDbAdvancedSearchModal extends Modal {
btn.setButtonText('Ok'); btn.setButtonText('Ok');
btn.setCta(); btn.setCta();
btn.onClick(() => { btn.onClick(() => {
this.search(); void this.search();
}); });
btn.buttonEl.addClass('media-db-plugin-button'); btn.buttonEl.addClass('media-db-plugin-button');
this.searchBtn = btn; this.searchBtn = btn;

View file

@ -0,0 +1,134 @@
import type { ButtonComponent } from 'obsidian';
import { DropdownComponent, Modal, Setting, TextComponent, ToggleComponent } from 'obsidian';
import type { APIModel } from 'src/api/APIModel';
import { BulkImportLookupMethod } from 'src/utils/BulkImportHelper';
import type MediaDbPlugin from '../main';
export class MediaDbBulkImportModal extends Modal {
plugin: MediaDbPlugin;
onSubmit: (selectedAPI: string, lookupMethod: BulkImportLookupMethod, fieldName: string, appendContent: boolean) => void;
selectedApi: string;
searchBtn?: ButtonComponent;
lookupMethod: BulkImportLookupMethod;
fieldName: string;
appendContent: boolean;
constructor(plugin: MediaDbPlugin, onSubmit: (selectedAPI: string, lookupMethod: BulkImportLookupMethod, fieldName: string, appendContent: boolean) => void) {
super(plugin.app);
this.plugin = plugin;
this.onSubmit = onSubmit;
this.selectedApi = plugin.apiManager.apis[0].apiName;
this.lookupMethod = BulkImportLookupMethod.TITLE;
this.fieldName = '';
this.appendContent = false;
}
submit(): void {
this.onSubmit(this.selectedApi, this.lookupMethod, this.fieldName, this.appendContent);
this.close();
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl('h2', { text: 'Import folder as Media DB entries' });
this.createDropdownEl(
contentEl,
'API to search',
(value: string) => {
this.selectedApi = value;
},
this.plugin.apiManager.apis.map((api: APIModel) => {
return { value: api.apiName, display: api.apiName };
}),
);
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
contentEl.createEl('h3', { text: 'Append note content to Media DB entry?' });
const appendContentToggleElementWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' });
const appendContentToggleTextWrapper = appendContentToggleElementWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' });
appendContentToggleTextWrapper.createEl('span', {
text: 'If this is enabled, the plugin will override metadata fields with the same name.',
cls: 'media-db-plugin-list-text',
});
const appendContentToggleComponentWrapper = appendContentToggleElementWrapper.createEl('div', { cls: 'media-db-plugin-list-toggle' });
const appendContentToggle = new ToggleComponent(appendContentToggleElementWrapper);
appendContentToggle.setValue(false);
appendContentToggle.onChange(value => (this.appendContent = value));
appendContentToggleComponentWrapper.appendChild(appendContentToggle.toggleEl);
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
contentEl.createEl('h3', { text: 'Media lookup method' });
contentEl.createEl('p', {
text: 'Choose whether to search the API by title (can return multiple results) or lookup directly using an ID (returns at most one result), and specify the name of the frontmatter property which contains the title or ID of the media.',
});
this.createDropdownEl(
contentEl,
'Lookup media by',
(value: string) => {
this.lookupMethod = value as BulkImportLookupMethod;
},
[
{ value: BulkImportLookupMethod.TITLE, display: 'Title' },
{ value: BulkImportLookupMethod.ID, display: 'ID' },
],
);
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
const fieldNameWrapperEl = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' });
const fieldNameLabelWrapperEl = fieldNameWrapperEl.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' });
fieldNameLabelWrapperEl.createEl('span', { text: 'Using the property named', cls: 'media-db-plugin-list-text' });
const fieldNameComponent = new TextComponent(fieldNameWrapperEl);
fieldNameComponent.setPlaceholder('title / id');
fieldNameComponent.onChange(value => (this.fieldName = value));
fieldNameComponent.inputEl.addEventListener('keydown', ke => {
if (ke.key === 'Enter') {
this.submit();
}
});
contentEl.appendChild(fieldNameWrapperEl);
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
new Setting(contentEl)
.addButton(btn => {
btn.setButtonText('Cancel');
btn.onClick(() => this.close());
btn.buttonEl.addClass('media-db-plugin-button');
})
.addButton(btn => {
btn.setButtonText('Ok');
btn.setCta();
btn.onClick(() => {
this.submit();
});
btn.buttonEl.addClass('media-db-plugin-button');
this.searchBtn = btn;
});
}
createDropdownEl(parentEl: HTMLElement, label: string, onChange: (value: string) => void, options: { value: string; display: string }[]): void {
const wrapperEl = parentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' });
const labelWrapperEl = wrapperEl.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' });
labelWrapperEl.createEl('span', { text: label, cls: 'media-db-plugin-list-text' });
const dropDownComponent = new DropdownComponent(wrapperEl);
dropDownComponent.onChange(onChange);
for (const option of options) {
dropDownComponent.addOption(option.value, option.display);
}
wrapperEl.appendChild(dropDownComponent.selectEl);
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -1,100 +0,0 @@
import type { App, ButtonComponent } from 'obsidian';
import { DropdownComponent, Modal, Setting, TextComponent, ToggleComponent } from 'obsidian';
import type MediaDbPlugin from '../main';
export class MediaDbFolderImportModal extends Modal {
plugin: MediaDbPlugin;
onSubmit: (selectedAPI: string, titleFieldName: string, appendContent: boolean) => void;
selectedApi: string;
searchBtn?: ButtonComponent;
titleFieldName: string;
appendContent: boolean;
constructor(app: App, plugin: MediaDbPlugin, onSubmit: (selectedAPI: string, titleFieldName: string, appendContent: boolean) => void) {
super(app);
this.plugin = plugin;
this.onSubmit = onSubmit;
this.selectedApi = plugin.apiManager.apis[0].apiName;
this.titleFieldName = '';
this.appendContent = false;
}
submit(): void {
this.onSubmit(this.selectedApi, this.titleFieldName, this.appendContent);
this.close();
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl('h2', { text: 'Import folder as Media DB entries' });
const apiSelectorWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' });
const apiSelectorTextWrapper = apiSelectorWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' });
apiSelectorTextWrapper.createEl('span', { text: 'API to search', cls: 'media-db-plugin-list-text' });
const apiSelectorComponent = new DropdownComponent(apiSelectorWrapper);
apiSelectorComponent.onChange((value: string) => {
this.selectedApi = value;
});
for (const api of this.plugin.apiManager.apis) {
apiSelectorComponent.addOption(api.apiName, api.apiName);
}
apiSelectorWrapper.appendChild(apiSelectorComponent.selectEl);
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
contentEl.createEl('h3', { text: 'Append note content to Media DB entry.' });
const appendContentToggleElementWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' });
const appendContentToggleTextWrapper = appendContentToggleElementWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' });
appendContentToggleTextWrapper.createEl('span', {
text: 'If this is enabled, the plugin will override metadata fields with the same name.',
cls: 'media-db-plugin-list-text',
});
const appendContentToggleComponentWrapper = appendContentToggleElementWrapper.createEl('div', { cls: 'media-db-plugin-list-toggle' });
const appendContentToggle = new ToggleComponent(appendContentToggleElementWrapper);
appendContentToggle.setValue(false);
appendContentToggle.onChange(value => (this.appendContent = value));
appendContentToggleComponentWrapper.appendChild(appendContentToggle.toggleEl);
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
contentEl.createEl('h3', { text: 'The name of the metadata field that should be used as the title to query.' });
const placeholder = 'title';
const titleFieldNameComponent = new TextComponent(contentEl);
titleFieldNameComponent.inputEl.style.width = '100%';
titleFieldNameComponent.setPlaceholder(placeholder);
titleFieldNameComponent.onChange(value => (this.titleFieldName = value));
titleFieldNameComponent.inputEl.addEventListener('keydown', ke => {
if (ke.key === 'Enter') {
this.submit();
}
});
contentEl.appendChild(titleFieldNameComponent.inputEl);
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
new Setting(contentEl)
.addButton(btn => {
btn.setButtonText('Cancel');
btn.onClick(() => this.close());
btn.buttonEl.addClass('media-db-plugin-button');
})
.addButton(btn => {
btn.setButtonText('Ok');
btn.setCta();
btn.onClick(() => {
this.submit();
});
btn.buttonEl.addClass('media-db-plugin-button');
this.searchBtn = btn;
});
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -1,7 +1,6 @@
import type { ButtonComponent } from 'obsidian'; import type { ButtonComponent } from 'obsidian';
import { DropdownComponent, Modal, Notice, Setting, TextComponent } from 'obsidian'; import { DropdownComponent, Modal, Notice, Setting, TextComponent } from 'obsidian';
import type MediaDbPlugin from '../main'; import type MediaDbPlugin from '../main';
import type { MediaTypeModel } from '../models/MediaTypeModel';
import type { IdSearchModalData, IdSearchModalOptions } from '../utils/ModalHelper'; import type { IdSearchModalData, IdSearchModalOptions } from '../utils/ModalHelper';
import { ID_SEARCH_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper'; import { ID_SEARCH_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper';
@ -24,7 +23,7 @@ export class MediaDbIdSearchModal extends Modal {
this.plugin = plugin; this.plugin = plugin;
this.title = idSearchModalOptions.modalTitle ?? ''; this.title = idSearchModalOptions.modalTitle ?? '';
this.selectedApi = idSearchModalOptions.preselectedAPI || plugin.apiManager.apis[0].apiName; this.selectedApi = idSearchModalOptions.preselectedAPI ?? plugin.apiManager.apis[0].apiName;
this.query = ''; this.query = '';
this.isBusy = false; this.isBusy = false;
} }
@ -39,7 +38,7 @@ export class MediaDbIdSearchModal extends Modal {
keyPressCallback(event: KeyboardEvent): void { keyPressCallback(event: KeyboardEvent): void {
if (event.key === 'Enter') { if (event.key === 'Enter') {
this.search(); void this.search();
} }
} }
@ -105,7 +104,7 @@ export class MediaDbIdSearchModal extends Modal {
btn.setButtonText('Ok'); btn.setButtonText('Ok');
btn.setCta(); btn.setCta();
btn.onClick(() => { btn.onClick(() => {
this.search(); void this.search();
}); });
btn.buttonEl.addClass('media-db-plugin-button'); btn.buttonEl.addClass('media-db-plugin-button');
this.searchBtn = btn; this.searchBtn = btn;

View file

@ -1,4 +1,3 @@
import type { ButtonComponent } from 'obsidian';
import { Component, MarkdownRenderer, Modal, Setting } from 'obsidian'; import { Component, MarkdownRenderer, Modal, Setting } from 'obsidian';
import type MediaDbPlugin from 'src/main'; import type MediaDbPlugin from 'src/main';
import type { MediaTypeModel } from 'src/models/MediaTypeModel'; import type { MediaTypeModel } from 'src/models/MediaTypeModel';

View file

@ -1,7 +1,6 @@
import type { ButtonComponent } from 'obsidian'; import type { ButtonComponent } from 'obsidian';
import { Modal, Notice, Setting, TextComponent, ToggleComponent } from 'obsidian'; import { Modal, Notice, Setting, TextComponent, ToggleComponent } from 'obsidian';
import type MediaDbPlugin from '../main'; import type MediaDbPlugin from '../main';
import type { MediaTypeModel } from '../models/MediaTypeModel';
import type { MediaType } from '../utils/MediaType'; import type { MediaType } from '../utils/MediaType';
import { MEDIA_TYPES } from '../utils/MediaTypeManager'; import { MEDIA_TYPES } from '../utils/MediaTypeManager';
import type { SearchModalData, SearchModalOptions } from '../utils/ModalHelper'; import type { SearchModalData, SearchModalOptions } from '../utils/ModalHelper';
@ -42,7 +41,7 @@ export class MediaDbSearchModal extends Modal {
keyPressCallback(event: KeyboardEvent): void { keyPressCallback(event: KeyboardEvent): void {
if (event.key === 'Enter') { if (event.key === 'Enter') {
this.search(); void this.search();
} }
} }
@ -131,7 +130,7 @@ export class MediaDbSearchModal extends Modal {
btn.setButtonText('Ok'); btn.setButtonText('Ok');
btn.setCta(); btn.setCta();
btn.onClick(() => { btn.onClick(() => {
this.search(); void this.search();
}); });
btn.buttonEl.addClass('media-db-plugin-button'); btn.buttonEl.addClass('media-db-plugin-button');
this.searchBtn = btn; this.searchBtn = btn;

View file

@ -1,7 +1,7 @@
import type MediaDbPlugin from '../main'; import type MediaDbPlugin from '../main';
import type { MediaTypeModel } from '../models/MediaTypeModel'; import type { MediaTypeModel } from '../models/MediaTypeModel';
import type { SelectModalData, SelectModalOptions } from '../utils/ModalHelper'; import type { SelectModalData, SelectModalOptions } from '../utils/ModalHelper';
import { SELECT_MODAL_OPTIONS_DEFAULT } from '../utils/ModalHelper'; import { SELECTMODALOPTIONSDEFAULT } from '../utils/ModalHelper';
import { SelectModal } from './SelectModal'; import { SelectModal } from './SelectModal';
export class MediaDbSearchResultModal extends SelectModal<MediaTypeModel> { export class MediaDbSearchResultModal extends SelectModal<MediaTypeModel> {
@ -13,18 +13,17 @@ export class MediaDbSearchResultModal extends SelectModal<MediaTypeModel> {
submitCallback?: (res: SelectModalData) => void; submitCallback?: (res: SelectModalData) => void;
closeCallback?: (err?: Error) => void; closeCallback?: (err?: Error) => void;
skipCallback?: () => void; skipCallback?: () => void;
submitButtonText: string;
constructor(plugin: MediaDbPlugin, selectModalOptions: SelectModalOptions) { constructor(plugin: MediaDbPlugin, selectModalOptions: SelectModalOptions) {
selectModalOptions = Object.assign({}, SELECT_MODAL_OPTIONS_DEFAULT, selectModalOptions); selectModalOptions = Object.assign({}, SELECTMODALOPTIONSDEFAULT, selectModalOptions);
super(plugin.app, selectModalOptions.elements ?? [], selectModalOptions.multiSelect); super(plugin.app, selectModalOptions.elements ?? [], selectModalOptions.multiSelect);
this.plugin = plugin; this.plugin = plugin;
this.title = selectModalOptions.modalTitle ?? ''; this.title = selectModalOptions.modalTitle ?? '';
this.description = 'Select one or multiple search results.'; this.description = selectModalOptions.description ?? 'Select one or multiple search results.';
this.addSkipButton = selectModalOptions.skipButton ?? false; this.addSkipButton = selectModalOptions.skipButton ?? false;
this.submitButtonText = selectModalOptions.submitButtonText ?? 'Ok';
this.busy = false; this.busy = false;
this.sendCallback = false; this.sendCallback = false;
} }

View file

@ -0,0 +1,50 @@
import type MediaDbPlugin from '../main';
import { SelectModal } from './SelectModal';
export interface SeasonSelectModalElement {
season_number: number;
name: string;
air_date?: string;
poster_path?: string;
}
export class MediaDbSeasonSelectModal extends SelectModal<SeasonSelectModalElement> {
plugin: MediaDbPlugin;
submitCallback?: (selectedSeasons: SeasonSelectModalElement[]) => void;
closeCallback?: (err?: Error) => void;
seriesName?: string;
constructor(plugin: MediaDbPlugin, seasons: SeasonSelectModalElement[], multiSelect = true, seriesName?: string) {
super(plugin.app, seasons, multiSelect);
this.plugin = plugin;
this.seriesName = seriesName;
this.title = `Select seasons for${seriesName ? ` ${seriesName}` : ''}`;
this.description = 'Select one or more seasons to create notes for.';
this.submitButtonText = 'Create Entry';
}
renderElement(season: SeasonSelectModalElement, el: HTMLElement): void {
el.createEl('div', { text: `${season.name}` });
if (season.air_date) {
el.createEl('small', { text: `Air date: ${season.air_date}` });
}
}
submit(): void {
const selected = this.selectModalElements.filter(x => x.isActive()).map(x => x.value);
this.submitCallback?.(selected);
this.close();
}
skip(): void {
this.close();
}
setSubmitCallback(cb: (selectedSeasons: SeasonSelectModalElement[]) => void): void {
this.submitCallback = cb;
}
setCloseCallback(cb: (err?: Error) => void): void {
this.closeCallback = cb;
}
}

View file

@ -12,6 +12,7 @@ export abstract class SelectModal<T> extends Modal {
cancelButton?: ButtonComponent; cancelButton?: ButtonComponent;
skipButton?: ButtonComponent; skipButton?: ButtonComponent;
submitButton?: ButtonComponent; submitButton?: ButtonComponent;
submitButtonText: string;
elementWrapper?: HTMLDivElement; elementWrapper?: HTMLDivElement;
@ -25,6 +26,7 @@ export abstract class SelectModal<T> extends Modal {
this.title = ''; this.title = '';
this.description = ''; this.description = '';
this.addSkipButton = false; this.addSkipButton = false;
this.submitButtonText = 'Ok';
this.cancelButton = undefined; this.cancelButton = undefined;
this.skipButton = undefined; this.skipButton = undefined;
this.submitButton = undefined; this.submitButton = undefined;
@ -54,7 +56,7 @@ export abstract class SelectModal<T> extends Modal {
this.scope.register([], 'Enter', () => this.submit()); this.scope.register([], 'Enter', () => this.submit());
} }
abstract renderElement(value: T, el: HTMLElement): any; abstract renderElement(value: T, el: HTMLElement): void;
abstract submit(): void; abstract submit(): void;
@ -76,7 +78,7 @@ export abstract class SelectModal<T> extends Modal {
} }
} }
async onOpen(): Promise<void> { onOpen(): void {
const { contentEl, titleEl } = this; const { contentEl, titleEl } = this;
titleEl.createEl('h2', { text: this.title }); titleEl.createEl('h2', { text: this.title });
@ -115,7 +117,7 @@ export abstract class SelectModal<T> extends Modal {
}); });
} }
bottomSettingRow.addButton(btn => { bottomSettingRow.addButton(btn => {
btn.setButtonText('Ok'); btn.setButtonText(this.submitButtonText);
btn.setCta(); btn.setCta();
btn.onClick(() => this.submit()); btn.onClick(() => this.submit());
btn.buttonEl.addClass('media-db-plugin-button'); btn.buttonEl.addClass('media-db-plugin-button');

View file

@ -43,7 +43,7 @@ export class BoardGameModel extends MediaTypeModel {
migrateObject(this, obj, this); migrateObject(this, obj, this);
if (!obj.hasOwnProperty('userData')) { if (!Object.hasOwn(obj, 'userData')) {
migrateObject(this.userData, obj, this.userData); migrateObject(this.userData, obj, this.userData);
} }

View file

@ -43,7 +43,7 @@ export class BookModel extends MediaTypeModel {
migrateObject(this, obj, this); migrateObject(this, obj, this);
if (!obj.hasOwnProperty('userData')) { if (!Object.hasOwn(obj, 'userData')) {
migrateObject(this.userData, obj, this.userData); migrateObject(this.userData, obj, this.userData);
} }

View file

@ -53,7 +53,7 @@ export class ComicMangaModel extends MediaTypeModel {
migrateObject(this, obj, this); migrateObject(this, obj, this);
if (!obj.hasOwnProperty('userData')) { if (!Object.hasOwn(obj, 'userData')) {
migrateObject(this.userData, obj, this.userData); migrateObject(this.userData, obj, this.userData);
} }

View file

@ -39,7 +39,7 @@ export class GameModel extends MediaTypeModel {
migrateObject(this, obj, this); migrateObject(this, obj, this);
if (!obj.hasOwnProperty('userData')) { if (!Object.hasOwn(obj, 'userData')) {
migrateObject(this.userData, obj, this.userData); migrateObject(this.userData, obj, this.userData);
} }

View file

@ -9,6 +9,7 @@ export abstract class MediaTypeModel {
dataSource: string; dataSource: string;
url: string; url: string;
id: string; id: string;
image?: string;
userData: object; userData: object;
@ -21,6 +22,8 @@ export abstract class MediaTypeModel {
this.dataSource = ''; this.dataSource = '';
this.url = ''; this.url = '';
this.id = ''; this.id = '';
this.image = '';
this.userData = {}; this.userData = {};
} }

View file

@ -17,6 +17,9 @@ export class MovieModel extends MediaTypeModel {
image: string; image: string;
released: boolean; released: boolean;
country: string[];
boxOffice: string;
ageRating: string;
streamingServices: string[]; streamingServices: string[];
premiere: string; premiere: string;
@ -40,6 +43,9 @@ export class MovieModel extends MediaTypeModel {
this.image = ''; this.image = '';
this.released = false; this.released = false;
this.country = [];
this.boxOffice = '';
this.ageRating = '';
this.streamingServices = []; this.streamingServices = [];
this.premiere = ''; this.premiere = '';
@ -51,7 +57,7 @@ export class MovieModel extends MediaTypeModel {
migrateObject(this, obj, this); migrateObject(this, obj, this);
if (!obj.hasOwnProperty('userData')) { if (!Object.hasOwn(obj, 'userData')) {
migrateObject(this.userData, obj, this.userData); migrateObject(this.userData, obj, this.userData);
} }

View file

@ -8,8 +8,18 @@ export type MusicReleaseData = ModelToData<MusicReleaseModel>;
export class MusicReleaseModel extends MediaTypeModel { export class MusicReleaseModel extends MediaTypeModel {
genres: string[]; genres: string[];
artists: string[]; artists: string[];
language: string;
image: string; image: string;
rating: number; rating: number;
releaseDate: string;
albumDuration: string;
trackCount: number;
tracks: {
number: number;
title: string;
duration: string;
featuredArtists: string[];
}[];
userData: { userData: {
personalRating: number; personalRating: number;
@ -22,17 +32,23 @@ export class MusicReleaseModel extends MediaTypeModel {
this.artists = []; this.artists = [];
this.image = ''; this.image = '';
this.rating = 0; this.rating = 0;
this.releaseDate = '';
this.userData = { this.userData = {
personalRating: 0, personalRating: 0,
}; };
migrateObject(this, obj, this); migrateObject(this, obj, this);
if (!obj.hasOwnProperty('userData')) { if (!Object.hasOwn(obj, 'userData')) {
migrateObject(this.userData, obj, this.userData); migrateObject(this.userData, obj, this.userData);
} }
this.type = this.getMediaType(); this.type = this.getMediaType();
this.albumDuration = obj.albumDuration ?? '0:00';
this.trackCount = obj.trackCount ?? 0;
this.tracks = obj.tracks ?? [];
this.language = obj.language ?? '';
} }
getTags(): string[] { getTags(): string[] {

80
src/models/SeasonModel.ts Normal file
View file

@ -0,0 +1,80 @@
import { MediaType } from '../utils/MediaType';
import type { ModelToData } from '../utils/Utils';
import { mediaDbTag, migrateObject } from '../utils/Utils';
import { MediaTypeModel } from './MediaTypeModel';
export type SeasonData = ModelToData<SeasonModel>;
export class SeasonModel extends MediaTypeModel {
seasonNumber: number;
seasonTitle: string;
episodes: number;
plot: string;
genres: string[];
writer: string[];
studio: string[];
duration: string;
onlineRating: number;
actors: string[];
image: string;
released: boolean;
streamingServices: string[];
airing: boolean;
airedFrom: string;
airedTo: string;
userData: {
watched: boolean;
lastWatched: string;
personalRating: number;
};
constructor(obj: SeasonData) {
super();
this.seasonTitle = '';
this.seasonNumber = 0;
this.episodes = 0;
this.plot = '';
this.genres = [];
this.writer = [];
this.studio = [];
this.duration = '';
this.onlineRating = 0;
this.actors = [];
this.image = '';
this.released = false;
this.streamingServices = [];
this.airing = false;
this.airedFrom = '';
this.airedTo = '';
this.userData = {
watched: false,
lastWatched: '',
personalRating: 0,
};
migrateObject(this, obj, this);
if (!obj.hasOwnProperty('userData')) {
migrateObject(this.userData, obj, this.userData);
}
this.type = this.getMediaType();
}
getTags(): string[] {
return [mediaDbTag, 'tv', 'season'];
}
getMediaType(): MediaType {
return MediaType.Season;
}
getSummary(): string {
return this.seasonNumber + ' seasons';
}
}

View file

@ -17,6 +17,8 @@ export class SeriesModel extends MediaTypeModel {
image: string; image: string;
released: boolean; released: boolean;
country: string[];
ageRating: string;
streamingServices: string[]; streamingServices: string[];
airing: boolean; airing: boolean;
airedFrom: string; airedFrom: string;
@ -42,6 +44,8 @@ export class SeriesModel extends MediaTypeModel {
this.image = ''; this.image = '';
this.released = false; this.released = false;
this.country = [];
this.ageRating = '';
this.streamingServices = []; this.streamingServices = [];
this.airing = false; this.airing = false;
this.airedFrom = ''; this.airedFrom = '';
@ -55,7 +59,7 @@ export class SeriesModel extends MediaTypeModel {
migrateObject(this, obj, this); migrateObject(this, obj, this);
if (!obj.hasOwnProperty('userData')) { if (!Object.hasOwn(obj, 'userData')) {
migrateObject(this.userData, obj, this.userData); migrateObject(this.userData, obj, this.userData);
} }

View file

@ -24,7 +24,7 @@ export class WikiModel extends MediaTypeModel {
migrateObject(this, obj, this); migrateObject(this, obj, this);
if (!obj.hasOwnProperty('userData')) { if (!Object.hasOwn(obj, 'userData')) {
migrateObject(this.userData, obj, this.userData); migrateObject(this.userData, obj, this.userData);
} }

View file

@ -1,3 +1,4 @@
import type { MediaType } from 'src/utils/MediaType';
import type MediaDbPlugin from '../main'; import type MediaDbPlugin from '../main';
import { MEDIA_TYPES } from '../utils/MediaTypeManager'; import { MEDIA_TYPES } from '../utils/MediaTypeManager';
import { PropertyMappingOption } from './PropertyMapping'; import { PropertyMappingOption } from './PropertyMapping';
@ -16,7 +17,7 @@ export class PropertyMapper {
* @param obj * @param obj
*/ */
convertObject(obj: Record<string, unknown>): Record<string, unknown> { convertObject(obj: Record<string, unknown>): Record<string, unknown> {
if (!obj.hasOwnProperty('type')) { if (!Object.hasOwn(obj, 'type')) {
return obj; return obj;
} }
@ -34,14 +35,23 @@ export class PropertyMapper {
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
for (const propertyMapping of propertyMappings) { for (const propertyMapping of propertyMappings) {
if (propertyMapping.property === key) { if (propertyMapping.property === key) {
let finalValue = value;
if (propertyMapping.wikilink) {
if (typeof value === 'string') {
finalValue = `[[${value}]]`;
} else if (Array.isArray(value)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
finalValue = value.map(v => (typeof v === 'string' ? `[[${v}]]` : v));
}
}
if (propertyMapping.mapping === PropertyMappingOption.Map) { if (propertyMapping.mapping === PropertyMappingOption.Map) {
// @ts-ignore // @ts-ignore
newObj[propertyMapping.newProperty] = value; newObj[propertyMapping.newProperty] = finalValue;
} else if (propertyMapping.mapping === PropertyMappingOption.Remove) { } else if (propertyMapping.mapping === PropertyMappingOption.Remove) {
// do nothing // do nothing
} else if (propertyMapping.mapping === PropertyMappingOption.Default) { } else if (propertyMapping.mapping === PropertyMappingOption.Default) {
// @ts-ignore // @ts-ignore
newObj[key] = value; newObj[key] = finalValue;
} }
break; break;
} }
@ -58,7 +68,7 @@ export class PropertyMapper {
* @param obj * @param obj
*/ */
convertObjectBack(obj: Record<string, unknown>): Record<string, unknown> { convertObjectBack(obj: Record<string, unknown>): Record<string, unknown> {
if (!obj.hasOwnProperty('type')) { if (!Object.hasOwn(obj, 'type')) {
return obj; return obj;
} }
@ -66,7 +76,7 @@ export class PropertyMapper {
obj.type = 'comicManga'; obj.type = 'comicManga';
console.debug(`MDB | updated metadata type`, obj.type); console.debug(`MDB | updated metadata type`, obj.type);
} }
if (MEDIA_TYPES.contains(obj.type as any)) { if (MEDIA_TYPES.contains(obj.type as MediaType)) {
return obj; return obj;
} }

View file

@ -75,7 +75,7 @@ export class PropertyMappingModel {
copy(): PropertyMappingModel { copy(): PropertyMappingModel {
const copy = new PropertyMappingModel(this.type); const copy = new PropertyMappingModel(this.type);
for (const property of this.properties) { for (const property of this.properties) {
const propertyCopy = new PropertyMapping(property.property, property.newProperty, property.mapping, property.locked); const propertyCopy = new PropertyMapping(property.property, property.newProperty, property.mapping, property.locked, property.wikilink);
copy.properties.push(propertyCopy); copy.properties.push(propertyCopy);
} }
return copy; return copy;
@ -87,12 +87,14 @@ export class PropertyMapping {
newProperty: string; newProperty: string;
locked: boolean; locked: boolean;
mapping: PropertyMappingOption; mapping: PropertyMappingOption;
wikilink: boolean;
constructor(property: string, newProperty: string, mapping: PropertyMappingOption, locked?: boolean) { constructor(property: string, newProperty: string, mapping: PropertyMappingOption, locked?: boolean, wikilink?: boolean) {
this.property = property; this.property = property;
this.newProperty = newProperty; this.newProperty = newProperty;
this.mapping = mapping; this.mapping = mapping;
this.locked = locked ?? false; this.locked = locked ?? false;
this.wikilink = wikilink ?? false;
} }
validate(): { res: boolean; err?: Error } { validate(): { res: boolean; err?: Error } {

View file

@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import { run } from 'svelte/legacy';
import { PropertyMappingModel, PropertyMappingOption, propertyMappingOptions } from './PropertyMapping'; import { PropertyMappingModel, PropertyMappingOption, propertyMappingOptions } from './PropertyMapping';
import { capitalizeFirstLetter } from '../utils/Utils'; import { capitalizeFirstLetter } from '../utils/Utils';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
@ -12,55 +10,152 @@
let { model, save }: Props = $props(); let { model, save }: Props = $props();
let validationResult: { res: boolean; err?: Error } | undefined = $state(); let validationResult: { res: boolean; err?: Error } | undefined = $derived(model.validate());
$effect(() => {
validationResult = model.validate();
});
</script> </script>
<div class="media-db-plugin-property-mappings-model-container"> <div class="media-db-plugin-property-mappings-model-container">
<div class="setting-item-name">{capitalizeFirstLetter(model.type)}</div> <div class="setting-item-name">{capitalizeFirstLetter(model.type)}</div>
<div class="media-db-plugin-property-mappings-container">
{#each model.properties as property}
<div class="media-db-plugin-property-mapping-element">
<div class="media-db-plugin-property-mapping-element-property-name-wrapper">
<pre class="media-db-plugin-property-mapping-element-property-name"><code>{property.property}</code></pre>
</div>
{#if property.locked}
<div class="media-db-plugin-property-binding-text">property cannot be remapped</div>
{:else}
<select class="dropdown" bind:value={property.mapping}>
{#each propertyMappingOptions as remappingOption}
<option value={remappingOption}>
{remappingOption}
</option>
{/each}
</select>
{#if property.mapping === PropertyMappingOption.Map} <table class="media-db-plugin-property-mappings-table">
<Icon iconName="arrow-right" /> <thead>
<div class="media-db-plugin-property-mapping-to"> <tr>
<input type="text" spellcheck="false" bind:value={property.newProperty} /> <th class="col-property">Property</th>
</div> <th class="col-mapping">Mapping</th>
<th class="col-new-name">New name</th>
<th class="col-wikilink">Wikilink</th>
</tr>
</thead>
<tbody>
{#each model.properties as property}
<tr>
<td class="col-property">
<code>{property.property}</code>
</td>
{#if property.locked}
<td class="col-locked" colspan="3">
<div class="media-db-plugin-property-binding-text">property cannot be remapped</div>
</td>
{:else}
<td class="col-mapping">
<select
class="dropdown"
bind:value={property.mapping}
onchange={() => {
model = model.copy();
}}
>
{#each propertyMappingOptions as remappingOption}
<option value={remappingOption}>
{remappingOption}
</option>
{/each}
</select>
</td>
<td class="col-new-name">
{#if property.mapping === PropertyMappingOption.Map}
<div class="media-db-plugin-property-mapping-to">
<Icon iconName="arrow-right" />
<input class="media-db-plugin-property-mapping-input" type="text" spellcheck="false" bind:value={property.newProperty} />
</div>
{:else}
<span class="media-db-plugin-property-mapping-to-disabled"></span>
{/if}
</td>
<td class="col-wikilink">
<label class="media-db-plugin-property-mapping-wikilink-label" title="Convert value to wikilink ([[value]])">
<input type="checkbox" bind:checked={property.wikilink} />
</label>
</td>
{/if} {/if}
{/if} </tr>
</div> {/each}
{/each} </tbody>
</div> </table>
{#if !validationResult?.res} {#if !validationResult?.res}
<div class="media-db-plugin-property-mapping-validation"> <div class="media-db-plugin-property-mapping-validation">
{validationResult?.err?.message} {validationResult?.err?.message}
</div> </div>
{/if} {/if}
<button <button
class="media-db-plugin-property-mappings-save-button {validationResult?.res ? 'mod-cta' : 'mod-muted'}" class="media-db-plugin-property-mappings-save-button {validationResult?.res ? 'mod-cta' : 'mod-muted'}"
onclick={() => { onclick={() => {
if (model.validate().res) save(model); if (model.validate().res) save(model);
}} }}
>Save >
Save
</button> </button>
</div> </div>
<style> <style>
.media-db-plugin-property-mappings-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
table-layout: fixed; /* prevent overflow from wide cells */
}
/* remove excessive left padding and keep within container */
.media-db-plugin-property-mappings-table th,
.media-db-plugin-property-mappings-table td {
padding: 2px 4px;
border-bottom: 1px solid var(--background-modifier-border);
vertical-align: middle;
}
/* column widths */
.col-property {
width: 25%;
white-space: nowrap;
}
.col-mapping {
width: 20%;
}
.col-new-name {
width: 40%;
}
.col-wikilink {
width: 15%;
text-align: center;
}
/* ensure inner controls don't push table wider than container */
.media-db-plugin-property-mapping-to {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.media-db-plugin-property-mapping-input,
.media-db-plugin-property-mappings-table select.dropdown {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.media-db-plugin-property-mapping-wikilink-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.95em;
cursor: pointer;
}
.media-db-plugin-property-mapping-to-disabled {
color: var(--text-muted);
}
/* avoid extra left indentation from <pre> */
.media-db-plugin-property-mappings-table code {
padding: 0;
margin: 0;
}
</style> </style>

View file

@ -1,10 +1,11 @@
import type { App } from 'obsidian'; import type { App } from 'obsidian';
import { Notice, PluginSettingTab, Setting } from 'obsidian'; import { Notice, PluginSettingTab, Setting } from 'obsidian';
import type { MediaType } from 'src/utils/MediaType';
import { mount } from 'svelte'; import { mount } from 'svelte';
import type MediaDbPlugin from '../main'; import type MediaDbPlugin from '../main';
import type { MediaTypeModel } from '../models/MediaTypeModel'; import type { MediaTypeModel } from '../models/MediaTypeModel';
import { MEDIA_TYPES } from '../utils/MediaTypeManager'; import { MEDIA_TYPES } from '../utils/MediaTypeManager';
import { fragWithHTML } from '../utils/Utils'; import { fragWithHTML, unCamelCase } from '../utils/Utils';
import { PropertyMapping, PropertyMappingModel, PropertyMappingOption } from './PropertyMapping'; import { PropertyMapping, PropertyMappingModel, PropertyMappingOption } from './PropertyMapping';
import PropertyMappingModelsComponent from './PropertyMappingModelsComponent.svelte'; import PropertyMappingModelsComponent from './PropertyMappingModelsComponent.svelte';
import { FileSuggest } from './suggesters/FileSuggest'; import { FileSuggest } from './suggesters/FileSuggest';
@ -12,35 +13,34 @@ import { FolderSuggest } from './suggesters/FolderSuggest';
export interface MediaDbPluginSettings { export interface MediaDbPluginSettings {
OMDbKey: string; OMDbKey: string;
TMDBKey: string;
MobyGamesKey: string; MobyGamesKey: string;
GiantBombKey: string; GiantBombKey: string;
ComicVineKey: string; ComicVineKey: string;
BoardgameGeekKey: string;
sfwFilter: boolean; sfwFilter: boolean;
templates: boolean; templates: boolean;
customDateFormat: string; customDateFormat: string;
openNoteInNewTab: boolean; openNoteInNewTab: boolean;
useDefaultFrontMatter: boolean; useDefaultFrontMatter: boolean;
enableTemplaterIntegration: boolean; enableTemplaterIntegration: boolean;
// TODO: disabled for now, as i currently don't have the time to fix this from the original PR that introduced it (#133) OMDbAPI_disabledMediaTypes: MediaType[];
// apiToggle: { TMDBSeriesAPI_disabledMediaTypes: MediaType[];
// OMDbAPI: { TMDBSeasonAPI_disabledMediaTypes: MediaType[];
// movie: boolean; TMDBMovieAPI_disabledMediaTypes: MediaType[];
// series: boolean; MALAPI_disabledMediaTypes: MediaType[];
// game: boolean; MALAPIManga_disabledMediaTypes: MediaType[];
// }; ComicVineAPI_disabledMediaTypes: MediaType[];
// MALAPI: { SteamAPI_disabledMediaTypes: MediaType[];
// movie: boolean; MobyGamesAPI_disabledMediaTypes: MediaType[];
// series: boolean; GiantBombAPI_disabledMediaTypes: MediaType[];
// }; WikipediaAPI_disabledMediaTypes: MediaType[];
// SteamAPI: { BoardgameGeekAPI_disabledMediaTypes: MediaType[];
// game: boolean; MusicBrainzAPI_disabledMediaTypes: MediaType[];
// }; OpenLibraryAPI_disabledMediaTypes: MediaType[];
// MobyGamesAPI: {
// game: boolean;
// };
// };
movieTemplate: string; movieTemplate: string;
seriesTemplate: string; seriesTemplate: string;
seasonTemplate: string;
mangaTemplate: string; mangaTemplate: string;
gameTemplate: string; gameTemplate: string;
wikiTemplate: string; wikiTemplate: string;
@ -50,6 +50,7 @@ export interface MediaDbPluginSettings {
movieFileNameTemplate: string; movieFileNameTemplate: string;
seriesFileNameTemplate: string; seriesFileNameTemplate: string;
seasonFileNameTemplate: string;
mangaFileNameTemplate: string; mangaFileNameTemplate: string;
gameFileNameTemplate: string; gameFileNameTemplate: string;
wikiFileNameTemplate: string; wikiFileNameTemplate: string;
@ -59,6 +60,7 @@ export interface MediaDbPluginSettings {
moviePropertyConversionRules: string; moviePropertyConversionRules: string;
seriesPropertyConversionRules: string; seriesPropertyConversionRules: string;
seasonPropertyConversionRules: string;
mangaPropertyConversionRules: string; mangaPropertyConversionRules: string;
gamePropertyConversionRules: string; gamePropertyConversionRules: string;
wikiPropertyConversionRules: string; wikiPropertyConversionRules: string;
@ -68,6 +70,7 @@ export interface MediaDbPluginSettings {
movieFolder: string; movieFolder: string;
seriesFolder: string; seriesFolder: string;
seasonFolder: string;
mangaFolder: string; mangaFolder: string;
gameFolder: string; gameFolder: string;
wikiFolder: string; wikiFolder: string;
@ -75,39 +78,41 @@ export interface MediaDbPluginSettings {
boardgameFolder: string; boardgameFolder: string;
bookFolder: string; bookFolder: string;
imageDownload: boolean;
imageFolder: string;
propertyMappingModels: PropertyMappingModel[]; propertyMappingModels: PropertyMappingModel[];
} }
const DEFAULT_SETTINGS: MediaDbPluginSettings = { const DEFAULT_SETTINGS: MediaDbPluginSettings = {
OMDbKey: '', OMDbKey: '',
TMDBKey: '',
MobyGamesKey: '', MobyGamesKey: '',
GiantBombKey: '', GiantBombKey: '',
ComicVineKey: '', ComicVineKey: '',
BoardgameGeekKey: '',
sfwFilter: true, sfwFilter: true,
templates: true, templates: true,
customDateFormat: 'L', customDateFormat: 'L',
openNoteInNewTab: true, openNoteInNewTab: true,
useDefaultFrontMatter: true, useDefaultFrontMatter: true,
enableTemplaterIntegration: false, enableTemplaterIntegration: false,
// apiToggle: { OMDbAPI_disabledMediaTypes: [],
// OMDbAPI: { TMDBSeriesAPI_disabledMediaTypes: [],
// movie: true, TMDBSeasonAPI_disabledMediaTypes: [],
// series: true, TMDBMovieAPI_disabledMediaTypes: [],
// game: true, MALAPI_disabledMediaTypes: [],
// }, MALAPIManga_disabledMediaTypes: [],
// MALAPI: { ComicVineAPI_disabledMediaTypes: [],
// movie: true, SteamAPI_disabledMediaTypes: [],
// series: true, MobyGamesAPI_disabledMediaTypes: [],
// }, GiantBombAPI_disabledMediaTypes: [],
// SteamAPI: { WikipediaAPI_disabledMediaTypes: [],
// game: true, BoardgameGeekAPI_disabledMediaTypes: [],
// }, MusicBrainzAPI_disabledMediaTypes: [],
// MobyGamesAPI: { OpenLibraryAPI_disabledMediaTypes: [],
// game: true,
// },
// },
movieTemplate: '', movieTemplate: '',
seriesTemplate: '', seriesTemplate: '',
seasonTemplate: '',
mangaTemplate: '', mangaTemplate: '',
gameTemplate: '', gameTemplate: '',
wikiTemplate: '', wikiTemplate: '',
@ -117,6 +122,7 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = {
movieFileNameTemplate: '{{ title }} ({{ year }})', movieFileNameTemplate: '{{ title }} ({{ year }})',
seriesFileNameTemplate: '{{ title }} ({{ year }})', seriesFileNameTemplate: '{{ title }} ({{ year }})',
seasonFileNameTemplate: '{{ title }} ({{ year }})',
mangaFileNameTemplate: '{{ title }} ({{ year }})', mangaFileNameTemplate: '{{ title }} ({{ year }})',
gameFileNameTemplate: '{{ title }} ({{ year }})', gameFileNameTemplate: '{{ title }} ({{ year }})',
wikiFileNameTemplate: '{{ title }}', wikiFileNameTemplate: '{{ title }}',
@ -126,6 +132,7 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = {
moviePropertyConversionRules: '', moviePropertyConversionRules: '',
seriesPropertyConversionRules: '', seriesPropertyConversionRules: '',
seasonPropertyConversionRules: '',
mangaPropertyConversionRules: '', mangaPropertyConversionRules: '',
gamePropertyConversionRules: '', gamePropertyConversionRules: '',
wikiPropertyConversionRules: '', wikiPropertyConversionRules: '',
@ -135,6 +142,7 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = {
movieFolder: 'Media DB/movies', movieFolder: 'Media DB/movies',
seriesFolder: 'Media DB/series', seriesFolder: 'Media DB/series',
seasonFolder: 'Media DB/series',
mangaFolder: 'Media DB/comics', mangaFolder: 'Media DB/comics',
gameFolder: 'Media DB/games', gameFolder: 'Media DB/games',
wikiFolder: 'Media DB/wiki', wikiFolder: 'Media DB/wiki',
@ -142,6 +150,8 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = {
boardgameFolder: 'Media DB/boardgames', boardgameFolder: 'Media DB/boardgames',
bookFolder: 'Media DB/books', bookFolder: 'Media DB/books',
imageDownload: false,
imageFolder: 'Media DB/images',
propertyMappingModels: [], propertyMappingModels: [],
}; };
@ -161,16 +171,54 @@ export function getDefaultSettings(plugin: MediaDbPlugin): MediaDbPluginSettings
const propertyMappingModel: PropertyMappingModel = new PropertyMappingModel(mediaType); const propertyMappingModel: PropertyMappingModel = new PropertyMappingModel(mediaType);
for (const key of Object.keys(metadataObj)) { for (const key of Object.keys(metadataObj)) {
propertyMappingModel.properties.push(new PropertyMapping(key, '', PropertyMappingOption.Default, lockedPropertyMappings.contains(key))); propertyMappingModel.properties.push(
new PropertyMapping(
key,
'',
PropertyMappingOption.Default,
lockedPropertyMappings.contains(key),
false, // wikilink default
),
);
} }
propertyMappingModels.push(propertyMappingModel); propertyMappingModels.push(propertyMappingModel);
} }
// MIGRATION: Ensure all property mappings have wikilink defined (for settings loaded from disk)
if (defaultSettings.propertyMappingModels && Array.isArray(defaultSettings.propertyMappingModels)) {
for (const model of defaultSettings.propertyMappingModels) {
if (model.properties && Array.isArray(model.properties)) {
for (const prop of model.properties) {
if (typeof prop.wikilink === 'undefined') {
prop.wikilink = false;
}
}
}
}
}
defaultSettings.propertyMappingModels = propertyMappingModels; defaultSettings.propertyMappingModels = propertyMappingModels;
return defaultSettings; return defaultSettings;
} }
/**
* Ensures all property mappings in loaded settings have the wikilink property defined.
*/
export function ensureWikilinkOnPropertyMappings(settings: MediaDbPluginSettings): void {
if (settings.propertyMappingModels && Array.isArray(settings.propertyMappingModels)) {
for (const model of settings.propertyMappingModels) {
if (model.properties && Array.isArray(model.properties)) {
for (const prop of model.properties) {
if (typeof prop.wikilink === 'undefined') {
prop.wikilink = false;
}
}
}
}
}
}
export class MediaDbSettingTab extends PluginSettingTab { export class MediaDbSettingTab extends PluginSettingTab {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
@ -195,6 +243,17 @@ export class MediaDbSettingTab extends PluginSettingTab {
void this.plugin.saveSettings(); void this.plugin.saveSettings();
}); });
}); });
new Setting(containerEl)
.setName('TMDB API key')
.setDesc('API key for "https://www.themoviedb.org".')
.addText(cb => {
cb.setPlaceholder('API key')
.setValue(this.plugin.settings.TMDBKey)
.onChange(data => {
this.plugin.settings.TMDBKey = data;
void this.plugin.saveSettings();
});
});
new Setting(containerEl) new Setting(containerEl)
.setName('Moby Games key') .setName('Moby Games key')
@ -230,6 +289,17 @@ export class MediaDbSettingTab extends PluginSettingTab {
void this.plugin.saveSettings(); void this.plugin.saveSettings();
}); });
}); });
new Setting(containerEl)
.setName('Boardgame Geek Key')
.setDesc('API key for "www.boardgamegeek.com".')
.addText(cb => {
cb.setPlaceholder('API key')
.setValue(this.plugin.settings.BoardgameGeekKey)
.onChange(data => {
this.plugin.settings.BoardgameGeekKey = data;
void this.plugin.saveSettings();
});
});
new Setting(containerEl) new Setting(containerEl)
.setName('SFW filter') .setName('SFW filter')
@ -310,89 +380,91 @@ export class MediaDbSettingTab extends PluginSettingTab {
}); });
}); });
// containerEl.createEl('h3', { text: 'APIs per media type' }); new Setting(containerEl)
// containerEl.createEl('h5', { text: 'Movies' }); .setName('Download images')
// new Setting(containerEl) .setDesc('Downloads images for new notes in the folder below')
// .setName('OMDb API') .addToggle(cb => {
// .setDesc('Use OMDb API for movies.') cb.setValue(this.plugin.settings.imageDownload).onChange(data => {
// .addToggle(cb => { this.plugin.settings.imageDownload = data;
// cb.setValue(this.plugin.settings.apiToggle.OMDbAPI.movie).onChange(data => { void this.plugin.saveSettings();
// this.plugin.settings.apiToggle.OMDbAPI.movie = data; });
// void this.plugin.saveSettings(); });
// });
// }); new Setting(containerEl)
// new Setting(containerEl) .setName('Image folder')
// .setName('MAL API') .setDesc('Where downloaded images should be stored.')
// .setDesc('Use MAL API for movies.') .addSearch(cb => {
// .addToggle(cb => { const suggester = new FolderSuggest(this.app, cb.inputEl);
// cb.setValue(this.plugin.settings.apiToggle.MALAPI.movie).onChange(data => { suggester.onSelect(folder => {
// this.plugin.settings.apiToggle.MALAPI.movie = data; cb.setValue(folder.path);
// void this.plugin.saveSettings(); this.plugin.settings.imageFolder = folder.path;
// }); void this.plugin.saveSettings();
// }); suggester.close();
// containerEl.createEl('h5', { text: 'Series' }); });
// new Setting(containerEl) cb.setPlaceholder(DEFAULT_SETTINGS.imageFolder)
// .setName('OMDb API') .setValue(this.plugin.settings.imageFolder)
// .setDesc('Use OMDb API for series.') .onChange(data => {
// .addToggle(cb => { this.plugin.settings.imageFolder = data;
// cb.setValue(this.plugin.settings.apiToggle.OMDbAPI.series).onChange(data => { void this.plugin.saveSettings();
// this.plugin.settings.apiToggle.OMDbAPI.series = data; });
// void this.plugin.saveSettings(); });
// });
// }); // Create a map to store APIs for each media type
// new Setting(containerEl) const mediaTypeApiMap = new Map<MediaType, string[]>();
// .setName('MAL API')
// .setDesc('Use MAL API for series.') // Populate the map with APIs for each media type dynamically
// .addToggle(cb => { for (const api of this.plugin.apiManager.apis) {
// cb.setValue(this.plugin.settings.apiToggle.MALAPI.series).onChange(data => { for (const mediaType of api.types) {
// this.plugin.settings.apiToggle.MALAPI.series = data; if (!mediaTypeApiMap.has(mediaType)) {
// void this.plugin.saveSettings(); mediaTypeApiMap.set(mediaType, []);
// }); }
// }); mediaTypeApiMap.get(mediaType)!.push(api.apiName);
// containerEl.createEl('h5', { text: 'Games' }); }
// new Setting(containerEl) }
// .setName('OMDb API')
// .setDesc('Use OMDb API for games.') // Filter out media types with only one API
// .addToggle(cb => { const filteredMediaTypes = Array.from(mediaTypeApiMap.entries()).filter(([_, apis]) => apis.length > 1);
// cb.setValue(this.plugin.settings.apiToggle.OMDbAPI.game).onChange(data => {
// this.plugin.settings.apiToggle.OMDbAPI.game = data; // Dynamically create settings based on the filtered media types and their APIs
// void this.plugin.saveSettings(); for (const [mediaType, apis] of filteredMediaTypes) {
// }); new Setting(containerEl).setName(`Select APIs for ${unCamelCase(mediaType)}`).setHeading();
// }); for (const apiName of apis) {
// new Setting(containerEl) const api = this.plugin.apiManager.apis.find(api => api.apiName === apiName);
// .setName('Steam API') if (api) {
// .setDesc('Use OMDb API for games.') const disabledMediaTypes = api.getDisabledMediaTypes();
// .addToggle(cb => { new Setting(containerEl)
// cb.setValue(this.plugin.settings.apiToggle.SteamAPI.game).onChange(data => { .setName(apiName)
// this.plugin.settings.apiToggle.SteamAPI.game = data; .setDesc(`Use ${apiName} API for ${unCamelCase(mediaType)}.`)
// void this.plugin.saveSettings(); .addToggle(cb => {
// }); cb.setValue(!disabledMediaTypes.includes(mediaType)).onChange(data => {
// }); if (data) {
// new Setting(containerEl) const index = disabledMediaTypes.indexOf(mediaType);
// .setName('MobyGames API') if (index > -1) {
// .setDesc('Use MobyGames API for games.') disabledMediaTypes.splice(index, 1);
// .addToggle(cb => { }
// cb.setValue(this.plugin.settings.apiToggle.MobyGamesAPI.game).onChange(data => { } else {
// this.plugin.settings.apiToggle.MobyGamesAPI.game = data; disabledMediaTypes.push(mediaType);
// void this.plugin.saveSettings(); }
// }); void this.plugin.saveSettings();
// }); });
// new Setting(containerEl) });
// .setName('Giantbomb API') }
// .setDesc('Use Giantbomb API for games.') }
// .addToggle(cb => { }
// cb.setValue(this.plugin.settings.apiToggle.GiantBombAPI.game).onChange(data => {
// this.plugin.settings.apiToggle.GiantBombAPI.game = data;
// void this.plugin.saveSettings();
// });
// });
new Setting(containerEl).setName('New file location').setHeading(); new Setting(containerEl).setName('New file location').setHeading();
// region new file location // region new file location
new Setting(containerEl) new Setting(containerEl)
.setName('Movie folder') .setName('Movie folder')
.setDesc('Where newly imported movies should be placed.') .setDesc('Where newly imported movies should be placed.')
.addSearch(cb => { .addSearch(cb => {
new FolderSuggest(this.app, cb.inputEl); const suggester = new FolderSuggest(this.app, cb.inputEl);
suggester.onSelect(folder => {
cb.setValue(folder.path);
this.plugin.settings.movieFolder = folder.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder(DEFAULT_SETTINGS.movieFolder) cb.setPlaceholder(DEFAULT_SETTINGS.movieFolder)
.setValue(this.plugin.settings.movieFolder) .setValue(this.plugin.settings.movieFolder)
.onChange(data => { .onChange(data => {
@ -405,7 +477,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Series folder') .setName('Series folder')
.setDesc('Where newly imported series should be placed.') .setDesc('Where newly imported series should be placed.')
.addSearch(cb => { .addSearch(cb => {
new FolderSuggest(this.app, cb.inputEl); const suggester = new FolderSuggest(this.app, cb.inputEl);
suggester.onSelect(folder => {
cb.setValue(folder.path);
this.plugin.settings.seriesFolder = folder.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder(DEFAULT_SETTINGS.seriesFolder) cb.setPlaceholder(DEFAULT_SETTINGS.seriesFolder)
.setValue(this.plugin.settings.seriesFolder) .setValue(this.plugin.settings.seriesFolder)
.onChange(data => { .onChange(data => {
@ -414,11 +492,30 @@ export class MediaDbSettingTab extends PluginSettingTab {
}); });
}); });
new Setting(containerEl)
.setName('Season folder')
.setDesc('Where newly imported seasons should be placed.')
.addSearch(cb => {
new FolderSuggest(this.app, cb.inputEl);
cb.setPlaceholder(DEFAULT_SETTINGS.seasonFolder)
.setValue(this.plugin.settings.seriesFolder)
.onChange(data => {
this.plugin.settings.seasonFolder = data;
void this.plugin.saveSettings();
});
});
new Setting(containerEl) new Setting(containerEl)
.setName('Comic and manga folder') .setName('Comic and manga folder')
.setDesc('Where newly imported comics and manga should be placed.') .setDesc('Where newly imported comics and manga should be placed.')
.addSearch(cb => { .addSearch(cb => {
new FolderSuggest(this.app, cb.inputEl); const suggester = new FolderSuggest(this.app, cb.inputEl);
suggester.onSelect(folder => {
cb.setValue(folder.path);
this.plugin.settings.mangaFolder = folder.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder(DEFAULT_SETTINGS.mangaFolder) cb.setPlaceholder(DEFAULT_SETTINGS.mangaFolder)
.setValue(this.plugin.settings.mangaFolder) .setValue(this.plugin.settings.mangaFolder)
.onChange(data => { .onChange(data => {
@ -431,7 +528,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Game folder') .setName('Game folder')
.setDesc('Where newly imported games should be placed.') .setDesc('Where newly imported games should be placed.')
.addSearch(cb => { .addSearch(cb => {
new FolderSuggest(this.app, cb.inputEl); const suggester = new FolderSuggest(this.app, cb.inputEl);
suggester.onSelect(folder => {
cb.setValue(folder.path);
this.plugin.settings.gameFolder = folder.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder(DEFAULT_SETTINGS.gameFolder) cb.setPlaceholder(DEFAULT_SETTINGS.gameFolder)
.setValue(this.plugin.settings.gameFolder) .setValue(this.plugin.settings.gameFolder)
.onChange(data => { .onChange(data => {
@ -444,7 +547,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Wiki folder') .setName('Wiki folder')
.setDesc('Where newly imported wiki articles should be placed.') .setDesc('Where newly imported wiki articles should be placed.')
.addSearch(cb => { .addSearch(cb => {
new FolderSuggest(this.app, cb.inputEl); const suggester = new FolderSuggest(this.app, cb.inputEl);
suggester.onSelect(folder => {
cb.setValue(folder.path);
this.plugin.settings.wikiFolder = folder.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder(DEFAULT_SETTINGS.wikiFolder) cb.setPlaceholder(DEFAULT_SETTINGS.wikiFolder)
.setValue(this.plugin.settings.wikiFolder) .setValue(this.plugin.settings.wikiFolder)
.onChange(data => { .onChange(data => {
@ -457,7 +566,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Music folder') .setName('Music folder')
.setDesc('Where newly imported music should be placed.') .setDesc('Where newly imported music should be placed.')
.addSearch(cb => { .addSearch(cb => {
new FolderSuggest(this.app, cb.inputEl); const suggester = new FolderSuggest(this.app, cb.inputEl);
suggester.onSelect(folder => {
cb.setValue(folder.path);
this.plugin.settings.musicReleaseFolder = folder.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder(DEFAULT_SETTINGS.musicReleaseFolder) cb.setPlaceholder(DEFAULT_SETTINGS.musicReleaseFolder)
.setValue(this.plugin.settings.musicReleaseFolder) .setValue(this.plugin.settings.musicReleaseFolder)
.onChange(data => { .onChange(data => {
@ -470,7 +585,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Board game folder') .setName('Board game folder')
.setDesc('Where newly imported board games should be places.') .setDesc('Where newly imported board games should be places.')
.addSearch(cb => { .addSearch(cb => {
new FolderSuggest(this.app, cb.inputEl); const suggester = new FolderSuggest(this.app, cb.inputEl);
suggester.onSelect(folder => {
cb.setValue(folder.path);
this.plugin.settings.boardgameFolder = folder.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder(DEFAULT_SETTINGS.boardgameFolder) cb.setPlaceholder(DEFAULT_SETTINGS.boardgameFolder)
.setValue(this.plugin.settings.boardgameFolder) .setValue(this.plugin.settings.boardgameFolder)
.onChange(data => { .onChange(data => {
@ -482,7 +603,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Book folder') .setName('Book folder')
.setDesc('Where newly imported books should be placed.') .setDesc('Where newly imported books should be placed.')
.addSearch(cb => { .addSearch(cb => {
new FolderSuggest(this.app, cb.inputEl); const suggester = new FolderSuggest(this.app, cb.inputEl);
suggester.onSelect(folder => {
cb.setValue(folder.path);
this.plugin.settings.bookFolder = folder.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder(DEFAULT_SETTINGS.bookFolder) cb.setPlaceholder(DEFAULT_SETTINGS.bookFolder)
.setValue(this.plugin.settings.bookFolder) .setValue(this.plugin.settings.bookFolder)
.onChange(data => { .onChange(data => {
@ -499,7 +626,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Movie template') .setName('Movie template')
.setDesc('Template file to be used when creating a new note for a movie.') .setDesc('Template file to be used when creating a new note for a movie.')
.addSearch(cb => { .addSearch(cb => {
new FileSuggest(this.app, cb.inputEl); const suggester = new FileSuggest(this.app, cb.inputEl);
suggester.onSelect(file => {
cb.setValue(file.path);
this.plugin.settings.movieTemplate = file.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder('Example: movieTemplate.md') cb.setPlaceholder('Example: movieTemplate.md')
.setValue(this.plugin.settings.movieTemplate) .setValue(this.plugin.settings.movieTemplate)
.onChange(data => { .onChange(data => {
@ -512,7 +645,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Series template') .setName('Series template')
.setDesc('Template file to be used when creating a new note for a series.') .setDesc('Template file to be used when creating a new note for a series.')
.addSearch(cb => { .addSearch(cb => {
new FileSuggest(this.app, cb.inputEl); const suggester = new FileSuggest(this.app, cb.inputEl);
suggester.onSelect(file => {
cb.setValue(file.path);
this.plugin.settings.seriesTemplate = file.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder('Example: seriesTemplate.md') cb.setPlaceholder('Example: seriesTemplate.md')
.setValue(this.plugin.settings.seriesTemplate) .setValue(this.plugin.settings.seriesTemplate)
.onChange(data => { .onChange(data => {
@ -521,11 +660,30 @@ export class MediaDbSettingTab extends PluginSettingTab {
}); });
}); });
new Setting(containerEl)
.setName('Season template')
.setDesc('Template file to be used when creating a new note for a season.')
.addSearch(cb => {
new FileSuggest(this.app, cb.inputEl);
cb.setPlaceholder('Example: seasonTemplate.md')
.setValue(this.plugin.settings.seasonTemplate)
.onChange(data => {
this.plugin.settings.seasonTemplate = data;
void this.plugin.saveSettings();
});
});
new Setting(containerEl) new Setting(containerEl)
.setName('Manga and Comics template') .setName('Manga and Comics template')
.setDesc('Template file to be used when creating a new note for a manga or a comic.') .setDesc('Template file to be used when creating a new note for a manga or a comic.')
.addSearch(cb => { .addSearch(cb => {
new FileSuggest(this.app, cb.inputEl); const suggester = new FileSuggest(this.app, cb.inputEl);
suggester.onSelect(file => {
cb.setValue(file.path);
this.plugin.settings.mangaTemplate = file.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder('Example: mangaTemplate.md') cb.setPlaceholder('Example: mangaTemplate.md')
.setValue(this.plugin.settings.mangaTemplate) .setValue(this.plugin.settings.mangaTemplate)
.onChange(data => { .onChange(data => {
@ -538,7 +696,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Game template') .setName('Game template')
.setDesc('Template file to be used when creating a new note for a game.') .setDesc('Template file to be used when creating a new note for a game.')
.addSearch(cb => { .addSearch(cb => {
new FileSuggest(this.app, cb.inputEl); const suggester = new FileSuggest(this.app, cb.inputEl);
suggester.onSelect(file => {
cb.setValue(file.path);
this.plugin.settings.gameTemplate = file.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder('Example: gameTemplate.md') cb.setPlaceholder('Example: gameTemplate.md')
.setValue(this.plugin.settings.gameTemplate) .setValue(this.plugin.settings.gameTemplate)
.onChange(data => { .onChange(data => {
@ -551,7 +715,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Wiki template') .setName('Wiki template')
.setDesc('Template file to be used when creating a new note for a wiki entry.') .setDesc('Template file to be used when creating a new note for a wiki entry.')
.addSearch(cb => { .addSearch(cb => {
new FileSuggest(this.app, cb.inputEl); const suggester = new FileSuggest(this.app, cb.inputEl);
suggester.onSelect(file => {
cb.setValue(file.path);
this.plugin.settings.wikiTemplate = file.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder('Example: wikiTemplate.md') cb.setPlaceholder('Example: wikiTemplate.md')
.setValue(this.plugin.settings.wikiTemplate) .setValue(this.plugin.settings.wikiTemplate)
.onChange(data => { .onChange(data => {
@ -564,7 +734,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Music release template') .setName('Music release template')
.setDesc('Template file to be used when creating a new note for a music release.') .setDesc('Template file to be used when creating a new note for a music release.')
.addSearch(cb => { .addSearch(cb => {
new FileSuggest(this.app, cb.inputEl); const suggester = new FileSuggest(this.app, cb.inputEl);
suggester.onSelect(file => {
cb.setValue(file.path);
this.plugin.settings.musicReleaseTemplate = file.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder('Example: musicReleaseTemplate.md') cb.setPlaceholder('Example: musicReleaseTemplate.md')
.setValue(this.plugin.settings.musicReleaseTemplate) .setValue(this.plugin.settings.musicReleaseTemplate)
.onChange(data => { .onChange(data => {
@ -577,7 +753,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Board game template') .setName('Board game template')
.setDesc('Template file to be used when creating a new note for a boardgame.') .setDesc('Template file to be used when creating a new note for a boardgame.')
.addSearch(cb => { .addSearch(cb => {
new FileSuggest(this.app, cb.inputEl); const suggester = new FileSuggest(this.app, cb.inputEl);
suggester.onSelect(file => {
cb.setValue(file.path);
this.plugin.settings.boardgameTemplate = file.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder('Example: boardgameTemplate.md') cb.setPlaceholder('Example: boardgameTemplate.md')
.setValue(this.plugin.settings.boardgameTemplate) .setValue(this.plugin.settings.boardgameTemplate)
.onChange(data => { .onChange(data => {
@ -590,7 +772,13 @@ export class MediaDbSettingTab extends PluginSettingTab {
.setName('Book template') .setName('Book template')
.setDesc('Template file to be used when creating a new note for a book.') .setDesc('Template file to be used when creating a new note for a book.')
.addSearch(cb => { .addSearch(cb => {
new FileSuggest(this.app, cb.inputEl); const suggester = new FileSuggest(this.app, cb.inputEl);
suggester.onSelect(file => {
cb.setValue(file.path);
this.plugin.settings.bookTemplate = file.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder('Example: bookTemplate.md') cb.setPlaceholder('Example: bookTemplate.md')
.setValue(this.plugin.settings.bookTemplate) .setValue(this.plugin.settings.bookTemplate)
.onChange(data => { .onChange(data => {
@ -627,6 +815,18 @@ export class MediaDbSettingTab extends PluginSettingTab {
}); });
}); });
new Setting(containerEl)
.setName('Season file name template')
.setDesc('Template for the file name used when creating a new note for a season.')
.addText(cb => {
cb.setPlaceholder(`Example: ${DEFAULT_SETTINGS.seasonFileNameTemplate}`)
.setValue(this.plugin.settings.seasonFileNameTemplate)
.onChange(data => {
this.plugin.settings.seasonFileNameTemplate = data;
void this.plugin.saveSettings();
});
});
new Setting(containerEl) new Setting(containerEl)
.setName('Manga and comic file name template') .setName('Manga and comic file name template')
.setDesc('Template for the file name used when creating a new note for a manga or comic.') .setDesc('Template for the file name used when creating a new note for a manga or comic.')

View file

@ -1,29 +1,17 @@
import type { TAbstractFile } from 'obsidian'; import { AbstractInputSuggest, TFile } from 'obsidian';
import { TFile } from 'obsidian';
import { TextInputSuggest } from './Suggest';
export class FileSuggest extends TextInputSuggest<TFile> { export class FileSuggest extends AbstractInputSuggest<TFile> {
getSuggestions(inputStr: string): TFile[] { protected getSuggestions(query: string): TFile[] | Promise<TFile[]> {
const abstractFiles = this.app.vault.getAllLoadedFiles(); const lowerCaseInputStr = query.toLowerCase();
const files: TFile[] = [];
const lowerCaseInputStr = inputStr.toLowerCase();
abstractFiles.forEach((file: TAbstractFile) => { // we do two filters because otherwise TS type inference does convert the array to TFile[]
if (file instanceof TFile && file.name.toLowerCase().contains(lowerCaseInputStr)) { return this.app.vault
files.push(file); .getAllLoadedFiles()
} .filter(file => file instanceof TFile)
}); .filter(file => file.path.toLowerCase().contains(lowerCaseInputStr));
return files;
} }
renderSuggestion(file: TFile, el: HTMLElement): void { renderSuggestion(value: TFile, el: HTMLElement): void {
el.setText(file.path); el.setText(value.path);
}
selectSuggestion(file: TFile): void {
this.inputEl.value = file.path;
this.inputEl.trigger('input');
this.close();
} }
} }

View file

@ -1,31 +1,17 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes import { AbstractInputSuggest, TFolder } from 'obsidian';
import type { TAbstractFile } from 'obsidian'; export class FolderSuggest extends AbstractInputSuggest<TFolder> {
import { TFolder } from 'obsidian'; protected getSuggestions(query: string): TFolder[] | Promise<TFolder[]> {
import { TextInputSuggest } from './Suggest'; const lowerCaseInputStr = query.toLowerCase();
export class FolderSuggest extends TextInputSuggest<TFolder> { // we do two filters because otherwise TS type inference does convert the array to TFolder[]
getSuggestions(inputStr: string): TFolder[] { return this.app.vault
const abstractFiles = this.app.vault.getAllLoadedFiles(); .getAllLoadedFiles()
const folders: TFolder[] = []; .filter(file => file instanceof TFolder)
const lowerCaseInputStr = inputStr.toLowerCase(); .filter(file => file.path.toLowerCase().contains(lowerCaseInputStr));
abstractFiles.forEach((folder: TAbstractFile) => {
if (folder instanceof TFolder && folder.path.toLowerCase().contains(lowerCaseInputStr)) {
folders.push(folder);
}
});
return folders;
} }
renderSuggestion(file: TFolder, el: HTMLElement): void { renderSuggestion(value: TFolder, el: HTMLElement): void {
el.setText(file.path); el.setText(value.path);
}
selectSuggestion(file: TFolder): void {
this.inputEl.value = file.path;
this.inputEl.trigger('input');
this.close();
} }
} }

View file

@ -1,185 +0,0 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import type { Instance as PopperInstance } from '@popperjs/core';
import { createPopper } from '@popperjs/core';
import type { App, ISuggestOwner } from 'obsidian';
import { Scope } from 'obsidian';
import { wrapAround } from 'src/utils/Utils';
export class Suggest<T> {
private owner: ISuggestOwner<T>;
private values: T[];
private suggestions: HTMLElement[];
private selectedItem: number;
private containerEl: HTMLElement;
constructor(owner: ISuggestOwner<T>, containerEl: HTMLElement, scope: Scope) {
this.owner = owner;
this.containerEl = containerEl;
this.values = [];
this.suggestions = [];
this.selectedItem = 0;
containerEl.on('click', '.suggestion-item', (e, el) => this.onSuggestionClick(e, el));
containerEl.on('mousemove', '.suggestion-item', (e, el) => this.onSuggestionMouseover(e, el));
scope.register([], 'ArrowUp', event => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem - 1, true);
return false;
}
return undefined;
});
scope.register([], 'ArrowDown', event => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem + 1, true);
return false;
}
return undefined;
});
scope.register([], 'Enter', event => {
if (!event.isComposing) {
this.useSelectedItem(event);
return false;
}
return undefined;
});
}
onSuggestionClick(event: MouseEvent, el: HTMLElement): void {
event.preventDefault();
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
this.useSelectedItem(event);
}
onSuggestionMouseover(_event: MouseEvent, el: HTMLElement): void {
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
}
setSuggestions(values: T[]): void {
this.containerEl.empty();
const suggestionEls: HTMLDivElement[] = [];
values.forEach(value => {
const suggestionEl = this.containerEl.createDiv('suggestion-item');
this.owner.renderSuggestion(value, suggestionEl);
suggestionEls.push(suggestionEl);
});
this.values = values;
this.suggestions = suggestionEls;
this.setSelectedItem(0, false);
}
useSelectedItem(event: MouseEvent | KeyboardEvent): void {
const currentValue = this.values[this.selectedItem];
if (currentValue) {
this.owner.selectSuggestion(currentValue, event);
}
}
setSelectedItem(selectedIndex: number, scrollIntoView: boolean): void {
const normalizedIndex = this.suggestions.length > 0 ? wrapAround(selectedIndex, this.suggestions.length) : 0;
const prevSelectedSuggestion = this.suggestions[this.selectedItem];
const selectedSuggestion = this.suggestions[normalizedIndex];
prevSelectedSuggestion?.removeClass('is-selected');
selectedSuggestion?.addClass('is-selected');
this.selectedItem = normalizedIndex;
if (scrollIntoView) {
selectedSuggestion.scrollIntoView(false);
}
}
}
export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
protected app: App;
protected inputEl: HTMLInputElement;
private popper?: PopperInstance;
private scope: Scope;
private suggestEl: HTMLElement;
private suggest: Suggest<T>;
constructor(app: App, inputEl: HTMLInputElement) {
this.app = app;
this.inputEl = inputEl;
this.scope = new Scope();
this.suggestEl = createDiv('suggestion-container');
const suggestion = this.suggestEl.createDiv('suggestion');
this.suggest = new Suggest(this, suggestion, this.scope);
this.scope.register([], 'Escape', this.close.bind(this));
this.inputEl.addEventListener('input', this.onInputChanged.bind(this));
this.inputEl.addEventListener('focus', this.onInputChanged.bind(this));
this.inputEl.addEventListener('blur', this.close.bind(this));
this.suggestEl.on('mousedown', '.suggestion-container', (event: MouseEvent) => {
event.preventDefault();
});
}
onInputChanged(): void {
const inputStr = this.inputEl.value;
const suggestions = this.getSuggestions(inputStr);
if (suggestions.length > 0) {
this.suggest.setSuggestions(suggestions);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.open((this.app as any).dom.appContainerEl, this.inputEl);
}
}
open(container: HTMLElement, inputEl: HTMLElement): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.app as any).keymap.pushScope(this.scope);
container.appendChild(this.suggestEl);
this.popper = createPopper(inputEl, this.suggestEl, {
placement: 'bottom-start',
modifiers: [
{
name: 'sameWidth',
enabled: true,
fn: ({ state, instance }): void => {
// Note: positioning needs to be calculated twice -
// first pass - positioning it according to the width of the popper
// second pass - position it with the width bound to the reference element
// we need to early exit to avoid an infinite loop
const targetWidth = `${state.rects.reference.width}px`;
if (state.styles.popper.width === targetWidth) {
return;
}
state.styles.popper.width = targetWidth;
instance.update();
},
phase: 'beforeWrite',
requires: ['computeStyles'],
},
],
});
}
close(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.app as any).keymap.popScope(this.scope);
this.suggest.setSuggestions([]);
this.popper?.destroy();
this.suggestEl.detach();
}
abstract getSuggestions(inputStr: string): T[];
abstract renderSuggestion(item: T, el: HTMLElement): void;
abstract selectSuggestion(item: T): void;
}

View file

@ -0,0 +1,150 @@
import type { TFolder } from 'obsidian';
import { TFile } from 'obsidian';
import type MediaDbPlugin from 'src/main';
import { MediaDbBulkImportModal as MediaDbBulkImportModal } from 'src/modals/MediaDbBulkImportModal';
import type { MediaTypeModel } from 'src/models/MediaTypeModel';
import { ModalResultCode } from './ModalHelper';
import { dateTimeToString, markdownTable } from './Utils';
export enum BulkImportLookupMethod {
ID = 'id',
TITLE = 'title',
}
interface BulkImportError {
filePath: string;
error: string;
canceled?: boolean;
}
export class BulkImportHelper {
readonly plugin: MediaDbPlugin;
constructor(plugin: MediaDbPlugin) {
this.plugin = plugin;
}
async import(folder: TFolder): Promise<void> {
const erroredFiles: BulkImportError[] = [];
let canceled: boolean = false;
const { selectedAPI, lookupMethod, fieldName, appendContent } = await new Promise<{
selectedAPI: string;
lookupMethod: BulkImportLookupMethod;
fieldName: string;
appendContent: boolean;
}>(resolve => {
new MediaDbBulkImportModal(this.plugin, (selectedAPI: string, lookupMethod: BulkImportLookupMethod, fieldName: string, appendContent: boolean) => {
resolve({ selectedAPI, lookupMethod, fieldName, appendContent });
}).open();
});
for (const child of folder.children) {
if (!(child instanceof TFile)) {
continue;
}
const file: TFile = child;
if (canceled) {
erroredFiles.push({ filePath: file.path, error: 'user canceled' });
continue;
}
const metadata = this.plugin.getMetadataFromFileCache(file);
const lookupValue = metadata[fieldName];
if (!lookupValue || typeof lookupValue !== 'string') {
erroredFiles.push({ filePath: file.path, error: `metadata field '${fieldName}' not found, empty, or not a string` });
continue;
} else if (lookupMethod === BulkImportLookupMethod.ID) {
const error = await this.importById(file, lookupValue, selectedAPI, appendContent);
if (error) {
erroredFiles.push(error);
}
} else if (lookupMethod === BulkImportLookupMethod.TITLE) {
const error = await this.importByTitle(file, lookupValue, selectedAPI, appendContent);
if (error) {
if (error.canceled) {
canceled = true;
}
erroredFiles.push(error);
}
} else {
erroredFiles.push({ filePath: file.path, error: `invalid lookup type` });
continue;
}
}
if (erroredFiles.length > 0) {
await this.createErroredFilesReport(erroredFiles);
}
}
private async importById(file: TFile, lookupValue: string, selectedAPI: string, appendContent: boolean): Promise<BulkImportError | undefined> {
try {
const model = await this.plugin.apiManager.queryDetailedInfoById(lookupValue, selectedAPI);
if (model) {
await this.plugin.createMediaDbNotes([model], appendContent ? file : undefined);
return undefined;
} else {
return { filePath: file.path, error: `Failed to query API with id: ${lookupValue}` };
}
} catch (e) {
return { filePath: file.path, error: `${e}` };
}
}
private async importByTitle(file: TFile, lookupValue: string, selectedAPI: string, appendContent: boolean): Promise<BulkImportError | undefined> {
let results: MediaTypeModel[] = [];
try {
results = await this.plugin.apiManager.query(lookupValue, [selectedAPI]);
} catch (e) {
return { filePath: file.path, error: `${e}` };
}
if (!results || results.length === 0) {
return { filePath: file.path, error: `no search results` };
}
const { selectModalResult, selectModal } = await this.plugin.modalHelper.createSelectModal({
elements: results,
skipButton: true,
modalTitle: `Results for '${lookupValue}'`,
});
if (selectModalResult.code === ModalResultCode.ERROR) {
selectModal.close();
return { filePath: file.path, error: selectModalResult.error.message };
}
if (selectModalResult.code === ModalResultCode.CLOSE) {
selectModal.close();
return { filePath: file.path, error: 'user canceled', canceled: true };
}
if (selectModalResult.code === ModalResultCode.SKIP) {
selectModal.close();
return { filePath: file.path, error: 'user skipped' };
}
if (selectModalResult.data.selected.length === 0) {
selectModal.close();
return { filePath: file.path, error: `no search results selected` };
}
const detailedResults = await this.plugin.queryDetails(selectModalResult.data.selected);
await this.plugin.createMediaDbNotes(detailedResults, appendContent ? file : undefined);
selectModal.close();
return undefined;
}
private async createErroredFilesReport(erroredFiles: BulkImportError[]): Promise<void> {
const title = `MDB - bulk import error report ${dateTimeToString(new Date())}`;
const filePath = `${title}.md`;
const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error]));
const fileContent = `# ${title}\n\n${markdownTable(table)}`;
await this.plugin.app.vault.create(filePath, fileContent);
}
}

View file

@ -17,9 +17,7 @@ export class DateFormatter {
getPreview(format?: string): string { getPreview(format?: string): string {
const today = moment(); const today = moment();
if (!format) { format ??= this.toFormat;
format = this.toFormat;
}
return today.locale(this.locale).format(format); return today.locale(this.locale).format(format);
} }
@ -35,7 +33,7 @@ export class DateFormatter {
* from the locale of this machine. * from the locale of this machine.
* @returns formatted date string or null if `dateString` is not a valid date * @returns formatted date string or null if `dateString` is not a valid date
*/ */
format(dateString: string, dateFormat?: string, locale: string = 'en'): string | null { format(dateString: string | null | undefined, dateFormat?: string, locale: string = 'en'): string | null {
if (!dateString) { if (!dateString) {
return null; return null;
} }

View file

@ -0,0 +1,16 @@
// Illegal characters in the form `[illegal_character, replacement][]`
export const ILLEGAL_FILENAME_CHARACTERS = [
['/', '-'],
['\\', '-'],
['<', ''],
['>', ''],
[':', ' - '],
['"', "'"],
['|', ' - '],
['?', ''],
['*', ''],
['[', '('],
[']', ')'],
['^', ''],
['#', ''],
];

View file

@ -1,6 +1,7 @@
export enum MediaType { export enum MediaType {
Movie = 'movie', Movie = 'movie',
Series = 'series', Series = 'series',
Season = 'season',
ComicManga = 'comicManga', ComicManga = 'comicManga',
Game = 'game', Game = 'game',
MusicRelease = 'musicRelease', MusicRelease = 'musicRelease',

View file

@ -1,21 +1,24 @@
import type { App, TAbstractFile, TFile } from 'obsidian'; import type { App, TFile } from 'obsidian';
import { TFolder } from 'obsidian'; import { TFolder } from 'obsidian';
import { BoardGameModel } from '../models/BoardGameModel'; import { BoardGameModel } from '../models/BoardGameModel';
import { BookModel } from '../models/BookModel'; import { BookModel } from '../models/BookModel';
import { GameModel } from '../models/GameModel';
import { ComicMangaModel } from '../models/ComicMangaModel'; import { ComicMangaModel } from '../models/ComicMangaModel';
import { GameModel } from '../models/GameModel';
import type { MediaTypeModel } from '../models/MediaTypeModel'; import type { MediaTypeModel } from '../models/MediaTypeModel';
import { MovieModel } from '../models/MovieModel'; import { MovieModel } from '../models/MovieModel';
import { MusicReleaseModel } from '../models/MusicReleaseModel'; import { MusicReleaseModel } from '../models/MusicReleaseModel';
import { SeriesModel } from '../models/SeriesModel'; import { SeriesModel } from '../models/SeriesModel';
import { SeasonModel } from '../models/SeasonModel';
import { WikiModel } from '../models/WikiModel'; import { WikiModel } from '../models/WikiModel';
import type { MediaDbPluginSettings } from '../settings/Settings'; import type { MediaDbPluginSettings } from '../settings/Settings';
import { ILLEGAL_FILENAME_CHARACTERS } from './IllegalFilenameCharactersList';
import { MediaType } from './MediaType'; import { MediaType } from './MediaType';
import { replaceTags } from './Utils'; import { replaceTags } from './Utils';
export const MEDIA_TYPES: MediaType[] = [ export const MEDIA_TYPES: MediaType[] = [
MediaType.Movie, MediaType.Movie,
MediaType.Series, MediaType.Series,
MediaType.Season,
MediaType.ComicManga, MediaType.ComicManga,
MediaType.Game, MediaType.Game,
MediaType.Wiki, MediaType.Wiki,
@ -39,6 +42,7 @@ export class MediaTypeManager {
this.mediaFileNameTemplateMap = new Map<MediaType, string>(); this.mediaFileNameTemplateMap = new Map<MediaType, string>();
this.mediaFileNameTemplateMap.set(MediaType.Movie, settings.movieFileNameTemplate); this.mediaFileNameTemplateMap.set(MediaType.Movie, settings.movieFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.Series, settings.seriesFileNameTemplate); this.mediaFileNameTemplateMap.set(MediaType.Series, settings.seriesFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.Season, settings.seasonFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.ComicManga, settings.mangaFileNameTemplate); this.mediaFileNameTemplateMap.set(MediaType.ComicManga, settings.mangaFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.Game, settings.gameFileNameTemplate); this.mediaFileNameTemplateMap.set(MediaType.Game, settings.gameFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.Wiki, settings.wikiFileNameTemplate); this.mediaFileNameTemplateMap.set(MediaType.Wiki, settings.wikiFileNameTemplate);
@ -49,6 +53,7 @@ export class MediaTypeManager {
this.mediaTemplateMap = new Map<MediaType, string>(); this.mediaTemplateMap = new Map<MediaType, string>();
this.mediaTemplateMap.set(MediaType.Movie, settings.movieTemplate); this.mediaTemplateMap.set(MediaType.Movie, settings.movieTemplate);
this.mediaTemplateMap.set(MediaType.Series, settings.seriesTemplate); this.mediaTemplateMap.set(MediaType.Series, settings.seriesTemplate);
this.mediaTemplateMap.set(MediaType.Season, settings.seasonTemplate);
this.mediaTemplateMap.set(MediaType.ComicManga, settings.mangaTemplate); this.mediaTemplateMap.set(MediaType.ComicManga, settings.mangaTemplate);
this.mediaTemplateMap.set(MediaType.Game, settings.gameTemplate); this.mediaTemplateMap.set(MediaType.Game, settings.gameTemplate);
this.mediaTemplateMap.set(MediaType.Wiki, settings.wikiTemplate); this.mediaTemplateMap.set(MediaType.Wiki, settings.wikiTemplate);
@ -61,6 +66,7 @@ export class MediaTypeManager {
this.mediaFolderMap = new Map<MediaType, string>(); this.mediaFolderMap = new Map<MediaType, string>();
this.mediaFolderMap.set(MediaType.Movie, settings.movieFolder); this.mediaFolderMap.set(MediaType.Movie, settings.movieFolder);
this.mediaFolderMap.set(MediaType.Series, settings.seriesFolder); this.mediaFolderMap.set(MediaType.Series, settings.seriesFolder);
this.mediaFolderMap.set(MediaType.Season, settings.seasonFolder);
this.mediaFolderMap.set(MediaType.ComicManga, settings.mangaFolder); this.mediaFolderMap.set(MediaType.ComicManga, settings.mangaFolder);
this.mediaFolderMap.set(MediaType.Game, settings.gameFolder); this.mediaFolderMap.set(MediaType.Game, settings.gameFolder);
this.mediaFolderMap.set(MediaType.Wiki, settings.wikiFolder); this.mediaFolderMap.set(MediaType.Wiki, settings.wikiFolder);
@ -71,7 +77,14 @@ export class MediaTypeManager {
getFileName(mediaTypeModel: MediaTypeModel): string { getFileName(mediaTypeModel: MediaTypeModel): string {
// Ignore undefined tags since some search APIs do not return all properties in the model and produce clean file names even if errors occur // Ignore undefined tags since some search APIs do not return all properties in the model and produce clean file names even if errors occur
return replaceTags(this.mediaFileNameTemplateMap.get(mediaTypeModel.getMediaType())!, mediaTypeModel, true); const fileName = replaceTags(this.mediaFileNameTemplateMap.get(mediaTypeModel.getMediaType())!, mediaTypeModel, true);
return this.cleanFileName(fileName);
}
cleanFileName(fileName: string): string {
const cleanedFileName = ILLEGAL_FILENAME_CHARACTERS.reduce((str, char) => str.replaceAll(char[0], char[1]), fileName);
// Remove all duplicate whitespace in the file name
return cleanedFileName.replaceAll(/ +/g, ' ');
} }
async getTemplate(mediaTypeModel: MediaTypeModel, app: App): Promise<string> { async getTemplate(mediaTypeModel: MediaTypeModel, app: App): Promise<string> {
@ -104,9 +117,7 @@ export class MediaTypeManager {
async getFolder(mediaTypeModel: MediaTypeModel, app: App): Promise<TFolder> { async getFolder(mediaTypeModel: MediaTypeModel, app: App): Promise<TFolder> {
let folderPath = this.mediaFolderMap.get(mediaTypeModel.getMediaType()); let folderPath = this.mediaFolderMap.get(mediaTypeModel.getMediaType());
if (!folderPath) { folderPath ??= `/`;
folderPath = `/`;
}
// console.log(folderPath); // console.log(folderPath);
if (!(await app.vault.adapter.exists(folderPath))) { if (!(await app.vault.adapter.exists(folderPath))) {
@ -115,7 +126,7 @@ export class MediaTypeManager {
const folder = app.vault.getAbstractFileByPath(folderPath); const folder = app.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof TFolder)) { if (!(folder instanceof TFolder)) {
throw Error(`Expected ${folder} to be instance of TFolder`); throw Error(`Expected ${folder?.path} to be instance of TFolder`);
} }
return folder; return folder;
@ -127,11 +138,13 @@ export class MediaTypeManager {
* @param obj * @param obj
* @param mediaType * @param mediaType
*/ */
createMediaTypeModelFromMediaType(obj: any, mediaType: MediaType): MediaTypeModel { createMediaTypeModelFromMediaType(obj: object, mediaType: MediaType): MediaTypeModel {
if (mediaType === MediaType.Movie) { if (mediaType === MediaType.Movie) {
return new MovieModel(obj); return new MovieModel(obj);
} else if (mediaType === MediaType.Series) { } else if (mediaType === MediaType.Series) {
return new SeriesModel(obj); return new SeriesModel(obj);
} else if (mediaType === MediaType.Season) {
return new SeasonModel(obj);
} else if (mediaType === MediaType.ComicManga) { } else if (mediaType === MediaType.ComicManga) {
return new ComicMangaModel(obj); return new ComicMangaModel(obj);
} else if (mediaType === MediaType.Game) { } else if (mediaType === MediaType.Game) {

View file

@ -159,12 +159,13 @@ export interface IdSearchModalOptions {
* - skipButton: whether to add a skip button to the modal * - skipButton: whether to add a skip button to the modal
*/ */
export interface SelectModalOptions { export interface SelectModalOptions {
modalTitle?: string;
elements?: MediaTypeModel[]; elements?: MediaTypeModel[];
multiSelect?: boolean; multiSelect?: boolean;
modalTitle?: string;
skipButton?: boolean; skipButton?: boolean;
description?: string; // Add this
submitButtonText?: string; // Add this too
} }
/** /**
* Options for the preview modal. * Options for the preview modal.
* - modalTitle: the title of the modal * - modalTitle: the title of the modal
@ -189,7 +190,7 @@ export const ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS: AdvancedSearchModalOptions =
export const ID_SEARCH_MODAL_DEFAULT_OPTIONS: IdSearchModalOptions = { export const ID_SEARCH_MODAL_DEFAULT_OPTIONS: IdSearchModalOptions = {
modalTitle: 'Media DB Id Search', modalTitle: 'Media DB Id Search',
preselectedAPI: '', preselectedAPI: undefined,
prefilledSearchString: '', prefilledSearchString: '',
}; };
@ -205,6 +206,15 @@ export const PREVIEW_MODAL_DEFAULT_OPTIONS: PreviewModalOptions = {
elements: [], elements: [],
}; };
export const SELECTMODALOPTIONSDEFAULT: SelectModalOptions = {
elements: [],
multiSelect: true,
modalTitle: '',
skipButton: false,
description: 'Select one or multiple search results.',
submitButtonText: 'Ok',
};
/** /**
* A class providing multiple usefull functions for dealing with the plugins modals. * A class providing multiple usefull functions for dealing with the plugins modals.
*/ */
@ -505,12 +515,12 @@ export class ModalHelper {
console.warn(previewModalResult.error); console.warn(previewModalResult.error);
new Notice(previewModalResult.error.toString()); new Notice(previewModalResult.error.toString());
previewModal.close(); previewModal.close();
return true; return false;
} }
if (previewModalResult.code === ModalResultCode.CLOSE) { if (previewModalResult.code === ModalResultCode.CLOSE) {
// modal is already being closed // modal is already being closed
return true; return false;
} }
try { try {

View file

@ -0,0 +1,16 @@
import type { App } from 'obsidian';
import type { SeasonSelectModalElement } from '../modals/MediaDbSeasonSelectModal';
import { MediaDbSeasonSelectModal } from '../modals/MediaDbSeasonSelectModal';
export async function openSeasonSelectModal(app: App, plugin: any, seasons: SeasonSelectModalElement[]): Promise<SeasonSelectModalElement[] | undefined> {
return new Promise(resolve => {
const modal = new MediaDbSeasonSelectModal(plugin, seasons, true);
modal.setSubmitCallback(selected => {
resolve(selected);
});
modal.setCloseCallback(() => {
resolve(undefined);
});
modal.open();
});
}

View file

@ -1,4 +1,6 @@
import { iso6392 } from 'iso-639-2';
import type { TFile, TFolder, App } from 'obsidian'; import type { TFile, TFolder, App } from 'obsidian';
import { requestUrl } from 'obsidian';
import type { MediaTypeModel } from '../models/MediaTypeModel'; import type { MediaTypeModel } from '../models/MediaTypeModel';
export const pluginName: string = 'obsidian-media-db-plugin'; export const pluginName: string = 'obsidian-media-db-plugin';
@ -42,7 +44,8 @@ function replaceTag(match: string, mediaTypeModel: MediaTypeModel, ignoreUndefin
return ignoreUndefined ? '' : '{{ INVALID TEMPLATE TAG - object undefined }}'; return ignoreUndefined ? '' : '{{ INVALID TEMPLATE TAG - object undefined }}';
} }
return obj; // eslint-disable-next-line @typescript-eslint/no-base-to-string
return obj?.toString() ?? 'null';
} else if (parts.length === 2) { } else if (parts.length === 2) {
const operator = parts[0]; const operator = parts[0];
@ -58,7 +61,8 @@ function replaceTag(match: string, mediaTypeModel: MediaTypeModel, ignoreUndefin
if (!Array.isArray(obj)) { if (!Array.isArray(obj)) {
return '{{ INVALID TEMPLATE TAG - operator LIST is only applicable on an array }}'; return '{{ INVALID TEMPLATE TAG - operator LIST is only applicable on an array }}';
} }
return obj.map((e: any) => `- ${e}`).join('\n');
return obj.map((e: unknown) => `- ${e}`).join('\n');
} else if (operator === 'ENUM') { } else if (operator === 'ENUM') {
if (!Array.isArray(obj)) { if (!Array.isArray(obj)) {
return '{{ INVALID TEMPLATE TAG - operator ENUM is only applicable on an array }}'; return '{{ INVALID TEMPLATE TAG - operator ENUM is only applicable on an array }}';
@ -68,12 +72,16 @@ function replaceTag(match: string, mediaTypeModel: MediaTypeModel, ignoreUndefin
if (!Array.isArray(obj)) { if (!Array.isArray(obj)) {
return '{{ INVALID TEMPLATE TAG - operator FIRST is only applicable on an array }}'; return '{{ INVALID TEMPLATE TAG - operator FIRST is only applicable on an array }}';
} }
return obj[0];
const first = obj[0] as unknown;
return first?.toString() ?? 'null';
} else if (operator === 'LAST') { } else if (operator === 'LAST') {
if (!Array.isArray(obj)) { if (!Array.isArray(obj)) {
return '{{ INVALID TEMPLATE TAG - operator LAST is only applicable on an array }}'; return '{{ INVALID TEMPLATE TAG - operator LAST is only applicable on an array }}';
} }
return obj[obj.length - 1];
const last = obj[obj.length - 1] as unknown;
return last?.toString() ?? 'null';
} }
return `{{ INVALID TEMPLATE TAG - unknown operator ${operator} }}`; return `{{ INVALID TEMPLATE TAG - unknown operator ${operator} }}`;
@ -82,12 +90,12 @@ function replaceTag(match: string, mediaTypeModel: MediaTypeModel, ignoreUndefin
return '{{ INVALID TEMPLATE TAG }}'; return '{{ INVALID TEMPLATE TAG }}';
} }
function traverseMetaData(path: string[], mediaTypeModel: MediaTypeModel): any { function traverseMetaData(path: string[], mediaTypeModel: MediaTypeModel): unknown {
let o: any = mediaTypeModel; let o: unknown = mediaTypeModel;
for (const part of path) { for (const part of path) {
if (o !== undefined) { if (o !== undefined) {
o = o[part]; o = (o as Record<string, unknown>)[part];
} }
} }
@ -195,9 +203,9 @@ export interface CreateNoteOptions {
folder?: TFolder; folder?: TFolder;
} }
export function migrateObject<T extends object>(object: T, oldData: any, defaultData: T): void { export function migrateObject<T extends object>(object: T, oldData: Record<string, unknown>, defaultData: T): void {
for (const key in object) { for (const key in object) {
object[key] = oldData.hasOwnProperty(key) ? oldData[key] : defaultData[key]; object[key] = Object.hasOwn(oldData, key) && oldData[key] !== undefined && oldData[key] !== null ? (oldData[key] as T[typeof key]) : defaultData[key];
} }
} }
@ -215,6 +223,8 @@ export function unCamelCase(str: string): string {
); );
} }
/* eslint-disable */
export function hasTemplaterPlugin(app: App): boolean { export function hasTemplaterPlugin(app: App): boolean {
const templater = (app as any).plugins.plugins['templater-obsidian']; const templater = (app as any).plugins.plugins['templater-obsidian'];
@ -224,13 +234,72 @@ export function hasTemplaterPlugin(app: App): boolean {
// Copied from https://github.com/anpigon/obsidian-book-search-plugin // Copied from https://github.com/anpigon/obsidian-book-search-plugin
// Licensed under the MIT license. Copyright (c) 2020 Jake Runzer // Licensed under the MIT license. Copyright (c) 2020 Jake Runzer
export async function useTemplaterPluginInFile(app: App, file: TFile): Promise<void> { export async function useTemplaterPluginInFile(app: App, file: TFile): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const templater = (app as any).plugins.plugins['templater-obsidian']; const templater = (app as any).plugins.plugins['templater-obsidian'];
if (templater && !templater?.settings.trigger_on_file_creation) { if (templater && !templater?.settings.trigger_on_file_creation) {
await templater.templater.overwrite_file_commands(file); await templater.templater.overwrite_file_commands(file);
} }
} }
/* eslint-enable */
export type ModelToData<T> = { export type ModelToData<T> = {
[K in keyof T as T[K] extends Function ? never : K]?: T[K]; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
[K in keyof T as T[K] extends Function ? never : K]?: T[K] | null;
}; };
// Checks if a given URL points to an existing image (status 200), or returns false for 404/other errors.
export async function imageUrlExists(url: string): Promise<boolean> {
try {
// @ts-ignore
const response = await requestUrl({
url,
method: 'HEAD',
throw: false,
});
return response.status === 200;
} catch {
return false;
}
}
export function isTruthy<T>(value: T): value is Exclude<T, false | 0 | '' | null | undefined> {
return Boolean(value);
}
/**
* Wraps Obsidians `requestUrl` in a fetch like API.
*/
export async function obsidianFetch(input: Request): Promise<Response> {
const obs_headers: Record<string, string> = {};
input.headers.forEach((header, value) => {
obs_headers[header] = value;
});
const res = await requestUrl({
url: input.url,
method: input.method,
headers: obs_headers,
throw: false, // Do not throw on error, handle it manually
});
const responseHeaders: Headers = new Headers();
for (const [key, value] of Object.entries(res.headers)) {
responseHeaders.append(key, value);
}
return {
ok: res.status >= 200 && res.status < 300,
status: res.status,
headers: responseHeaders,
// eslint-disable-next-line
json: async () => res.json,
text: async () => res.text,
} as Response;
}
export function getLanguageName(code: string): string | null {
const language = iso6392.find(lang => lang.iso6392B === code || lang.iso6392T === code);
return language?.name ?? null;
}

5
test/placeholder.test.ts Normal file
View file

@ -0,0 +1,5 @@
import { test, expect } from 'bun:test';
test('placeholder test', () => {
expect(true).toBe(true);
});