diff --git a/.gitignore b/.gitignore index af90ec9..2c3c4af 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ exampleVault/.obsidian/plugins/obsidian-media-db-plugin/* !exampleVault/.obsidian/plugins/obsidian-media-db-plugin/.hotreload exampleVault/Media DB/* +meta.txt diff --git a/README.md b/README.md index d28a75f..ba4da7f 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Now you select the result you want, and the plugin will cast its magic, creating | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | [Jikan](https://jikan.moe/) | Jikan is an API that uses [My Anime List](https://myanimelist.net) and offers metadata for anime. | series, movies, specials, OVAs, manga, manwha, novels | No | 60 per minute and 3 per second | Yes | | [OMDb](https://www.omdbapi.com/) | OMDb is an API that offers metadata for movies, series, and games. | series, movies, games | Yes, you can get a free key here [here](https://www.omdbapi.com/apikey.aspx) | 1000 per day | No | +| [TMDB](https://www.themoviedb.org/) | TMDB is a API that offers community editable metadata for movies and series. | series, movies | Yes, by making an account [here](https://www.themoviedb.org/signup) and getting your `API Key` (**not** `API Read Access Token`) [here](https://www.themoviedb.org/settings/api) | 50 per second | Yes | | [MusicBrainz](https://musicbrainz.org/) | MusicBrainz is an API that offers information about music releases. | music releases | No | 50 per second | No | | [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) | The Wikipedia API allows access to all Wikipedia articles. | wiki articles | No | None | No | | [Steam](https://store.steampowered.com/) | The Steam API offers information on all Steam games. | games | No | 10000 per day | No | diff --git a/src/api/apis/OpenLibraryAPI.ts b/src/api/apis/OpenLibraryAPI.ts index d5c041e..e3590a0 100644 --- a/src/api/apis/OpenLibraryAPI.ts +++ b/src/api/apis/OpenLibraryAPI.ts @@ -69,7 +69,7 @@ export class OpenLibraryAPI extends APIModel { year: result.first_publish_year?.toString() ?? 'unknown', dataSource: this.apiName, id: result.key, - author: result.author_name.join(', '), + author: result.author_name?.join(', '), }), ); } @@ -119,7 +119,7 @@ export class OpenLibraryAPI extends APIModel { isbn13: Number.isNaN(isbn13) ? undefined : isbn13, englishTitle: result.title, - author: result.author_name.join(', '), + author: result.author_name?.join(', '), pages: Number.isNaN(pages) ? undefined : pages, onlineRating: result.ratings_average, image: result.cover_edition_key ? `https://covers.openlibrary.org/b/OLID/` + result.cover_edition_key + `-L.jpg` : undefined, diff --git a/src/api/apis/TMDBMovieAPI.ts b/src/api/apis/TMDBMovieAPI.ts new file mode 100644 index 0000000..71494d8 --- /dev/null +++ b/src/api/apis/TMDBMovieAPI.ts @@ -0,0 +1,130 @@ +import { Notice, renderResults } from 'obsidian'; +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'; + +export class TMDBMovieAPI extends APIModel { + plugin: MediaDbPlugin; + typeMappings: Map; + 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(); + this.typeMappings.set('movie', 'movie'); + } + + async searchByTitle(title: string): Promise { + 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 searchUrl = `https://api.themoviedb.org/3/search/movie?api_key=${this.plugin.settings.TMDBKey}&query=${encodeURIComponent(title)}&include_adult=${this.plugin.settings.sfwFilter ? 'false' : 'true'}`; + const fetchData = await fetch(searchUrl); + + if (fetchData.status === 401) { + throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); + } + if (fetchData.status !== 200) { + throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); + } + + const data = await fetchData.json(); + + if (data.total_results === 0) { + if (data.Error === 'Movie not found!') { + return []; + } + + throw Error(`MDB | Received error from ${this.apiName}: \n${JSON.stringify(data, undefined, 4)}`); + } + if (!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, + }), + ); + } + + return ret; + } + + async getById(id: string): Promise { + console.log(`MDB | api "${this.apiName}" queried by ID`); + + if (!this.plugin.settings.TMDBKey) { + throw Error(`MDB | API key for ${this.apiName} missing.`); + } + + const searchUrl = `https://api.themoviedb.org/3/movie/${encodeURIComponent(id)}?api_key=${this.plugin.settings.TMDBKey}&append_to_response=credits`; + const fetchData = await fetch(searchUrl); + + if (fetchData.status === 401) { + throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); + } + if (fetchData.status !== 200) { + throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); + } + + const result = await fetchData.json(); + // 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, + + plot: result.overview ?? '', + genres: result.genres.map((g: any) => g.name) ?? [], + writer: result.credits.crew.filter((c: any) => c.job === 'Screenplay').map((c: any) => c.name) ?? [], + 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 ?? 'unknown', + onlineRating: result.vote_average, + 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 as MediaType[]; + } +} diff --git a/src/api/apis/TMDBSeasonAPI.ts b/src/api/apis/TMDBSeasonAPI.ts new file mode 100644 index 0000000..d0ea90f --- /dev/null +++ b/src/api/apis/TMDBSeasonAPI.ts @@ -0,0 +1,198 @@ +import { Notice, renderResults } from 'obsidian'; +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'; + +export class TMDBSeasonAPI extends APIModel { + plugin: MediaDbPlugin; + typeMappings: Map; + 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(); + this.typeMappings.set('tv', 'season'); + } + + async searchByTitle(title: string): Promise { + 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 searchUrl = `https://api.themoviedb.org/3/search/tv?api_key=${this.plugin.settings.TMDBKey}&query=${encodeURIComponent(title)}&include_adult=${this.plugin.settings.sfwFilter ? 'false' : 'true'}`; + const searchResp = await fetch(searchUrl); + + if (searchResp.status === 401) { + throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); + } + if (searchResp.status !== 200) { + throw Error(`MDB | Received status code ${searchResp.status} from ${this.apiName}.`); + } + + const searchData = await searchResp.json(); + 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 detailsUrl = `https://api.themoviedb.org/3/tv/${encodeURIComponent(result.id)}?api_key=${this.plugin.settings.TMDBKey}`; + const detailsResp = await fetch(detailsUrl); + if (detailsResp.status === 200) { + const detailsData = await detailsResp.json(); + 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 { + if (!this.plugin.settings.TMDBKey) { + throw new Error(`MDB | API key for ${this.apiName} missing.`); + } + const seriesUrl = `https://api.themoviedb.org/3/tv/${encodeURIComponent(tvId)}?api_key=${this.plugin.settings.TMDBKey}`; + const seriesResp = await fetch(seriesUrl); + if (seriesResp.status === 401) { + throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); + } + if (seriesResp.status !== 200) { + throw Error(`MDB | Received status code ${seriesResp.status} from ${this.apiName}.`); + } + const seriesData = await seriesResp.json(); + 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 { + 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 "/season/".`); + } + + const tvId = m[1]; + const seasonNumber = m[2]; + + const seasonUrl = `https://api.themoviedb.org/3/tv/${encodeURIComponent(tvId)}/season/${encodeURIComponent(seasonNumber)}?api_key=${this.plugin.settings.TMDBKey}`; + const seasonResp = await fetch(seasonUrl); + + if (seasonResp.status === 401) { + throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); + } + if (seasonResp.status !== 200) { + throw Error(`MDB | Received status code ${seasonResp.status} from ${this.apiName}.`); + } + + const seasonData = await seasonResp.json(); + + // Fetch parent series to build consistent titles and inherit fields + const seriesUrl = `https://api.themoviedb.org/3/tv/${encodeURIComponent(tvId)}?api_key=${this.plugin.settings.TMDBKey}&append_to_response=credits`; + const seriesResp = await fetch(seriesUrl); + + if (seriesResp.status === 401) { + throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); + } + if (seriesResp.status !== 200) { + throw Error(`MDB | Received status code ${seriesResp.status} from ${this.apiName}.`); + } + + const seriesData = await seriesResp.json(); + 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 : (seasonData.episodes ?? 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: any) => g.name) ?? [], + writer: seriesData.created_by?.map((c: any) => c.name) ?? [], + studio: seriesData.production_companies?.map((s: any) => s.name) ?? [], + duration: seriesData.episode_run_time?.[0]?.toString() ?? '', + onlineRating: seasonData.vote_average ?? 0, + 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 }, + }); + } + + // Settings didn’t define TMDBSeasonAPIdisabledMediaTypes yet; return an empty list for now + getDisabledMediaTypes(): MediaType[] { + return []; + } +} diff --git a/src/api/apis/TMDBSeriesAPI.ts b/src/api/apis/TMDBSeriesAPI.ts new file mode 100644 index 0000000..1fcf383 --- /dev/null +++ b/src/api/apis/TMDBSeriesAPI.ts @@ -0,0 +1,131 @@ +import { Notice, renderResults } from 'obsidian'; +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'; + +export class TMDBSeriesAPI extends APIModel { + plugin: MediaDbPlugin; + typeMappings: Map; + 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(); + this.typeMappings.set('tv', 'series'); + } + + async searchByTitle(title: string): Promise { + 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 searchUrl = `https://api.themoviedb.org/3/search/tv?api_key=${this.plugin.settings.TMDBKey}&query=${encodeURIComponent(title)}&include_adult=${this.plugin.settings.sfwFilter ? 'false' : 'true'}`; + const fetchData = await fetch(searchUrl); + + if (fetchData.status === 401) { + throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); + } + if (fetchData.status !== 200) { + throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); + } + + const data = await fetchData.json(); + + if (data.total_results === 0) { + if (data.Error === 'Series not found!') { + return []; + } + + throw Error(`MDB | Received error from ${this.apiName}: \n${JSON.stringify(data, undefined, 4)}`); + } + if (!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, + }), + ); + } + + return ret; + } + + async getById(id: string): Promise { + console.log(`MDB | api "${this.apiName}" queried by ID`); + + if (!this.plugin.settings.TMDBKey) { + throw Error(`MDB | API key for ${this.apiName} missing.`); + } + + const searchUrl = `https://api.themoviedb.org/3/tv/${encodeURIComponent(id)}?api_key=${this.plugin.settings.TMDBKey}&append_to_response=credits`; + const fetchData = await fetch(searchUrl); + + if (fetchData.status === 401) { + throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); + } + if (fetchData.status !== 200) { + throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); + } + + const result = await fetchData.json(); + // 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, + + 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] ?? 'unknown', + onlineRating: result.vote_average, + actors: result.credits.cast.map((c: any) => c.name).slice(0, 5) ?? [], + image: `https://image.tmdb.org/t/p/w780${result.poster_path}`, + + 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 as MediaType[]; + } +} diff --git a/src/main.ts b/src/main.ts index 4c69dc4..323d00c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,9 @@ import { MusicBrainzAPI } from './api/apis/MusicBrainzAPI'; import { OMDbAPI } from './api/apis/OMDbAPI'; import { OpenLibraryAPI } from './api/apis/OpenLibraryAPI'; 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 { ConfirmOverwriteModal } from './modals/ConfirmOverwriteModal'; import type { MediaTypeModel } from './models/MediaTypeModel'; @@ -56,6 +59,9 @@ export default class MediaDbPlugin extends Plugin { this.apiManager.registerAPI(new WikipediaAPI(this)); this.apiManager.registerAPI(new MusicBrainzAPI(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 OpenLibraryAPI(this)); this.apiManager.registerAPI(new ComicVineAPI(this)); @@ -212,14 +218,69 @@ export default class MediaDbPlugin extends Plugin { const proceed: boolean = false; while (!proceed) { - selectResults = - (await this.modalHelper.openSelectModal({ elements: apiSearchResults }, async selectModalData => { - return await this.queryDetails(selectModalData.selected); - })) ?? []; + if (types.length === 1 && types[0] === 'season') { + selectResults = + (await this.modalHelper.openSelectModal({ elements: apiSearchResults }, 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; } + // Only show the season select modal if the user searches for seasons + if (types.length === 1 && types[0] === 'season' && selectResults.length === 1 && selectResults[0].dataSource === 'TMDBSeasonAPI') { + // Dynamically import the modal + const { MediaDbSeasonSelectModal } = await import('./modals/MediaDbSeasonSelectModal'); + const TMDBSeasonAPI = this.apiManager.getApiByName('TMDBSeasonAPI') as import('./api/apis/TMDBSeasonAPI').TMDBSeasonAPI; + if (!TMDBSeasonAPI) { + new Notice('TMDBSeasonAPI not found.'); + return; + } + // 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; + } + // 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; + } + // Fetch full metadata for each selected seasond 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; + } + const confirmed = await this.modalHelper.openPreviewModal({ elements: selectResults }, async previewModalData => { return previewModalData.confirmed; }); diff --git a/src/modals/MediaDbSeasonSelectModal.ts b/src/modals/MediaDbSeasonSelectModal.ts new file mode 100644 index 0000000..1bde2d7 --- /dev/null +++ b/src/modals/MediaDbSeasonSelectModal.ts @@ -0,0 +1,49 @@ +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 { + 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 Season(s) for${seriesName ? `: ${seriesName}` : ''}`; + this.description = 'Select one or more seasons to create notes for.'; + } + + 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; + } +} diff --git a/src/models/SeasonModel.ts b/src/models/SeasonModel.ts new file mode 100644 index 0000000..07f9626 --- /dev/null +++ b/src/models/SeasonModel.ts @@ -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; + +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'; + } +} diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index c7e5ac7..255f0b7 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -13,6 +13,7 @@ import { FolderSuggest } from './suggesters/FolderSuggest'; export interface MediaDbPluginSettings { OMDbKey: string; + TMDBKey: string; MobyGamesKey: string; GiantBombKey: string; ComicVineKey: string; @@ -23,6 +24,9 @@ export interface MediaDbPluginSettings { useDefaultFrontMatter: boolean; enableTemplaterIntegration: boolean; OMDbAPI_disabledMediaTypes: MediaType[]; + TMDBSeriesAPI_disabledMediaTypes: MediaType[]; + TMDBSeasonAPI_disabledMediaTypes: MediaType[]; + TMDBMovieAPI_disabledMediaTypes: MediaType[]; MALAPI_disabledMediaTypes: MediaType[]; MALAPIManga_disabledMediaTypes: MediaType[]; ComicVineAPI_disabledMediaTypes: MediaType[]; @@ -35,6 +39,7 @@ export interface MediaDbPluginSettings { OpenLibraryAPI_disabledMediaTypes: MediaType[]; movieTemplate: string; seriesTemplate: string; + seasonTemplate: string; mangaTemplate: string; gameTemplate: string; wikiTemplate: string; @@ -44,6 +49,7 @@ export interface MediaDbPluginSettings { movieFileNameTemplate: string; seriesFileNameTemplate: string; + seasonFileNameTemplate: string; mangaFileNameTemplate: string; gameFileNameTemplate: string; wikiFileNameTemplate: string; @@ -53,6 +59,7 @@ export interface MediaDbPluginSettings { moviePropertyConversionRules: string; seriesPropertyConversionRules: string; + seasonPropertyConversionRules: string; mangaPropertyConversionRules: string; gamePropertyConversionRules: string; wikiPropertyConversionRules: string; @@ -62,6 +69,7 @@ export interface MediaDbPluginSettings { movieFolder: string; seriesFolder: string; + seasonFolder: string; mangaFolder: string; gameFolder: string; wikiFolder: string; @@ -76,6 +84,7 @@ export interface MediaDbPluginSettings { const DEFAULT_SETTINGS: MediaDbPluginSettings = { OMDbKey: '', + TMDBKey: '', MobyGamesKey: '', GiantBombKey: '', ComicVineKey: '', @@ -86,6 +95,9 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { useDefaultFrontMatter: true, enableTemplaterIntegration: false, OMDbAPI_disabledMediaTypes: [], + TMDBSeriesAPI_disabledMediaTypes: [], + TMDBSeasonAPI_disabledMediaTypes: [], + TMDBMovieAPI_disabledMediaTypes: [], MALAPI_disabledMediaTypes: [], MALAPIManga_disabledMediaTypes: [], ComicVineAPI_disabledMediaTypes: [], @@ -98,6 +110,7 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { OpenLibraryAPI_disabledMediaTypes: [], movieTemplate: '', seriesTemplate: '', + seasonTemplate: '', mangaTemplate: '', gameTemplate: '', wikiTemplate: '', @@ -107,6 +120,7 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { movieFileNameTemplate: '{{ title }} ({{ year }})', seriesFileNameTemplate: '{{ title }} ({{ year }})', + seasonFileNameTemplate: '{{ title }} ({{ year }})', mangaFileNameTemplate: '{{ title }} ({{ year }})', gameFileNameTemplate: '{{ title }} ({{ year }})', wikiFileNameTemplate: '{{ title }}', @@ -116,6 +130,7 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { moviePropertyConversionRules: '', seriesPropertyConversionRules: '', + seasonPropertyConversionRules: '', mangaPropertyConversionRules: '', gamePropertyConversionRules: '', wikiPropertyConversionRules: '', @@ -125,6 +140,7 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { movieFolder: 'Media DB/movies', seriesFolder: 'Media DB/series', + seasonFolder: 'Media DB/series', mangaFolder: 'Media DB/comics', gameFolder: 'Media DB/games', wikiFolder: 'Media DB/wiki', @@ -396,6 +412,19 @@ 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) .setName('Comic and manga folder') .setDesc('Where newly imported comics and manga should be placed.') @@ -503,6 +532,19 @@ 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) .setName('Manga and Comics template') .setDesc('Template file to be used when creating a new note for a manga or a comic.') @@ -609,6 +651,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) .setName('Manga and comic file name template') .setDesc('Template for the file name used when creating a new note for a manga or comic.') diff --git a/src/utils/MediaType.ts b/src/utils/MediaType.ts index 02d1c2c..857050f 100644 --- a/src/utils/MediaType.ts +++ b/src/utils/MediaType.ts @@ -1,6 +1,7 @@ export enum MediaType { Movie = 'movie', Series = 'series', + Season = 'season', ComicManga = 'comicManga', Game = 'game', MusicRelease = 'musicRelease', diff --git a/src/utils/MediaTypeManager.ts b/src/utils/MediaTypeManager.ts index 6f60c3b..b9e1419 100644 --- a/src/utils/MediaTypeManager.ts +++ b/src/utils/MediaTypeManager.ts @@ -8,6 +8,7 @@ import type { MediaTypeModel } from '../models/MediaTypeModel'; import { MovieModel } from '../models/MovieModel'; import { MusicReleaseModel } from '../models/MusicReleaseModel'; import { SeriesModel } from '../models/SeriesModel'; +import { SeasonModel } from '../models/SeasonModel'; import { WikiModel } from '../models/WikiModel'; import type { MediaDbPluginSettings } from '../settings/Settings'; import { ILLEGAL_FILENAME_CHARACTERS } from './IllegalFilenameCharactersList'; @@ -17,6 +18,7 @@ import { replaceTags } from './Utils'; export const MEDIA_TYPES: MediaType[] = [ MediaType.Movie, MediaType.Series, + MediaType.Season, MediaType.ComicManga, MediaType.Game, MediaType.Wiki, @@ -40,6 +42,7 @@ export class MediaTypeManager { this.mediaFileNameTemplateMap = new Map(); this.mediaFileNameTemplateMap.set(MediaType.Movie, settings.movieFileNameTemplate); 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.Game, settings.gameFileNameTemplate); this.mediaFileNameTemplateMap.set(MediaType.Wiki, settings.wikiFileNameTemplate); @@ -50,6 +53,7 @@ export class MediaTypeManager { this.mediaTemplateMap = new Map(); this.mediaTemplateMap.set(MediaType.Movie, settings.movieTemplate); 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.Game, settings.gameTemplate); this.mediaTemplateMap.set(MediaType.Wiki, settings.wikiTemplate); @@ -62,6 +66,7 @@ export class MediaTypeManager { this.mediaFolderMap = new Map(); this.mediaFolderMap.set(MediaType.Movie, settings.movieFolder); 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.Game, settings.gameFolder); this.mediaFolderMap.set(MediaType.Wiki, settings.wikiFolder); @@ -138,6 +143,8 @@ export class MediaTypeManager { return new MovieModel(obj); } else if (mediaType === MediaType.Series) { return new SeriesModel(obj); + } else if (mediaType === MediaType.Season) { + return new SeasonModel(obj); } else if (mediaType === MediaType.ComicManga) { return new ComicMangaModel(obj); } else if (mediaType === MediaType.Game) { diff --git a/src/utils/SeasonModalHelper.ts b/src/utils/SeasonModalHelper.ts new file mode 100644 index 0000000..93002df --- /dev/null +++ b/src/utils/SeasonModalHelper.ts @@ -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 { + return new Promise(resolve => { + const modal = new MediaDbSeasonSelectModal(plugin, seasons, true); + modal.setSubmitCallback(selected => { + resolve(selected); + }); + modal.setCloseCallback(() => { + resolve(undefined); + }); + modal.open(); + }); +}