From 0bb17177eec964375f86a8e6555af2a5ca4a3fac Mon Sep 17 00:00:00 2001 From: ltctceplrm <14954927+ltctceplrm@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:57:33 +0200 Subject: [PATCH 1/6] WIP season search --- README.md | 1 + src/api/apis/TMDBMovieAPI.ts | 130 ++++++++++++++++++++++++++ src/api/apis/TMDBSeasonAPI.ts | 169 ++++++++++++++++++++++++++++++++++ src/api/apis/TMDBSeriesAPI.ts | 131 ++++++++++++++++++++++++++ src/main.ts | 6 ++ src/models/SeasonModel.ts | 80 ++++++++++++++++ src/settings/Settings.ts | 54 +++++++++++ src/utils/MediaType.ts | 1 + src/utils/MediaTypeManager.ts | 7 ++ 9 files changed, 579 insertions(+) create mode 100644 src/api/apis/TMDBMovieAPI.ts create mode 100644 src/api/apis/TMDBSeasonAPI.ts create mode 100644 src/api/apis/TMDBSeriesAPI.ts create mode 100644 src/models/SeasonModel.ts diff --git a/README.md b/README.md index 08131e6..e877f81 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ Now you select the result you want and the plugin will cast it's magic and creat | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | [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 | +| [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/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..dab2fc2 --- /dev/null +++ b/src/api/apis/TMDBSeasonAPI.ts @@ -0,0 +1,169 @@ +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.`); + } + + // 1) Search for series + 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.total_results === 0 || !searchData.results) { + return []; + } + + const ret: MediaTypeModel[] = []; + + // 2) For each series result, fetch its seasons and flatten into SeasonModel entries (cap total to 20) + for (const series of searchData.results) { + if (ret.length >= 20) break; + + const tvId = series.id; + const seriesUrl = `https://api.themoviedb.org/3/tv/${encodeURIComponent(tvId)}?api_key=${this.plugin.settings.TMDBKey}`; + const tvResp = await fetch(seriesUrl); + + if (tvResp.status === 401) { + throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); + } + if (tvResp.status !== 200) { + // Skip this series if it fails; do not abort the whole search + console.warn(`MDB | Skipping series ${tvId} due to status ${tvResp.status}`); + continue; + } + + const tvData = await tvResp.json(); + const seriesName = tvData?.name ?? series?.name ?? series?.original_name ?? ''; + + if (Array.isArray(tvData?.seasons)) { + for (const season of tvData.seasons) { + if (ret.length >= 20) break; + + // Some seasons (e.g., specials) may have limited metadata; handle gracefully + const seasonNumber = season.season_number ?? 0; + const airDate = season.air_date ?? ''; + const titleText = `${seriesName} - Season ${seasonNumber}`; + ret.push( + new SeasonModel({ + // SeasonModel constructor sets type to MediaType.Series internally + title: titleText, + englishTitle: titleText, + year: airDate ? new Date(airDate).getFullYear().toString() : 'unknown', + dataSource: this.apiName, + id: `${tvId}-S${seasonNumber}`, + seasonTitle: season.name ?? titleText, + seasonNumber: seasonNumber, + episodes: season.episode_count ?? 0, + airedFrom: this.plugin.dateFormatter.format(airDate, this.apiDateFormat) ?? 'unknown', + airedTo: 'unknown', + plot: season.overview ?? '', + image: season.poster_path ? `https://image.tmdb.org/t/p/w780${season.poster_path}` : '', + userData: { watched: false, lastWatched: '', personalRating: 0 }, + }), + ); + } + } + } + + 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-S2" + const m = /^(\d+)-S(\d+)$/.exec(id); + if (!m) { + throw Error(`MDB | Invalid season id "${id}". Expected format "-S".`); + } + + 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 + 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 airDate = seasonData.air_date ?? ''; + const titleText = `${seriesName} - Season ${seasonData.season_number}`; + + return new SeasonModel({ + title: titleText, + englishTitle: titleText, + year: airDate ? new Date(airDate).getFullYear().toString() : 'unknown', + dataSource: this.apiName, + id: `${tvId}-S${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: 'unknown', + plot: seasonData.overview ?? '', + image: seasonData.poster_path ? `https://image.tmdb.org/t/p/w780${seasonData.poster_path}` : '', + 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..1fcadc5 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)); diff --git a/src/models/SeasonModel.ts b/src/models/SeasonModel.ts new file mode 100644 index 0000000..ef5a20a --- /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 'Season ' + this.seasonNumber + '(' + this.year + ')'; + } +} 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) { From 06e8cfa06e57fb45e1a4dfc075cb039890292e84 Mon Sep 17 00:00:00 2001 From: ltctceplrm <14954927+ltctceplrm@users.noreply.github.com> Date: Sun, 19 Oct 2025 14:31:49 +0200 Subject: [PATCH 2/6] Refactor TMDBSeasonAPI + added extra metadata Refactored TMDBSeasonAPI to improve API calls and data handling. I also added metadata from the main series that is missing in the season details (actors, writers, genre,...) this might be inaccurate for some seasons however but there's no other option --- src/api/apis/TMDBSeasonAPI.ts | 94 ++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 35 deletions(-) diff --git a/src/api/apis/TMDBSeasonAPI.ts b/src/api/apis/TMDBSeasonAPI.ts index dab2fc2..4db5105 100644 --- a/src/api/apis/TMDBSeasonAPI.ts +++ b/src/api/apis/TMDBSeasonAPI.ts @@ -28,7 +28,6 @@ export class TMDBSeasonAPI extends APIModel { throw new Error(`MDB | API key for ${this.apiName} missing.`); } - // 1) Search for series 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); @@ -40,57 +39,65 @@ export class TMDBSeasonAPI extends APIModel { } const searchData = await searchResp.json(); - - if (searchData.total_results === 0 || !searchData.results) { + if (!searchData.results || searchData.total_results === 0) { return []; } const ret: MediaTypeModel[] = []; - // 2) For each series result, fetch its seasons and flatten into SeasonModel entries (cap total to 20) - for (const series of searchData.results) { + for (const result of searchData.results) { if (ret.length >= 20) break; - const tvId = series.id; - const seriesUrl = `https://api.themoviedb.org/3/tv/${encodeURIComponent(tvId)}?api_key=${this.plugin.settings.TMDBKey}`; - const tvResp = await fetch(seriesUrl); + const tvId = result.id; + 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 (tvResp.status === 401) { + if (seriesResp.status === 401) { throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); } - if (tvResp.status !== 200) { - // Skip this series if it fails; do not abort the whole search - console.warn(`MDB | Skipping series ${tvId} due to status ${tvResp.status}`); + if (seriesResp.status !== 200) { + console.warn(`MDB | Skipping series ${tvId} due to status ${seriesResp.status}`); continue; } - const tvData = await tvResp.json(); - const seriesName = tvData?.name ?? series?.name ?? series?.original_name ?? ''; + const seriesData = await seriesResp.json(); + const seriesName = seriesData?.name ?? result?.name ?? result?.original_name ?? ''; - if (Array.isArray(tvData?.seasons)) { - for (const season of tvData.seasons) { + if (Array.isArray(seriesData?.seasons)) { + for (const season of seriesData.seasons) { if (ret.length >= 20) break; - // Some seasons (e.g., specials) may have limited metadata; handle gracefully const seasonNumber = season.season_number ?? 0; - const airDate = season.air_date ?? ''; + const seasonDetailsUrl = `https://api.themoviedb.org/3/tv/${encodeURIComponent(tvId)}/season/${encodeURIComponent(seasonNumber)}?api_key=${this.plugin.settings.TMDBKey}`; + const seasonDetailsResp = await fetch(seasonDetailsUrl); + + if (seasonDetailsResp.status === 401) { + throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); + } + if (seasonDetailsResp.status !== 200) { + console.warn(`MDB | Skipping season ${tvId}/season/${seasonNumber} due to status ${seasonDetailsResp.status}`); + continue; + } + + const seasonData = await seasonDetailsResp.json(); + + // Get airedTo as the air_date of the last episode, if available + let airedTo = 'unknown'; + if (Array.isArray(seasonData.episodes) && seasonData.episodes.length > 0) { + const lastEp = seasonData.episodes[seasonData.episodes.length - 1]; + if (lastEp?.air_date) airedTo = lastEp.air_date; + } + const titleText = `${seriesName} - Season ${seasonNumber}`; ret.push( new SeasonModel({ - // SeasonModel constructor sets type to MediaType.Series internally title: titleText, englishTitle: titleText, - year: airDate ? new Date(airDate).getFullYear().toString() : 'unknown', + year: seasonData.air_date ? new Date(seasonData.air_date).getFullYear().toString() : 'unknown', dataSource: this.apiName, - id: `${tvId}-S${seasonNumber}`, - seasonTitle: season.name ?? titleText, + id: `${tvId}/season/${seasonNumber}`, + seasonTitle: seasonData.name ?? titleText, seasonNumber: seasonNumber, - episodes: season.episode_count ?? 0, - airedFrom: this.plugin.dateFormatter.format(airDate, this.apiDateFormat) ?? 'unknown', - airedTo: 'unknown', - plot: season.overview ?? '', - image: season.poster_path ? `https://image.tmdb.org/t/p/w780${season.poster_path}` : '', - userData: { watched: false, lastWatched: '', personalRating: 0 }, }), ); } @@ -107,10 +114,10 @@ export class TMDBSeasonAPI extends APIModel { throw Error(`MDB | API key for ${this.apiName} missing.`); } - // Expect season ids like "12345-S2" - const m = /^(\d+)-S(\d+)$/.exec(id); + // 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 "-S".`); + throw Error(`MDB | Invalid season id "${id}". Expected format "/season/".`); } const tvId = m[1]; @@ -128,8 +135,8 @@ export class TMDBSeasonAPI extends APIModel { const seasonData = await seasonResp.json(); - // Fetch parent series to build consistent titles - const seriesUrl = `https://api.themoviedb.org/3/tv/${encodeURIComponent(tvId)}?api_key=${this.plugin.settings.TMDBKey}`; + // 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) { @@ -145,19 +152,36 @@ export class TMDBSeasonAPI extends APIModel { 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, - id: `${tvId}-S${seasonData.season_number}`, + 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: '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 }, }); } From 23b5ee0ef9212282e2990c368bed2bb238065f44 Mon Sep 17 00:00:00 2001 From: ltctceplrm <14954927+ltctceplrm@users.noreply.github.com> Date: Sun, 19 Oct 2025 14:33:07 +0200 Subject: [PATCH 3/6] Add meta.txt to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 0e729e59d31d317141d3955f92f461c398452a00 Mon Sep 17 00:00:00 2001 From: ltctceplrm <14954927+ltctceplrm@users.noreply.github.com> Date: Sat, 25 Oct 2025 14:16:13 +0200 Subject: [PATCH 4/6] Handle potentially missing fields for OpenLibraryAPI --- src/api/apis/OpenLibraryAPI.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/apis/OpenLibraryAPI.ts b/src/api/apis/OpenLibraryAPI.ts index c39faa5..e3590a0 100644 --- a/src/api/apis/OpenLibraryAPI.ts +++ b/src/api/apis/OpenLibraryAPI.ts @@ -66,10 +66,10 @@ export class OpenLibraryAPI extends APIModel { new BookModel({ title: result.title, englishTitle: result.title, - year: result.first_publish_year.toString(), + year: result.first_publish_year?.toString() ?? 'unknown', dataSource: this.apiName, id: result.key, - author: result.author_name.join(', '), + author: result.author_name?.join(', '), }), ); } @@ -111,7 +111,7 @@ export class OpenLibraryAPI extends APIModel { return new BookModel({ title: result.title, - year: result.first_publish_year.toString(), + year: result.first_publish_year?.toString() ?? 'unknown', dataSource: this.apiName, url: `https://openlibrary.org` + result.key, id: result.key, @@ -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, From 18af97a4c687bd088913971cef49f7f9b0ec20bd Mon Sep 17 00:00:00 2001 From: ltctceplrm <14954927+ltctceplrm@users.noreply.github.com> Date: Sat, 25 Oct 2025 17:30:12 +0200 Subject: [PATCH 5/6] Added season selection modal to improve TMDB season handling You can now search for a series, select it and it'll list all the seasons for that series. With the new modal you can then select the seasons you want to add. --- src/api/apis/TMDBSeasonAPI.ts | 107 +++++++++++++------------ src/main.ts | 61 +++++++++++++- src/modals/MediaDbSeasonSelectModal.ts | 47 +++++++++++ src/models/SeasonModel.ts | 2 +- src/utils/SeasonModalHelper.ts | 16 ++++ 5 files changed, 177 insertions(+), 56 deletions(-) create mode 100644 src/modals/MediaDbSeasonSelectModal.ts create mode 100644 src/utils/SeasonModalHelper.ts diff --git a/src/api/apis/TMDBSeasonAPI.ts b/src/api/apis/TMDBSeasonAPI.ts index 4db5105..d0ea90f 100644 --- a/src/api/apis/TMDBSeasonAPI.ts +++ b/src/api/apis/TMDBSeasonAPI.ts @@ -48,62 +48,67 @@ export class TMDBSeasonAPI extends APIModel { for (const result of searchData.results) { if (ret.length >= 20) break; - const tvId = result.id; - 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) { - console.warn(`MDB | Skipping series ${tvId} due to status ${seriesResp.status}`); - continue; - } - - const seriesData = await seriesResp.json(); - const seriesName = seriesData?.name ?? result?.name ?? result?.original_name ?? ''; - - if (Array.isArray(seriesData?.seasons)) { - for (const season of seriesData.seasons) { - if (ret.length >= 20) break; - - const seasonNumber = season.season_number ?? 0; - const seasonDetailsUrl = `https://api.themoviedb.org/3/tv/${encodeURIComponent(tvId)}/season/${encodeURIComponent(seasonNumber)}?api_key=${this.plugin.settings.TMDBKey}`; - const seasonDetailsResp = await fetch(seasonDetailsUrl); - - if (seasonDetailsResp.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); + // 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; } - if (seasonDetailsResp.status !== 200) { - console.warn(`MDB | Skipping season ${tvId}/season/${seasonNumber} due to status ${seasonDetailsResp.status}`); - continue; - } - - const seasonData = await seasonDetailsResp.json(); + } + } 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, + }) + ); + } - // 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 ret; + } - const titleText = `${seriesName} - Season ${seasonNumber}`; - ret.push( - new SeasonModel({ - title: titleText, - englishTitle: titleText, - year: seasonData.air_date ? new Date(seasonData.air_date).getFullYear().toString() : 'unknown', - dataSource: this.apiName, - id: `${tvId}/season/${seasonNumber}`, - seasonTitle: seasonData.name ?? titleText, - seasonNumber: seasonNumber, - }), - ); - } + //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; } diff --git a/src/main.ts b/src/main.ts index 1fcadc5..ffb6ed7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -218,14 +218,67 @@ 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; + } + 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); + 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..2490e67 --- /dev/null +++ b/src/modals/MediaDbSeasonSelectModal.ts @@ -0,0 +1,47 @@ +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; + + constructor(plugin: MediaDbPlugin, seasons: SeasonSelectModalElement[], multiSelect = true) { + super(plugin.app, seasons, multiSelect); + this.plugin = plugin; + this.title = 'Select Season(s)'; + 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 index ef5a20a..07f9626 100644 --- a/src/models/SeasonModel.ts +++ b/src/models/SeasonModel.ts @@ -75,6 +75,6 @@ export class SeasonModel extends MediaTypeModel { } getSummary(): string { - return 'Season ' + this.seasonNumber + '(' + this.year + ')'; + return this.seasonNumber + ' seasons'; } } 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(); + }); +} From 3686a0175d88d37d0f0d3aa67ab5aad1809700e3 Mon Sep 17 00:00:00 2001 From: ltctceplrm <14954927+ltctceplrm@users.noreply.github.com> Date: Sat, 25 Oct 2025 17:41:38 +0200 Subject: [PATCH 6/6] Added series title in the season selection modal --- src/main.ts | 4 +++- src/modals/MediaDbSeasonSelectModal.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index ffb6ed7..323d00c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -248,13 +248,15 @@ export default class MediaDbPlugin extends Plugin { 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); + })), true, seriesName); const selectedSeasons: any[] = await new Promise(resolve => { modal.setSubmitCallback(resolve); modal.open(); diff --git a/src/modals/MediaDbSeasonSelectModal.ts b/src/modals/MediaDbSeasonSelectModal.ts index 2490e67..1bde2d7 100644 --- a/src/modals/MediaDbSeasonSelectModal.ts +++ b/src/modals/MediaDbSeasonSelectModal.ts @@ -12,11 +12,13 @@ export class MediaDbSeasonSelectModal extends SelectModal void; closeCallback?: (err?: Error) => void; + seriesName?: string; - constructor(plugin: MediaDbPlugin, seasons: SeasonSelectModalElement[], multiSelect = true) { + constructor(plugin: MediaDbPlugin, seasons: SeasonSelectModalElement[], multiSelect = true, seriesName?: string) { super(plugin.app, seasons, multiSelect); this.plugin = plugin; - this.title = 'Select Season(s)'; + this.seriesName = seriesName; + this.title = `Select Season(s) for${seriesName ? `: ${seriesName}` : ''}`; this.description = 'Select one or more seasons to create notes for.'; }