diff --git a/overseerr-api.yml b/overseerr-api.yml index 90c2bcb77..3fcafe564 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -436,12 +436,7 @@ components: spokenLanguages: type: array items: - type: object - properties: - iso_639_1: - type: string - name: - type: string + $ref: '#/components/schemas/SpokenLanguage' status: type: string tagline: @@ -592,6 +587,10 @@ components: type: array items: $ref: '#/components/schemas/ProductionCompany' + spokenLanguages: + type: array + items: + $ref: '#/components/schemas/SpokenLanguage' seasons: type: array items: @@ -617,6 +616,10 @@ components: $ref: '#/components/schemas/Crew' externalIds: $ref: '#/components/schemas/ExternalIds' + keywords: + type: array + items: + $ref: '#/components/schemas/Keyword' mediaInfo: $ref: '#/components/schemas/MediaInfo' MediaRequest: @@ -961,6 +964,28 @@ components: type: string mediaInfo: $ref: '#/components/schemas/MediaInfo' + Keyword: + type: object + properties: + id: + type: number + example: 1 + name: + type: string + example: 'anime' + SpokenLanguage: + type: object + properties: + englishName: + type: string + example: 'English' + nullable: true + iso_639_1: + type: string + example: 'en' + name: + type: string + example: 'English' securitySchemes: cookieAuth: type: apiKey diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 4a86b68bb..903cd4cc6 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -6,7 +6,7 @@ interface SonarrSeason { monitored: boolean; } -interface SonarrSeries { +export interface SonarrSeries { title: string; sortTitle: string; seasonCount: number; @@ -33,7 +33,7 @@ interface SonarrSeries { tvMazeId: number; firstAired: string; lastInfoSync?: string; - seriesType: string; + seriesType: 'standard' | 'daily' | 'anime'; cleanTitle: string; imdbId: string; titleSlug: string; @@ -78,6 +78,7 @@ interface AddSeriesOptions { seasons: number[]; seasonFolder: boolean; rootFolderPath: string; + seriesType: SonarrSeries['seriesType']; monitored?: boolean; searchNow?: boolean; } @@ -153,6 +154,7 @@ class SonarrAPI { seasonFolder: options.seasonFolder, monitored: options.monitored, rootFolderPath: options.rootFolderPath, + seriesType: options.seriesType, addOptions: { ignoreEpisodesWithFiles: true, searchForMissingEpisodes: options.searchNow, @@ -164,7 +166,7 @@ class SonarrAPI { } catch (e) { logger.error('Something went wrong adding a series to Sonarr', { label: 'Sonarr API', - message: e.message, + errorMessage: e.message, error: e, }); throw new Error('Failed to add series'); diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index 4260cdfc3..d6e5cc783 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -1,5 +1,7 @@ import axios, { AxiosInstance } from 'axios'; +export const ANIME_KEYWORD_ID = 210024; + interface SearchOptions { query: string; page?: number; @@ -258,6 +260,11 @@ export interface TmdbTvDetails { name: string; origin_country: string; }[]; + spoken_languages: { + english_name: string; + iso_639_1: string; + name: string; + }[]; seasons: TmdbTvSeasonResult[]; status: string; type: string; @@ -268,6 +275,14 @@ export interface TmdbTvDetails { crew: TmdbCreditCrew[]; }; external_ids: TmdbExternalIds; + keywords: { + results: TmdbKeyword[]; + }; +} + +export interface TmdbKeyword { + id: number; + name: string; } export interface TmdbPersonDetail { @@ -437,7 +452,10 @@ class TheMovieDb { }): Promise => { try { const response = await this.axios.get(`/tv/${tvId}`, { - params: { language, append_to_response: 'credits,external_ids' }, + params: { + language, + append_to_response: 'credits,external_ids,keywords', + }, }); return response.data; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index d8ae9c89c..e5c993679 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -15,11 +15,11 @@ import { User } from './User'; import Media from './Media'; import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; import { getSettings } from '../lib/settings'; -import TheMovieDb from '../api/themoviedb'; +import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb'; import RadarrAPI from '../api/radarr'; import logger from '../logger'; import SeasonRequest from './SeasonRequest'; -import SonarrAPI from '../api/sonarr'; +import SonarrAPI, { SonarrSeries } from '../api/sonarr'; import notificationManager, { Notification } from '../lib/notifications'; @Entity() @@ -336,14 +336,32 @@ export class MediaRequest { throw new Error('Series was missing tvdb id'); } + let seriesType: SonarrSeries['seriesType'] = 'standard'; + + // Change series type to anime if the anime keyword is present on tmdb + if ( + series.keywords.results.some( + (keyword) => keyword.id === ANIME_KEYWORD_ID + ) + ) { + seriesType = 'anime'; + } + // Run this asynchronously so we don't wait for it on the UI side sonarr.addSeries({ - profileId: sonarrSettings.activeProfileId, - rootFolderPath: sonarrSettings.activeDirectory, + profileId: + seriesType === 'anime' && sonarrSettings.activeAnimeProfileId + ? sonarrSettings.activeAnimeProfileId + : sonarrSettings.activeProfileId, + rootFolderPath: + seriesType === 'anime' && sonarrSettings.activeAnimeDirectory + ? sonarrSettings.activeAnimeDirectory + : sonarrSettings.activeDirectory, title: series.name, tvdbid: series.external_ids.tvdb_id, seasons: this.seasons.map((season) => season.seasonNumber), seasonFolder: sonarrSettings.enableSeasonFolders, + seriesType, monitored: true, searchNow: true, }); diff --git a/server/models/Tv.ts b/server/models/Tv.ts index f1e8f7797..7c8759a9b 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -7,6 +7,7 @@ import { mapCrew, ExternalIds, mapExternalIds, + Keyword, } from './common'; import { TmdbTvEpisodeResult, @@ -45,6 +46,12 @@ export interface SeasonWithEpisodes extends Season { externalIds: ExternalIds; } +interface SpokenLanguage { + englishName: string; + iso_639_1: string; + name: string; +} + export interface TvDetails { id: number; backdropPath?: string; @@ -74,6 +81,7 @@ export interface TvDetails { overview: string; popularity: number; productionCompanies: ProductionCompany[]; + spokenLanguages: SpokenLanguage[]; seasons: Season[]; status: string; type: string; @@ -84,6 +92,7 @@ export interface TvDetails { crew: Crew[]; }; externalIds: ExternalIds; + keywords: Keyword[]; mediaInfo?: Media; } @@ -161,6 +170,11 @@ export const mapTvDetails = ( originCountry: company.origin_country, logoPath: company.logo_path, })), + spokenLanguages: show.spoken_languages.map((language) => ({ + englishName: language.english_name, + iso_639_1: language.iso_639_1, + name: language.name, + })), seasons: show.seasons.map(mapSeasonResult), status: show.status, type: show.type, @@ -179,5 +193,9 @@ export const mapTvDetails = ( crew: show.credits.crew.map(mapCrew), }, externalIds: mapExternalIds(show.external_ids), + keywords: show.keywords.results.map((keyword) => ({ + id: keyword.id, + name: keyword.name, + })), mediaInfo: media, }); diff --git a/server/models/common.ts b/server/models/common.ts index 696f7df23..90945dc2e 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -11,6 +11,11 @@ export interface ProductionCompany { name: string; } +export interface Keyword { + id: number; + name: string; +} + export interface Genre { id: number; name: string; diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 9f9201e41..7e8b06257 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -4,6 +4,7 @@ import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv'; import { mapTvResult } from '../models/Search'; import Media from '../entity/Media'; import RottenTomatoes from '../api/rottentomatoes'; +import logger from '../logger'; const tvRoutes = Router(); @@ -19,6 +20,10 @@ tvRoutes.get('/:id', async (req, res, next) => { return res.status(200).json(mapTvDetails(tv, media)); } catch (e) { + logger.error('Failed to get tv show', { + label: 'API', + errorMessage: e.message, + }); return next({ status: 404, message: 'TV Show does not exist' }); } }); diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index ad6fee594..cf1bb3abd 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -36,6 +36,8 @@ const messages = defineMessages({ baseUrlPlaceholder: 'Example: /sonarr', qualityprofile: 'Quality Profile', rootfolder: 'Root Folder', + animequalityprofile: 'Anime Quality Profile', + animerootfolder: 'Anime Root Folder', seasonfolders: 'Season Folders', server4k: '4K Server', selectQualityProfile: 'Select a Quality Profile', @@ -182,6 +184,8 @@ const SonarrModal: React.FC = ({ baseUrl: sonarr?.baseUrl, activeProfileId: sonarr?.activeProfileId, rootFolder: sonarr?.activeDirectory, + activeAnimeProfileId: sonarr?.activeAnimeProfileId, + activeAnimeRootFolder: sonarr?.activeAnimeDirectory, isDefault: sonarr?.isDefault ?? false, is4k: sonarr?.is4k ?? false, enableSeasonFolders: sonarr?.enableSeasonFolders ?? false, @@ -192,6 +196,9 @@ const SonarrModal: React.FC = ({ const profileName = testResponse.profiles.find( (profile) => profile.id === Number(values.activeProfileId) )?.name; + const animeProfileName = testResponse.profiles.find( + (profile) => profile.id === Number(values.activeAnimeProfileId) + )?.name; const submission = { name: values.name, @@ -203,6 +210,11 @@ const SonarrModal: React.FC = ({ activeProfileId: Number(values.activeProfileId), activeProfileName: profileName, activeDirectory: values.rootFolder, + activeAnimeProfileId: values.activeAnimeProfileId + ? Number(values.activeAnimeProfileId) + : undefined, + activeAnimeProfileName: animeProfileName ?? undefined, + activeAnimeDirectory: values.activeAnimeRootFolder, is4k: values.is4k, isDefault: values.isDefault, enableSeasonFolders: values.enableSeasonFolders, @@ -528,6 +540,92 @@ const SonarrModal: React.FC = ({ )} +
+ +
+
+ + + {testResponse.profiles.length > 0 && + testResponse.profiles.map((profile) => ( + + ))} + +
+ {errors.activeAnimeProfileId && + touched.activeAnimeProfileId && ( +
+ {errors.activeAnimeProfileId} +
+ )} +
+
+
+ +
+
+ + + {testResponse.rootFolders.length > 0 && + testResponse.rootFolders.map((folder) => ( + + ))} + +
+ {errors.activeAnimeRootFolder && + touched.activeAnimeRootFolder && ( +
+ {errors.rootFolder} +
+ )} +
+
)} + {data.keywords.some( + (keyword) => keyword.id === ANIME_KEYWORD_ID + ) && ( +
+ + {intl.formatMessage(messages.showtype)} + + + {intl.formatMessage(messages.anime)} + +
+ )}
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 79276bfae..475b008a2 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -152,6 +152,8 @@ "components.Settings.SettingsAbout.totalrequests": "Total Requests", "components.Settings.SettingsAbout.version": "Version", "components.Settings.SonarrModal.add": "Add Server", + "components.Settings.SonarrModal.animequalityprofile": "Anime Quality Profile", + "components.Settings.SonarrModal.animerootfolder": "Anime Root Folder", "components.Settings.SonarrModal.apiKey": "API Key", "components.Settings.SonarrModal.apiKeyPlaceholder": "Your Sonarr API Key", "components.Settings.SonarrModal.baseUrl": "Base URL", @@ -255,6 +257,7 @@ "components.TitleCard.movie": "Movie", "components.TitleCard.tvshow": "Series", "components.TvDetails.TvCast.fullseriescast": "Full Series Cast", + "components.TvDetails.anime": "Anime", "components.TvDetails.approve": "Approve", "components.TvDetails.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}", "components.TvDetails.available": "Available", @@ -275,6 +278,7 @@ "components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like...", "components.TvDetails.request": "Request", "components.TvDetails.requestmore": "Request More", + "components.TvDetails.showtype": "Show Type", "components.TvDetails.similar": "Similar Series", "components.TvDetails.similarsubtext": "Other series similar to {title}", "components.TvDetails.status": "Status",