diff --git a/overseerr-api.yml b/overseerr-api.yml index 32e505602..c85c1a083 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1143,6 +1143,135 @@ components: type: array items: $ref: '#/components/schemas/MovieResult' + SonarrSeries: + type: object + properties: + title: + type: string + example: COVID-25 + sortTitle: + type: string + example: covid 25 + seasonCount: + type: number + example: 1 + status: + type: string + example: upcoming + overview: + type: string + example: The thread is picked up again by Marianne Schmidt which ... + network: + type: string + example: CBS + airTime: + type: string + example: 02:15 + images: + type: array + items: + type: object + properties: + coverType: + type: string + example: banner + url: + type: string + example: /sonarr/MediaCoverProxy/6467f05d9872726ad08cbf920e5fee4bf69198682260acab8eab5d3c2c958e92/5c8f116c6aa5c.jpg + remotePoster: + type: string + example: https://artworks.thetvdb.com/banners/posters/5c8f116129983.jpg + seasons: + type: array + items: + type: object + properties: + seasonNumber: + type: number + example: 1 + monitored: + type: boolean + example: true + year: + type: number + example: 2015 + path: + type: string + profileId: + type: number + languageProfileId: + type: number + seasonFolder: + type: boolean + monitored: + type: boolean + useSceneNumbering: + type: boolean + runtime: + type: number + tvdbId: + type: number + example: 12345 + tvRageId: + type: number + tvMazeId: + type: number + firstAired: + type: string + lastInfoSync: + type: string + nullable: true + seriesType: + type: string + cleanTitle: + type: string + imdbId: + type: string + titleSlug: + type: string + certification: + type: string + genres: + type: array + items: + type: string + tags: + type: array + items: + type: string + added: + type: string + ratings: + type: array + items: + type: object + properties: + votes: + type: number + value: + type: number + qualityProfileId: + type: number + id: + type: number + nullable: true + rootFolderPath: + type: string + nullable: true + addOptions: + type: array + items: + type: object + properties: + ignoreEpisodesWithFiles: + type: boolean + nullable: true + ignoreEpisodesWithoutFiles: + type: boolean + nullable: true + searchForMissingEpisodes: + type: boolean + nullable: true securitySchemes: cookieAuth: type: apiKey @@ -3193,6 +3322,28 @@ paths: $ref: '#/components/schemas/SonarrSettings' profiles: $ref: '#/components/schemas/ServiceProfile' + /service/sonarr/lookup/{tmdbId}: + get: + summary: Returns a list of series from sonarr + description: Returns a list of series returned by searching for the name in sonarr + tags: + - service + parameters: + - in: path + name: tmdbId + required: true + schema: + type: number + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSeries' security: - cookieAuth: [] diff --git a/public/images/overseerr_poster_not_found.png b/public/images/overseerr_poster_not_found.png new file mode 100644 index 000000000..2f5bc203d Binary files /dev/null and b/public/images/overseerr_poster_not_found.png differ diff --git a/public/images/overseerr_poster_not_found_logo_center.png b/public/images/overseerr_poster_not_found_logo_center.png new file mode 100644 index 000000000..2ecd84b0f Binary files /dev/null and b/public/images/overseerr_poster_not_found_logo_center.png differ diff --git a/public/images/overseerr_poster_not_found_logo_top.png b/public/images/overseerr_poster_not_found_logo_top.png new file mode 100644 index 000000000..a74b096e1 Binary files /dev/null and b/public/images/overseerr_poster_not_found_logo_top.png differ diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index e3cd23534..6c8cd9af3 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -94,6 +94,28 @@ class SonarrAPI { }); } + public async getSeriesByTitle(title: string): Promise { + try { + const response = await this.axios.get('/series/lookup', { + params: { + term: title, + }, + }); + + if (!response.data[0]) { + throw new Error('No series found'); + } + + return response.data; + } catch (e) { + logger.error('Error retrieving series by series title', { + label: 'Sonarr API', + message: e.message, + }); + throw new Error('No series found'); + } + } + public async getSeriesByTvdbId(id: number): Promise { try { const response = await this.axios.get('/series/lookup', { diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 3b64f4727..0e9ca8cac 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -427,9 +427,12 @@ export class MediaRequest { }); logger.info('Sent request to Radarr', { label: 'Media Request' }); } catch (e) { - throw new Error( - `[MediaRequest] Request failed to send to radarr: ${e.message}` - ); + const errorMessage = `Request failed to send to radarr: ${e.message}`; + logger.error('Request failed to send to Radarr', { + label: 'Media Request', + errorMessage, + }); + throw new Error(errorMessage); } } } @@ -501,8 +504,10 @@ export class MediaRequest { }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`, }); const series = await tmdb.getTvShow({ tvId: media.tmdbId }); + const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; - if (!series.external_ids.tvdb_id) { + if (!tvdbId) { + this.handleRemoveParentUpdate(); throw new Error('Series was missing tvdb id'); } @@ -550,7 +555,7 @@ export class MediaRequest { profileId: qualityProfile, rootFolderPath: rootFolder, title: series.name, - tvdbid: series.external_ids.tvdb_id, + tvdbid: tvdbId, seasons: this.seasons.map((season) => season.seasonNumber), seasonFolder: sonarrSettings.enableSeasonFolders, seriesType, @@ -590,9 +595,12 @@ export class MediaRequest { }); logger.info('Sent request to Sonarr', { label: 'Media Request' }); } catch (e) { - throw new Error( - `[MediaRequest] Request failed to send to sonarr: ${e.message}` - ); + const errorMessage = `Request failed to send to sonarr: ${e.message}`; + logger.error('Request failed to send to Sonarr', { + label: 'Media Request', + errorMessage, + }); + throw new Error(errorMessage); } } } diff --git a/server/routes/request.ts b/server/routes/request.ts index 4750be2da..2a5e7c415 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -109,7 +109,7 @@ requestRoutes.post( if (!media) { media = new Media({ tmdbId: tmdbMedia.id, - tvdbId: tmdbMedia.external_ids.tvdb_id, + tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id, status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: req.body.mediaType, diff --git a/server/routes/service.ts b/server/routes/service.ts index c163a9403..94b2bc727 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -6,6 +6,8 @@ import { ServiceCommonServerWithDetails, } from '../interfaces/api/serviceInterfaces'; import { getSettings } from '../lib/settings'; +import TheMovieDb from '../api/themoviedb'; +import logger from '../logger'; const serviceRoutes = Router(); @@ -100,13 +102,13 @@ serviceRoutes.get<{ sonarrId: string }>( const settings = getSettings(); const sonarrSettings = settings.sonarr.find( - (radarr) => radarr.id === Number(req.params.sonarrId) + (sonarr) => sonarr.id === Number(req.params.sonarrId) ); if (!sonarrSettings) { return next({ status: 404, - message: 'Radarr server with provided ID does not exist.', + message: 'Sonarr server with provided ID does not exist.', }); } @@ -145,4 +147,52 @@ serviceRoutes.get<{ sonarrId: string }>( } ); +serviceRoutes.get<{ tmdbId: string }>( + '/sonarr/lookup/:tmdbId', + async (req, res, next) => { + const settings = getSettings(); + const tmdb = new TheMovieDb(); + + const sonarrSettings = settings.sonarr[0]; + + if (!sonarrSettings) { + logger.error('No sonarr server has been setup', { + label: 'Media Request', + }); + return next({ + status: 404, + message: 'No sonarr server has been setup', + }); + } + + const sonarr = new SonarrAPI({ + apiKey: sonarrSettings.apiKey, + url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${ + sonarrSettings.hostname + }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`, + }); + + try { + const tv = await tmdb.getTvShow({ + tvId: Number(req.params.tmdbId), + language: req.query.language as string, + }); + + const response = await sonarr.getSeriesByTitle(tv.name); + + return res.status(200).json(response); + } catch (e) { + logger.error('Failed to fetch tvdb search results', { + label: 'Media Request', + message: e.message, + }); + + return next({ + status: 500, + message: 'Something went wrong trying to fetch series information', + }); + } + } +); + export default serviceRoutes; diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 22c683681..f87069d1a 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -165,7 +165,11 @@ const MovieDetails: React.FC = ({ movie }) => {
diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index fd62b6972..0aa51d62b 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -39,6 +39,7 @@ const messages = defineMessages({ errorediting: 'Something went wrong editing the request.', requestedited: 'Request edited.', autoapproval: 'Auto Approval', + requesterror: 'Something went wrong when trying to request media.', }); interface RequestModalProps extends React.HTMLAttributes { @@ -78,41 +79,50 @@ const MovieRequestModal: React.FC = ({ const sendRequest = useCallback(async () => { setIsUpdating(true); - let overrideParams = {}; - if (requestOverrides) { - overrideParams = { - serverId: requestOverrides.server, - profileId: requestOverrides.profile, - rootFolder: requestOverrides.folder, - }; - } - const response = await axios.post('/api/v1/request', { - mediaId: data?.id, - mediaType: 'movie', - is4k, - ...overrideParams, - }); - if (response.data) { - if (onComplete) { - onComplete( - hasPermission(Permission.AUTO_APPROVE) || - hasPermission(Permission.AUTO_APPROVE_MOVIE) - ? MediaStatus.PROCESSING - : MediaStatus.PENDING + try { + let overrideParams = {}; + if (requestOverrides) { + overrideParams = { + serverId: requestOverrides.server, + profileId: requestOverrides.profile, + rootFolder: requestOverrides.folder, + }; + } + const response = await axios.post('/api/v1/request', { + mediaId: data?.id, + mediaType: 'movie', + is4k, + ...overrideParams, + }); + + if (response.data) { + if (onComplete) { + onComplete( + hasPermission(Permission.AUTO_APPROVE) || + hasPermission(Permission.AUTO_APPROVE_MOVIE) + ? MediaStatus.PROCESSING + : MediaStatus.PENDING + ); + } + addToast( + + {intl.formatMessage(messages.requestSuccess, { + title: data?.title, + strong: function strong(msg) { + return {msg}; + }, + })} + , + { appearance: 'success', autoDismiss: true } ); } - addToast( - - {intl.formatMessage(messages.requestSuccess, { - title: data?.title, - strong: function strong(msg) { - return {msg}; - }, - })} - , - { appearance: 'success', autoDismiss: true } - ); + } catch (e) { + addToast(intl.formatMessage(messages.requesterror), { + appearance: 'error', + autoDismiss: true, + }); + } finally { setIsUpdating(false); } }, [data, onComplete, addToast, requestOverrides]); @@ -123,25 +133,29 @@ const MovieRequestModal: React.FC = ({ const cancelRequest = async () => { setIsUpdating(true); - const response = await axios.delete( - `/api/v1/request/${activeRequest?.id}` - ); - if (response.status === 204) { - if (onComplete) { - onComplete(MediaStatus.UNKNOWN); - } - addToast( - - {intl.formatMessage(messages.requestCancel, { - title: data?.title, - strong: function strong(msg) { - return {msg}; - }, - })} - , - { appearance: 'success', autoDismiss: true } + try { + const response = await axios.delete( + `/api/v1/request/${activeRequest?.id}` ); + + if (response.status === 204) { + if (onComplete) { + onComplete(MediaStatus.UNKNOWN); + } + addToast( + + {intl.formatMessage(messages.requestCancel, { + title: data?.title, + strong: function strong(msg) { + return {msg}; + }, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + } catch (e) { setIsUpdating(false); } }; diff --git a/src/components/RequestModal/SearchByNameModal/index.tsx b/src/components/RequestModal/SearchByNameModal/index.tsx new file mode 100644 index 000000000..a7e5f25b8 --- /dev/null +++ b/src/components/RequestModal/SearchByNameModal/index.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import Alert from '../../Common/Alert'; +import Modal from '../../Common/Modal'; +import { SmallLoadingSpinner } from '../../Common/LoadingSpinner'; +import useSWR from 'swr'; +import { defineMessages, useIntl } from 'react-intl'; +import { SonarrSeries } from '../../../../server/api/sonarr'; + +const messages = defineMessages({ + next: 'Next', + notvdbid: 'Manual match required', + notvdbiddescription: + "We couldn't automatically match your request. Please select the correct match from the list below.", + nosummary: 'No summary for this title was found.', +}); + +interface SearchByNameModalProps { + setTvdbId: (id: number) => void; + tvdbId: number | undefined; + loading: boolean; + onCancel?: () => void; + closeModal: () => void; + modalTitle: string; + tmdbId: number; +} + +const SearchByNameModal: React.FC = ({ + setTvdbId, + tvdbId, + loading, + onCancel, + closeModal, + modalTitle, + tmdbId, +}) => { + const intl = useIntl(); + const { data, error } = useSWR( + `/api/v1/service/sonarr/lookup/${tmdbId}` + ); + + const handleClick = (tvdbId: number) => { + setTvdbId(tvdbId); + }; + + return ( + + + + } + > + + {intl.formatMessage(messages.notvdbiddescription)} + + {!data && !error && } +
+ {data?.slice(0, 6).map((item) => ( + + ))} +
+
+ ); +}; + +export default SearchByNameModal; diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index f16dbb8e1..896746a9a 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -18,6 +18,7 @@ import globalMessages from '../../i18n/globalMessages'; import SeasonRequest from '../../../server/entity/SeasonRequest'; import Alert from '../Common/Alert'; import AdvancedRequester, { RequestOverrides } from './AdvancedRequester'; +import SearchByNameModal from './SearchByNameModal'; const messages = defineMessages({ requestadmin: 'Your request will be immediately approved.', @@ -40,6 +41,12 @@ const messages = defineMessages({ requestedited: 'Request edited.', requestcancelled: 'Request cancelled.', autoapproval: 'Auto Approval', + requesterror: 'Something went wrong when trying to request media.', + next: 'Next', + notvdbid: 'No TVDB id was found connected on TMDB', + notvdbiddescription: + 'Either add the TVDB id to TMDB and come back later, or select the correct match below.', + backbutton: 'Back', }); interface RequestModalProps extends React.HTMLAttributes { @@ -73,6 +80,12 @@ const TvRequestModal: React.FC = ({ ); const intl = useIntl(); const { hasPermission } = useUser(); + const [searchModal, setSearchModal] = useState<{ + show: boolean; + }>({ + show: true, + }); + const [tvdbId, setTvdbId] = useState(undefined); const updateRequest = async () => { if (!editRequest) { @@ -129,38 +142,47 @@ const TvRequestModal: React.FC = ({ if (onUpdating) { onUpdating(true); } - let overrideParams = {}; - if (requestOverrides) { - overrideParams = { - serverId: requestOverrides.server, - profileId: requestOverrides.profile, - rootFolder: requestOverrides.folder, - }; - } - const response = await axios.post('/api/v1/request', { - mediaId: data?.id, - tvdbId: data?.externalIds.tvdbId, - mediaType: 'tv', - is4k, - seasons: selectedSeasons, - ...overrideParams, - }); - if (response.data) { - if (onComplete) { - onComplete(response.data.media.status); + try { + let overrideParams = {}; + if (requestOverrides) { + overrideParams = { + serverId: requestOverrides.server, + profileId: requestOverrides.profile, + rootFolder: requestOverrides.folder, + }; } - addToast( - - {intl.formatMessage(messages.requestSuccess, { - title: data?.name, - strong: function strong(msg) { - return {msg}; - }, - })} - , - { appearance: 'success', autoDismiss: true } - ); + const response = await axios.post('/api/v1/request', { + mediaId: data?.id, + tvdbId: tvdbId ?? data?.externalIds.tvdbId, + mediaType: 'tv', + is4k, + seasons: selectedSeasons, + ...overrideParams, + }); + + if (response.data) { + if (onComplete) { + onComplete(response.data.media.status); + } + addToast( + + {intl.formatMessage(messages.requestSuccess, { + title: data?.name, + strong: function strong(msg) { + return {msg}; + }, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + } catch (e) { + addToast(intl.formatMessage(messages.requesterror), { + appearance: 'error', + autoDismiss: true, + }); + } finally { if (onUpdating) { onUpdating(false); } @@ -279,11 +301,24 @@ const TvRequestModal: React.FC = ({ return seasonRequest; }; - return ( + return !data?.externalIds.tvdbId && searchModal.show ? ( + setSearchModal({ show: false })} + loading={!data && !error} + onCancel={onCancel} + modalTitle={intl.formatMessage( + is4k ? messages.request4ktitle : messages.requesttitle, + { title: data?.name } + )} + tmdbId={tmdbId} + /> + ) : ( setSearchModal({ show: true }) : onCancel} onOk={() => (editRequest ? updateRequest() : sendRequest())} title={intl.formatMessage( is4k ? messages.request4ktitle : messages.requesttitle, @@ -302,6 +337,11 @@ const TvRequestModal: React.FC = ({ okButtonType={ editRequest && selectedSeasons.length === 0 ? 'danger' : `primary` } + cancelText={ + tvdbId + ? intl.formatMessage(messages.backbutton) + : intl.formatMessage(globalMessages.cancel) + } iconSvg={ = ({ showDetail ? 'scale-105' : '' }`} style={{ - backgroundImage: `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`, + backgroundImage: image + ? `url(//image.tmdb.org/t/p/w300_and_h450_face${image})` + : `url('/images/overseerr_poster_not_found_logo_top.png')`, }} onMouseEnter={() => { if (!isTouch) { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 6cb399f71..493d3c227 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -134,14 +134,22 @@ "components.RequestModal.AdvancedRequester.loadingprofiles": "Loading profiles…", "components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile", "components.RequestModal.AdvancedRequester.rootfolder": "Root Folder", + "components.RequestModal.SearchByNameModal.next": "Next", + "components.RequestModal.SearchByNameModal.nosummary": "No summary for this title was found.", + "components.RequestModal.SearchByNameModal.notvdbid": "Manual match required", + "components.RequestModal.SearchByNameModal.notvdbiddescription": "We couldn't automatically match your request. Please select the correct match from the list below.", "components.RequestModal.autoapproval": "Auto Approval", + "components.RequestModal.backbutton": "Back", "components.RequestModal.cancel": "Cancel Request", "components.RequestModal.cancelling": "Cancelling…", "components.RequestModal.cancelrequest": "This will remove your request. Are you sure you want to continue?", "components.RequestModal.close": "Close", "components.RequestModal.errorediting": "Something went wrong editing the request.", "components.RequestModal.extras": "Extras", + "components.RequestModal.next": "Next", "components.RequestModal.notrequested": "Not Requested", + "components.RequestModal.notvdbid": "No TVDB id was found connected on TMDB", + "components.RequestModal.notvdbiddescription": "Either add the TVDB id to TMDB and come back later, or select the correct match below.", "components.RequestModal.numberofepisodes": "# of Episodes", "components.RequestModal.pending4krequest": "Pending request for {title} in 4K", "components.RequestModal.pendingrequest": "Pending request for {title}", @@ -154,6 +162,7 @@ "components.RequestModal.requestadmin": "Your request will be immediately approved.", "components.RequestModal.requestcancelled": "Request cancelled.", "components.RequestModal.requestedited": "Request edited.", + "components.RequestModal.requesterror": "Something went wrong when trying to request media.", "components.RequestModal.requestfrom": "There is currently a pending request from {username}", "components.RequestModal.requesting": "Requesting…", "components.RequestModal.requestseasons": "Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}",