feat: anime profile support (#384)

closes #266
This commit is contained in:
sct
2020-12-18 14:32:40 +09:00
committed by GitHub
parent 1f0486eba2
commit 0972f40a4e
10 changed files with 222 additions and 14 deletions

View File

@@ -436,12 +436,7 @@ components:
spokenLanguages: spokenLanguages:
type: array type: array
items: items:
type: object $ref: '#/components/schemas/SpokenLanguage'
properties:
iso_639_1:
type: string
name:
type: string
status: status:
type: string type: string
tagline: tagline:
@@ -592,6 +587,10 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/ProductionCompany' $ref: '#/components/schemas/ProductionCompany'
spokenLanguages:
type: array
items:
$ref: '#/components/schemas/SpokenLanguage'
seasons: seasons:
type: array type: array
items: items:
@@ -617,6 +616,10 @@ components:
$ref: '#/components/schemas/Crew' $ref: '#/components/schemas/Crew'
externalIds: externalIds:
$ref: '#/components/schemas/ExternalIds' $ref: '#/components/schemas/ExternalIds'
keywords:
type: array
items:
$ref: '#/components/schemas/Keyword'
mediaInfo: mediaInfo:
$ref: '#/components/schemas/MediaInfo' $ref: '#/components/schemas/MediaInfo'
MediaRequest: MediaRequest:
@@ -961,6 +964,28 @@ components:
type: string type: string
mediaInfo: mediaInfo:
$ref: '#/components/schemas/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: securitySchemes:
cookieAuth: cookieAuth:
type: apiKey type: apiKey

View File

@@ -6,7 +6,7 @@ interface SonarrSeason {
monitored: boolean; monitored: boolean;
} }
interface SonarrSeries { export interface SonarrSeries {
title: string; title: string;
sortTitle: string; sortTitle: string;
seasonCount: number; seasonCount: number;
@@ -33,7 +33,7 @@ interface SonarrSeries {
tvMazeId: number; tvMazeId: number;
firstAired: string; firstAired: string;
lastInfoSync?: string; lastInfoSync?: string;
seriesType: string; seriesType: 'standard' | 'daily' | 'anime';
cleanTitle: string; cleanTitle: string;
imdbId: string; imdbId: string;
titleSlug: string; titleSlug: string;
@@ -78,6 +78,7 @@ interface AddSeriesOptions {
seasons: number[]; seasons: number[];
seasonFolder: boolean; seasonFolder: boolean;
rootFolderPath: string; rootFolderPath: string;
seriesType: SonarrSeries['seriesType'];
monitored?: boolean; monitored?: boolean;
searchNow?: boolean; searchNow?: boolean;
} }
@@ -153,6 +154,7 @@ class SonarrAPI {
seasonFolder: options.seasonFolder, seasonFolder: options.seasonFolder,
monitored: options.monitored, monitored: options.monitored,
rootFolderPath: options.rootFolderPath, rootFolderPath: options.rootFolderPath,
seriesType: options.seriesType,
addOptions: { addOptions: {
ignoreEpisodesWithFiles: true, ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: options.searchNow, searchForMissingEpisodes: options.searchNow,
@@ -164,7 +166,7 @@ class SonarrAPI {
} catch (e) { } catch (e) {
logger.error('Something went wrong adding a series to Sonarr', { logger.error('Something went wrong adding a series to Sonarr', {
label: 'Sonarr API', label: 'Sonarr API',
message: e.message, errorMessage: e.message,
error: e, error: e,
}); });
throw new Error('Failed to add series'); throw new Error('Failed to add series');

View File

@@ -1,5 +1,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
export const ANIME_KEYWORD_ID = 210024;
interface SearchOptions { interface SearchOptions {
query: string; query: string;
page?: number; page?: number;
@@ -258,6 +260,11 @@ export interface TmdbTvDetails {
name: string; name: string;
origin_country: string; origin_country: string;
}[]; }[];
spoken_languages: {
english_name: string;
iso_639_1: string;
name: string;
}[];
seasons: TmdbTvSeasonResult[]; seasons: TmdbTvSeasonResult[];
status: string; status: string;
type: string; type: string;
@@ -268,6 +275,14 @@ export interface TmdbTvDetails {
crew: TmdbCreditCrew[]; crew: TmdbCreditCrew[];
}; };
external_ids: TmdbExternalIds; external_ids: TmdbExternalIds;
keywords: {
results: TmdbKeyword[];
};
}
export interface TmdbKeyword {
id: number;
name: string;
} }
export interface TmdbPersonDetail { export interface TmdbPersonDetail {
@@ -437,7 +452,10 @@ class TheMovieDb {
}): Promise<TmdbTvDetails> => { }): Promise<TmdbTvDetails> => {
try { try {
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, { const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
params: { language, append_to_response: 'credits,external_ids' }, params: {
language,
append_to_response: 'credits,external_ids,keywords',
},
}); });
return response.data; return response.data;

View File

@@ -15,11 +15,11 @@ import { User } from './User';
import Media from './Media'; import Media from './Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { getSettings } from '../lib/settings'; import { getSettings } from '../lib/settings';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb';
import RadarrAPI from '../api/radarr'; import RadarrAPI from '../api/radarr';
import logger from '../logger'; import logger from '../logger';
import SeasonRequest from './SeasonRequest'; import SeasonRequest from './SeasonRequest';
import SonarrAPI from '../api/sonarr'; import SonarrAPI, { SonarrSeries } from '../api/sonarr';
import notificationManager, { Notification } from '../lib/notifications'; import notificationManager, { Notification } from '../lib/notifications';
@Entity() @Entity()
@@ -336,14 +336,32 @@ export class MediaRequest {
throw new Error('Series was missing tvdb id'); 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 // Run this asynchronously so we don't wait for it on the UI side
sonarr.addSeries({ sonarr.addSeries({
profileId: sonarrSettings.activeProfileId, profileId:
rootFolderPath: sonarrSettings.activeDirectory, seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId,
rootFolderPath:
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
? sonarrSettings.activeAnimeDirectory
: sonarrSettings.activeDirectory,
title: series.name, title: series.name,
tvdbid: series.external_ids.tvdb_id, tvdbid: series.external_ids.tvdb_id,
seasons: this.seasons.map((season) => season.seasonNumber), seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders, seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
monitored: true, monitored: true,
searchNow: true, searchNow: true,
}); });

View File

@@ -7,6 +7,7 @@ import {
mapCrew, mapCrew,
ExternalIds, ExternalIds,
mapExternalIds, mapExternalIds,
Keyword,
} from './common'; } from './common';
import { import {
TmdbTvEpisodeResult, TmdbTvEpisodeResult,
@@ -45,6 +46,12 @@ export interface SeasonWithEpisodes extends Season {
externalIds: ExternalIds; externalIds: ExternalIds;
} }
interface SpokenLanguage {
englishName: string;
iso_639_1: string;
name: string;
}
export interface TvDetails { export interface TvDetails {
id: number; id: number;
backdropPath?: string; backdropPath?: string;
@@ -74,6 +81,7 @@ export interface TvDetails {
overview: string; overview: string;
popularity: number; popularity: number;
productionCompanies: ProductionCompany[]; productionCompanies: ProductionCompany[];
spokenLanguages: SpokenLanguage[];
seasons: Season[]; seasons: Season[];
status: string; status: string;
type: string; type: string;
@@ -84,6 +92,7 @@ export interface TvDetails {
crew: Crew[]; crew: Crew[];
}; };
externalIds: ExternalIds; externalIds: ExternalIds;
keywords: Keyword[];
mediaInfo?: Media; mediaInfo?: Media;
} }
@@ -161,6 +170,11 @@ export const mapTvDetails = (
originCountry: company.origin_country, originCountry: company.origin_country,
logoPath: company.logo_path, 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), seasons: show.seasons.map(mapSeasonResult),
status: show.status, status: show.status,
type: show.type, type: show.type,
@@ -179,5 +193,9 @@ export const mapTvDetails = (
crew: show.credits.crew.map(mapCrew), crew: show.credits.crew.map(mapCrew),
}, },
externalIds: mapExternalIds(show.external_ids), externalIds: mapExternalIds(show.external_ids),
keywords: show.keywords.results.map((keyword) => ({
id: keyword.id,
name: keyword.name,
})),
mediaInfo: media, mediaInfo: media,
}); });

View File

@@ -11,6 +11,11 @@ export interface ProductionCompany {
name: string; name: string;
} }
export interface Keyword {
id: number;
name: string;
}
export interface Genre { export interface Genre {
id: number; id: number;
name: string; name: string;

View File

@@ -4,6 +4,7 @@ import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv';
import { mapTvResult } from '../models/Search'; import { mapTvResult } from '../models/Search';
import Media from '../entity/Media'; import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes'; import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger';
const tvRoutes = Router(); const tvRoutes = Router();
@@ -19,6 +20,10 @@ tvRoutes.get('/:id', async (req, res, next) => {
return res.status(200).json(mapTvDetails(tv, media)); return res.status(200).json(mapTvDetails(tv, media));
} catch (e) { } catch (e) {
logger.error('Failed to get tv show', {
label: 'API',
errorMessage: e.message,
});
return next({ status: 404, message: 'TV Show does not exist' }); return next({ status: 404, message: 'TV Show does not exist' });
} }
}); });

View File

@@ -36,6 +36,8 @@ const messages = defineMessages({
baseUrlPlaceholder: 'Example: /sonarr', baseUrlPlaceholder: 'Example: /sonarr',
qualityprofile: 'Quality Profile', qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder', rootfolder: 'Root Folder',
animequalityprofile: 'Anime Quality Profile',
animerootfolder: 'Anime Root Folder',
seasonfolders: 'Season Folders', seasonfolders: 'Season Folders',
server4k: '4K Server', server4k: '4K Server',
selectQualityProfile: 'Select a Quality Profile', selectQualityProfile: 'Select a Quality Profile',
@@ -182,6 +184,8 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
baseUrl: sonarr?.baseUrl, baseUrl: sonarr?.baseUrl,
activeProfileId: sonarr?.activeProfileId, activeProfileId: sonarr?.activeProfileId,
rootFolder: sonarr?.activeDirectory, rootFolder: sonarr?.activeDirectory,
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
isDefault: sonarr?.isDefault ?? false, isDefault: sonarr?.isDefault ?? false,
is4k: sonarr?.is4k ?? false, is4k: sonarr?.is4k ?? false,
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false, enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
@@ -192,6 +196,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
const profileName = testResponse.profiles.find( const profileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeProfileId) (profile) => profile.id === Number(values.activeProfileId)
)?.name; )?.name;
const animeProfileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeAnimeProfileId)
)?.name;
const submission = { const submission = {
name: values.name, name: values.name,
@@ -203,6 +210,11 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
activeProfileId: Number(values.activeProfileId), activeProfileId: Number(values.activeProfileId),
activeProfileName: profileName, activeProfileName: profileName,
activeDirectory: values.rootFolder, activeDirectory: values.rootFolder,
activeAnimeProfileId: values.activeAnimeProfileId
? Number(values.activeAnimeProfileId)
: undefined,
activeAnimeProfileName: animeProfileName ?? undefined,
activeAnimeDirectory: values.activeAnimeRootFolder,
is4k: values.is4k, is4k: values.is4k,
isDefault: values.isDefault, isDefault: values.isDefault,
enableSeasonFolders: values.enableSeasonFolders, enableSeasonFolders: values.enableSeasonFolders,
@@ -528,6 +540,92 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="activeAnimeProfileId"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.animequalityprofile)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<Field
as="select"
id="activeAnimeProfileId"
name="activeAnimeProfileId"
disabled={!isValidated || isTesting}
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeAnimeProfileId &&
touched.activeAnimeProfileId && (
<div className="text-red-500 mt-2">
{errors.activeAnimeProfileId}
</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="activeAnimeRootFolder"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.animerootfolder)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<Field
as="select"
id="activeAnimeRootFolder"
name="activeAnimeRootFolder"
disabled={!isValidated || isTesting}
className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(messages.testFirstRootFolders)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.activeAnimeRootFolder &&
touched.activeAnimeRootFolder && (
<div className="text-red-500 mt-2">
{errors.rootFolder}
</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label <label
htmlFor="is4k" htmlFor="is4k"

View File

@@ -28,6 +28,7 @@ import RTAudRotten from '../../assets/rt_aud_rotten.svg';
import type { RTRating } from '../../../server/api/rottentomatoes'; import type { RTRating } from '../../../server/api/rottentomatoes';
import Head from 'next/head'; import Head from 'next/head';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb';
const messages = defineMessages({ const messages = defineMessages({
userrating: 'User Rating', userrating: 'User Rating',
@@ -56,6 +57,8 @@ const messages = defineMessages({
'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.', 'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.',
approve: 'Approve', approve: 'Approve',
decline: 'Decline', decline: 'Decline',
showtype: 'Show Type',
anime: 'Anime',
}); });
interface TvDetailsProps { interface TvDetailsProps {
@@ -431,6 +434,18 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)} )}
</div> </div>
)} )}
{data.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
) && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.showtype)}
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
{intl.formatMessage(messages.anime)}
</span>
</div>
)}
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0"> <div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm"> <span className="text-sm">
<FormattedMessage {...messages.status} /> <FormattedMessage {...messages.status} />

View File

@@ -152,6 +152,8 @@
"components.Settings.SettingsAbout.totalrequests": "Total Requests", "components.Settings.SettingsAbout.totalrequests": "Total Requests",
"components.Settings.SettingsAbout.version": "Version", "components.Settings.SettingsAbout.version": "Version",
"components.Settings.SonarrModal.add": "Add Server", "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.apiKey": "API Key",
"components.Settings.SonarrModal.apiKeyPlaceholder": "Your Sonarr API Key", "components.Settings.SonarrModal.apiKeyPlaceholder": "Your Sonarr API Key",
"components.Settings.SonarrModal.baseUrl": "Base URL", "components.Settings.SonarrModal.baseUrl": "Base URL",
@@ -255,6 +257,7 @@
"components.TitleCard.movie": "Movie", "components.TitleCard.movie": "Movie",
"components.TitleCard.tvshow": "Series", "components.TitleCard.tvshow": "Series",
"components.TvDetails.TvCast.fullseriescast": "Full Series Cast", "components.TvDetails.TvCast.fullseriescast": "Full Series Cast",
"components.TvDetails.anime": "Anime",
"components.TvDetails.approve": "Approve", "components.TvDetails.approve": "Approve",
"components.TvDetails.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}", "components.TvDetails.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.TvDetails.available": "Available", "components.TvDetails.available": "Available",
@@ -275,6 +278,7 @@
"components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like...", "components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like...",
"components.TvDetails.request": "Request", "components.TvDetails.request": "Request",
"components.TvDetails.requestmore": "Request More", "components.TvDetails.requestmore": "Request More",
"components.TvDetails.showtype": "Show Type",
"components.TvDetails.similar": "Similar Series", "components.TvDetails.similar": "Similar Series",
"components.TvDetails.similarsubtext": "Other series similar to {title}", "components.TvDetails.similarsubtext": "Other series similar to {title}",
"components.TvDetails.status": "Status", "components.TvDetails.status": "Status",