From 764db94f1bd7866309684d5bd56033b21cbc2e0c Mon Sep 17 00:00:00 2001 From: sct Date: Sun, 20 Dec 2020 11:00:48 +0900 Subject: [PATCH 01/23] fix(plex-sync): fixes processing movies using TMDB agent fix #363 --- server/job/plexsync/index.ts | 75 +++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index c38197ff..55bb420f 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -1,7 +1,10 @@ import { getRepository } from 'typeorm'; import { User } from '../../entity/User'; import PlexAPI, { PlexLibraryItem } from '../../api/plexapi'; -import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb'; +import TheMovieDb, { + TmdbMovieDetails, + TmdbTvDetails, +} from '../../api/themoviedb'; import Media from '../../entity/Media'; import { MediaStatus, MediaType } from '../../constants/media'; import logger from '../../logger'; @@ -93,40 +96,58 @@ class JobPlexSync { this.log(`Saved ${plexitem.title}`); } } else { - const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/); + let tmdbMovieId: number | undefined; + let tmdbMovie: TmdbMovieDetails | undefined; - if (matchedid?.[1]) { - const tmdbMovie = await this.tmdb.getMovieByImdbId({ - imdbId: matchedid[1], + const imdbMatch = plexitem.guid.match(imdbRegex); + const tmdbMatch = plexitem.guid.match(tmdbRegex); + + if (imdbMatch) { + tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: imdbMatch[1], }); + tmdbMovieId = tmdbMovie.id; + } else if (tmdbMatch) { + tmdbMovieId = Number(tmdbMatch[1]); + } - const existing = await this.getExisting(tmdbMovie.id); - if (existing && existing.status === MediaStatus.AVAILABLE) { - this.log(`Title exists and is already available ${plexitem.title}`); - } else if (existing && existing.status !== MediaStatus.AVAILABLE) { - existing.status = MediaStatus.AVAILABLE; - await mediaRepository.save(existing); - this.log( - `Request for ${plexitem.title} exists. Setting status AVAILABLE`, - 'info' - ); - } else if (tmdbMovie) { - const newMedia = new Media(); - newMedia.imdbId = tmdbMovie.external_ids.imdb_id; - newMedia.tmdbId = tmdbMovie.id; - newMedia.status = MediaStatus.AVAILABLE; - newMedia.mediaType = MediaType.MOVIE; - await mediaRepository.save(newMedia); - this.log(`Saved ${tmdbMovie.title}`); + if (!tmdbMovieId) { + throw new Error('Unable to find TMDB ID'); + } + + const existing = await this.getExisting(tmdbMovieId); + if (existing && existing.status === MediaStatus.AVAILABLE) { + this.log(`Title exists and is already available ${plexitem.title}`); + } else if (existing && existing.status !== MediaStatus.AVAILABLE) { + existing.status = MediaStatus.AVAILABLE; + await mediaRepository.save(existing); + this.log( + `Request for ${plexitem.title} exists. Setting status AVAILABLE`, + 'info' + ); + } else { + // If we have a tmdb movie guid but it didn't already exist, only then + // do we request the movie from tmdb (to reduce api requests) + if (!tmdbMovie) { + tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId }); } + const newMedia = new Media(); + newMedia.imdbId = tmdbMovie.external_ids.imdb_id; + newMedia.tmdbId = tmdbMovie.id; + newMedia.status = MediaStatus.AVAILABLE; + newMedia.mediaType = MediaType.MOVIE; + await mediaRepository.save(newMedia); + this.log(`Saved ${tmdbMovie.title}`); } } } catch (e) { this.log( - `Failed to process plex item. ratingKey: ${ - plexitem.parentRatingKey ?? plexitem.ratingKey - }`, - 'error' + `Failed to process plex item. ratingKey: ${plexitem.ratingKey}`, + 'error', + { + errorMessage: e.message, + plexitem, + } ); } } From 44a305426f3e9829c167a4a73095d0d248641f47 Mon Sep 17 00:00:00 2001 From: sct Date: Sun, 20 Dec 2020 21:41:25 +0900 Subject: [PATCH 02/23] feat(notifications): added ability to send test notifications closes #309 --- overseerr-api.yml | 30 ++++++ server/lib/notifications/agents/agent.ts | 10 ++ server/lib/notifications/agents/discord.ts | 95 ++++++++++++------ server/lib/notifications/agents/email.ts | 70 +++++++++++--- server/lib/notifications/index.ts | 1 + server/lib/settings.ts | 6 +- server/routes/settings.ts | 41 ++++++++ server/templates/email/test-email/html.pug | 96 +++++++++++++++++++ server/templates/email/test-email/subject.pug | 1 + .../Notifications/NotificationsDiscord.tsx | 38 +++++++- .../Notifications/NotificationsEmail.tsx | 43 ++++++++- src/i18n/locale/en.json | 2 + 12 files changed, 378 insertions(+), 55 deletions(-) create mode 100644 server/templates/email/test-email/html.pug create mode 100644 server/templates/email/test-email/subject.pug diff --git a/overseerr-api.yml b/overseerr-api.yml index 20901797..b52527ac 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1488,6 +1488,21 @@ paths: application/json: schema: $ref: '#/components/schemas/NotificationEmailSettings' + /settings/notifications/email/test: + post: + summary: Test the provided email settings + description: Sends a test notification to the email agent + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + responses: + '204': + description: Test notification attempted /settings/notifications/discord: get: summary: Return current discord notification settings @@ -1519,6 +1534,21 @@ paths: application/json: schema: $ref: '#/components/schemas/DiscordSettings' + /settings/notifications/discord/test: + post: + summary: Test the provided discord settings + description: Sends a test notification to the discord agent + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + responses: + '204': + description: Test notification attempted /settings/about: get: summary: Return current about stats diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index 15d57bca..d04cabf0 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -1,5 +1,6 @@ import { Notification } from '..'; import { User } from '../../../entity/User'; +import { NotificationAgentConfig } from '../../settings'; export interface NotificationPayload { subject: string; @@ -9,6 +10,15 @@ export interface NotificationPayload { extra?: { name: string; value: string }[]; } +export abstract class BaseAgent { + protected settings?: T; + public constructor(settings?: T) { + this.settings = settings; + } + + protected abstract getSettings(): T; +} + export interface NotificationAgent { shouldSend(type: Notification): boolean; send(type: Notification, payload: NotificationPayload): Promise; diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 008e9149..92348e43 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -1,8 +1,8 @@ import axios from 'axios'; import { Notification } from '..'; import logger from '../../../logger'; -import { getSettings } from '../../settings'; -import type { NotificationAgent, NotificationPayload } from './agent'; +import { getSettings, NotificationAgentDiscord } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; enum EmbedColors { DEFAULT = 0, @@ -37,6 +37,11 @@ interface DiscordImageEmbed { width?: number; } +interface Field { + name: string; + value: string; + inline?: boolean; +} interface DiscordRichEmbed { title?: string; type?: 'rich'; // Always rich for webhooks @@ -61,11 +66,7 @@ interface DiscordRichEmbed { icon_url?: string; proxy_icon_url?: string; }; - fields?: { - name: string; - value: string; - inline?: boolean; - }[]; + fields?: Field[]; } interface DiscordWebhookPayload { @@ -75,27 +76,75 @@ interface DiscordWebhookPayload { tts: boolean; } -class DiscordAgent implements NotificationAgent { +class DiscordAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentDiscord { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.discord; + } + public buildEmbed( type: Notification, payload: NotificationPayload ): DiscordRichEmbed { let color = EmbedColors.DEFAULT; - let status = 'Unknown'; + + const fields: Field[] = []; switch (type) { case Notification.MEDIA_PENDING: color = EmbedColors.ORANGE; - status = 'Pending Approval'; + fields.push( + { + name: 'Requested By', + value: payload.notifyUser.username ?? '', + inline: true, + }, + { + name: 'Status', + value: 'Pending Approval', + inline: true, + } + ); break; case Notification.MEDIA_APPROVED: color = EmbedColors.PURPLE; - status = 'Processing Request'; + fields.push( + { + name: 'Requested By', + value: payload.notifyUser.username ?? '', + inline: true, + }, + { + name: 'Status', + value: 'Processing Request', + inline: true, + } + ); break; case Notification.MEDIA_AVAILABLE: color = EmbedColors.GREEN; - status = 'Available'; + fields.push( + { + name: 'Requested By', + value: payload.notifyUser.username ?? '', + inline: true, + }, + { + name: 'Status', + value: 'Available', + inline: true, + } + ); break; + default: + color = EmbedColors.DARK_PURPLE; } return { @@ -105,16 +154,7 @@ class DiscordAgent implements NotificationAgent { timestamp: new Date().toISOString(), author: { name: 'Overseerr' }, fields: [ - { - name: 'Requested By', - value: payload.notifyUser.username ?? '', - inline: true, - }, - { - name: 'Status', - value: status, - inline: true, - }, + ...fields, // If we have extra data, map it to fields for discord notifications ...(payload.extra ?? []).map((extra) => ({ name: extra.name, @@ -130,12 +170,7 @@ class DiscordAgent implements NotificationAgent { // TODO: Add checking for type here once we add notification type filters for agents // eslint-disable-next-line @typescript-eslint/no-unused-vars public shouldSend(_type: Notification): boolean { - const settings = getSettings(); - - if ( - settings.notifications.agents.discord?.enabled && - settings.notifications.agents.discord?.options?.webhookUrl - ) { + if (this.getSettings().enabled && this.getSettings().options.webhookUrl) { return true; } @@ -146,11 +181,9 @@ class DiscordAgent implements NotificationAgent { type: Notification, payload: NotificationPayload ): Promise { - const settings = getSettings(); logger.debug('Sending discord notification', { label: 'Notifications' }); try { - const webhookUrl = - settings.notifications.agents.discord?.options?.webhookUrl; + const webhookUrl = this.getSettings().options.webhookUrl; if (!webhookUrl) { return false; diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 18552525..354a5150 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -1,7 +1,7 @@ -import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { Notification } from '..'; import path from 'path'; -import { getSettings } from '../../settings'; +import { getSettings, NotificationAgentEmail } from '../../settings'; import nodemailer from 'nodemailer'; import Email from 'email-templates'; import logger from '../../../logger'; @@ -9,13 +9,25 @@ import { getRepository } from 'typeorm'; import { User } from '../../../entity/User'; import { Permission } from '../../permissions'; -class EmailAgent implements NotificationAgent { +class EmailAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentEmail { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.email; + } + // TODO: Add checking for type here once we add notification type filters for agents // eslint-disable-next-line @typescript-eslint/no-unused-vars public shouldSend(_type: Notification): boolean { - const settings = getSettings(); + const settings = this.getSettings(); - if (settings.notifications.agents.email.enabled) { + if (settings.enabled) { return true; } @@ -23,7 +35,7 @@ class EmailAgent implements NotificationAgent { } private getSmtpTransport() { - const emailSettings = getSettings().notifications.agents.email.options; + const emailSettings = this.getSettings().options; return nodemailer.createTransport({ host: emailSettings.smtpHost, @@ -40,7 +52,7 @@ class EmailAgent implements NotificationAgent { } private getNewEmail() { - const settings = getSettings().notifications.agents.email; + const settings = this.getSettings(); return new Email({ message: { from: settings.options.emailFrom, @@ -51,7 +63,8 @@ class EmailAgent implements NotificationAgent { } private async sendMediaRequestEmail(payload: NotificationPayload) { - const settings = getSettings().main; + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; try { const userRepository = getRepository(User); const users = await userRepository.find(); @@ -76,7 +89,7 @@ class EmailAgent implements NotificationAgent { imageUrl: payload.image, timestamp: new Date().toTimeString(), requestedBy: payload.notifyUser.username, - actionUrl: settings.applicationUrl, + actionUrl: applicationUrl, requestType: 'New Request', }, }); @@ -92,7 +105,8 @@ class EmailAgent implements NotificationAgent { } private async sendMediaApprovedEmail(payload: NotificationPayload) { - const settings = getSettings().main; + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; try { const email = this.getNewEmail(); @@ -110,7 +124,7 @@ class EmailAgent implements NotificationAgent { imageUrl: payload.image, timestamp: new Date().toTimeString(), requestedBy: payload.notifyUser.username, - actionUrl: settings.applicationUrl, + actionUrl: applicationUrl, requestType: 'Request Approved', }, }); @@ -125,7 +139,8 @@ class EmailAgent implements NotificationAgent { } private async sendMediaAvailableEmail(payload: NotificationPayload) { - const settings = getSettings().main; + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; try { const email = this.getNewEmail(); @@ -143,7 +158,7 @@ class EmailAgent implements NotificationAgent { imageUrl: payload.image, timestamp: new Date().toTimeString(), requestedBy: payload.notifyUser.username, - actionUrl: settings.applicationUrl, + actionUrl: applicationUrl, requestType: 'Now Available', }, }); @@ -157,6 +172,32 @@ class EmailAgent implements NotificationAgent { } } + private async sendTestEmail(payload: NotificationPayload) { + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; + try { + const email = this.getNewEmail(); + + email.send({ + template: path.join(__dirname, '../../../templates/email/test-email'), + message: { + to: payload.notifyUser.email, + }, + locals: { + body: payload.message, + actionUrl: applicationUrl, + }, + }); + return true; + } catch (e) { + logger.error('Mail notification failed to send', { + label: 'Notifications', + message: e.message, + }); + return false; + } + } + public async send( type: Notification, payload: NotificationPayload @@ -173,6 +214,9 @@ class EmailAgent implements NotificationAgent { case Notification.MEDIA_AVAILABLE: this.sendMediaAvailableEmail(payload); break; + case Notification.TEST_NOTIFICATION: + this.sendTestEmail(payload); + break; } return true; diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index 91be4c5d..c826bfeb 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -5,6 +5,7 @@ export enum Notification { MEDIA_PENDING = 2, MEDIA_APPROVED = 4, MEDIA_AVAILABLE = 8, + TEST_NOTIFICATION = 16, } class NotificationManager { diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 32756363..02e6b46b 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -53,18 +53,18 @@ interface PublicSettings { initialized: boolean; } -interface NotificationAgent { +export interface NotificationAgentConfig { enabled: boolean; types: number; options: Record; } -interface NotificationAgentDiscord extends NotificationAgent { +export interface NotificationAgentDiscord extends NotificationAgentConfig { options: { webhookUrl: string; }; } -interface NotificationAgentEmail extends NotificationAgent { +export interface NotificationAgentEmail extends NotificationAgentConfig { options: { emailFrom: string; smtpHost: string; diff --git a/server/routes/settings.ts b/server/routes/settings.ts index 8f72e588..a55b861f 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings.ts @@ -21,6 +21,9 @@ import Media from '../entity/Media'; import { MediaRequest } from '../entity/MediaRequest'; import { getAppVersion } from '../utils/appVersion'; import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces'; +import { Notification } from '../lib/notifications'; +import DiscordAgent from '../lib/notifications/agents/discord'; +import EmailAgent from '../lib/notifications/agents/email'; const settingsRoutes = Router(); @@ -448,6 +451,25 @@ settingsRoutes.post('/notifications/discord', (req, res) => { res.status(200).json(settings.notifications.agents.discord); }); +settingsRoutes.post('/notifications/discord/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const discordAgent = new DiscordAgent(req.body); + discordAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + settingsRoutes.get('/notifications/email', (_req, res) => { const settings = getSettings(); @@ -463,6 +485,25 @@ settingsRoutes.post('/notifications/email', (req, res) => { res.status(200).json(settings.notifications.agents.email); }); +settingsRoutes.post('/notifications/email/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const emailAgent = new EmailAgent(req.body); + emailAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + settingsRoutes.get('/about', async (req, res) => { const mediaRepository = getRepository(Media); const mediaRequestRepository = getRepository(MediaRequest); diff --git a/server/templates/email/test-email/html.pug b/server/templates/email/test-email/html.pug new file mode 100644 index 00000000..46f4ca2c --- /dev/null +++ b/server/templates/email/test-email/html.pug @@ -0,0 +1,96 @@ +doctype html +head + meta(charset='utf-8') + meta(name='x-apple-disable-message-reformatting') + meta(http-equiv='x-ua-compatible' content='ie=edge') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') + link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen') + //if mso + xml + o:officedocumentsettings + o:pixelsperinch 96 + style. + td, + th, + div, + p, + a, + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: 'Segoe UI', sans-serif; + mso-line-height-rule: exactly; + } + style. + @media (max-width: 600px) { + .sm-w-full { + width: 100% !important; + } + } +div(role='article' aria-roledescription='email' aria-label='' lang='en') + table(style="\ + background-color: #f2f4f6;\ + font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\ + width: 100%;\ + " width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center') + table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center' style='\ + font-size: 16px;\ + padding-top: 25px;\ + padding-bottom: 25px;\ + text-align: center;\ + ') + a(href=actionUrl style='\ + text-shadow: 0 1px 0 #ffffff;\ + font-weight: 700;\ + font-size: 16px;\ + color: #a8aaaf;\ + text-decoration: none;\ + ') + | Overseerr + tr + td(style='width: 100%' width='100%') + table.sm-w-full(align='center' style='\ + background-color: #ffffff;\ + margin-left: auto;\ + margin-right: auto;\ + width: 570px;\ + ' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation') + tr + td(style='padding: 45px') + div(style='font-size: 16px') + | #{body} + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + color: #51545e;\ + ') + a(href=actionUrl style='color: #3869d4') Open Overseerr +tr + td + table.sm-w-full(align='center' style='\ + margin-left: auto;\ + margin-right: auto;\ + text-align: center;\ + width: 570px;\ + ' width='570' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center' style='font-size: 16px; padding: 45px') + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + text-align: center;\ + color: #a8aaaf;\ + ') + | Overseerr. diff --git a/server/templates/email/test-email/subject.pug b/server/templates/email/test-email/subject.pug new file mode 100644 index 00000000..6e50c1b5 --- /dev/null +++ b/server/templates/email/test-email/subject.pug @@ -0,0 +1 @@ += `Test Notification - Overseerr` diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx index 2f70269c..6bf001e7 100644 --- a/src/components/Settings/Notifications/NotificationsDiscord.tsx +++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx @@ -4,7 +4,7 @@ import useSWR from 'swr'; import LoadingSpinner from '../../Common/LoadingSpinner'; import Button from '../../Common/Button'; import { defineMessages, useIntl } from 'react-intl'; -import Axios from 'axios'; +import axios from 'axios'; import * as Yup from 'yup'; import { useToasts } from 'react-toast-notifications'; @@ -17,6 +17,8 @@ const messages = defineMessages({ webhookUrlPlaceholder: 'Server Settings -> Integrations -> Webhooks', discordsettingssaved: 'Discord notification settings saved!', discordsettingsfailed: 'Discord notification settings failed to save.', + testsent: 'Test notification sent!', + test: 'Test', }); const NotificationsDiscord: React.FC = () => { @@ -46,7 +48,7 @@ const NotificationsDiscord: React.FC = () => { validationSchema={NotificationsDiscordSchema} onSubmit={async (values) => { try { - await Axios.post('/api/v1/settings/notifications/discord', { + await axios.post('/api/v1/settings/notifications/discord', { enabled: values.enabled, types: values.types, options: { @@ -67,7 +69,22 @@ const NotificationsDiscord: React.FC = () => { } }} > - {({ errors, touched, isSubmitting }) => { + {({ errors, touched, isSubmitting, values, isValid }) => { + const testSettings = async () => { + await axios.post('/api/v1/settings/notifications/discord/test', { + enabled: true, + types: values.types, + options: { + webhookUrl: values.webhookUrl, + }, + }); + + addToast(intl.formatMessage(messages.testsent), { + appearance: 'info', + autoDismiss: true, + }); + }; + return (
@@ -112,11 +129,24 @@ const NotificationsDiscord: React.FC = () => {
+ + + + -
+
{intl.formatMessage(messages.manageModalClearMediaWarning)}
)} -
-
+
+
-
+
{data.mediaInfo?.status === MediaStatus.AVAILABLE && ( @@ -224,7 +229,7 @@ const MovieDetails: React.FC = ({ movie }) => { {data.title}{' '} ({data.releaseDate.slice(0, 4)}) - + {(data.runtime ?? 0) > 0 && ( <> = ({ movie }) => { {data.genres.map((g) => g.name).join(', ')}
-
+
{(!data.mediaInfo || data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
-
+

@@ -392,11 +397,26 @@ const MovieDetails: React.FC = ({ movie }) => { ? data.overview : intl.formatMessage(messages.overviewunavailable)}

+
    + {sortedCrew.slice(0, 6).map((person) => ( +
  • + {person.job} + + + {person.name} + + +
  • + ))} +

-
-
+
+
{(data.voteCount > 0 || ratingData) && ( -
+
{ratingData?.criticsRating && (ratingData?.criticsScore ?? 0) > 0 && ( <> @@ -407,7 +427,7 @@ const MovieDetails: React.FC = ({ movie }) => { )} - + {ratingData.criticsScore}% @@ -422,7 +442,7 @@ const MovieDetails: React.FC = ({ movie }) => { )} - + {ratingData.audienceScore}% @@ -432,7 +452,7 @@ const MovieDetails: React.FC = ({ movie }) => { - + {data.voteAverage}/10 @@ -443,7 +463,7 @@ const MovieDetails: React.FC = ({ movie }) => { - + = ({ movie }) => { - + {data.status}
@@ -465,7 +485,7 @@ const MovieDetails: React.FC = ({ movie }) => { - + = ({ movie }) => { - + = ({ movie }) => { - + { data.spokenLanguages.find( (lng) => lng.iso_639_1 === data.originalLanguage @@ -509,7 +529,7 @@ const MovieDetails: React.FC = ({ movie }) => { - + {data.productionCompanies[0]?.name}
@@ -525,10 +545,10 @@ const MovieDetails: React.FC = ({ movie }) => {
-
+
- + @@ -566,13 +586,13 @@ const MovieDetails: React.FC = ({ movie }) => { /> {(recommended?.results ?? []).length > 0 && ( <> -
+
- + @@ -616,13 +636,13 @@ const MovieDetails: React.FC = ({ movie }) => { )} {(similar?.results ?? []).length > 0 && ( <> -
+
- + diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index f9942c9b..58993258 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useContext } from 'react'; +import React, { useState, useContext, useMemo } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import { useRouter } from 'next/router'; @@ -30,6 +30,8 @@ import Head from 'next/head'; import globalMessages from '../../i18n/globalMessages'; import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb'; import ExternalLinkBlock from '../ExternalLinkBlock'; +import { sortCrewPriority } from '../../utils/creditHelpers'; +import { Crew } from '../../../server/models/common'; const messages = defineMessages({ userrating: 'User Rating', @@ -105,6 +107,10 @@ const TvDetails: React.FC = ({ tv }) => { `/api/v1/tv/${router.query.tvId}/ratings` ); + const sortedCrew = useMemo(() => sortCrewPriority(data?.credits.crew ?? []), [ + data, + ]); + if (!data && !error) { return ; } @@ -148,7 +154,7 @@ const TvDetails: React.FC = ({ tv }) => { return (
= ({ tv }) => { onClose={() => setShowManager(false)} subText={data.name} > -

+

{intl.formatMessage(messages.manageModalRequests)}

-
+
+ {sortedCrew.length > 0 && ( + + )}
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 5fae284e..dda83fff 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -19,6 +19,7 @@ "components.Layout.alphawarning": "This is ALPHA software. Almost everything is bound to be nearly broken and/or unstable. Please report issues to the Overseerr GitHub!", "components.Login.signinplex": "Sign in to continue", "components.MovieDetails.MovieCast.fullcast": "Full Cast", + "components.MovieDetails.MovieCrew.fullcrew": "Full Crew", "components.MovieDetails.approve": "Approve", "components.MovieDetails.available": "Available", "components.MovieDetails.budget": "Budget", @@ -46,9 +47,11 @@ "components.MovieDetails.studio": "Studio", "components.MovieDetails.unavailable": "Unavailable", "components.MovieDetails.userrating": "User Rating", + "components.MovieDetails.viewfullcrew": "View Full Crew", "components.MovieDetails.viewrequest": "View Request", "components.PersonDetails.appearsin": "Appears in", "components.PersonDetails.ascharacter": "as {character}", + "components.PersonDetails.crewmember": "Crew Member", "components.PersonDetails.nobiography": "No biography available.", "components.PlexLoginButton.loading": "Loadingโ€ฆ", "components.PlexLoginButton.loggingin": "Logging inโ€ฆ", @@ -277,6 +280,7 @@ "components.TitleCard.movie": "Movie", "components.TitleCard.tvshow": "Series", "components.TvDetails.TvCast.fullseriescast": "Full Series Cast", + "components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew", "components.TvDetails.anime": "Anime", "components.TvDetails.approve": "Approve", "components.TvDetails.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}", @@ -305,6 +309,7 @@ "components.TvDetails.status": "Status", "components.TvDetails.unavailable": "Unavailable", "components.TvDetails.userrating": "User Rating", + "components.TvDetails.viewfullcrew": "View Full Crew", "components.UserEdit.admin": "Admin", "components.UserEdit.adminDescription": "Full administrator access. Bypasses all permission checks.", "components.UserEdit.autoapprove": "Auto Approve", diff --git a/src/pages/movie/[movieId]/crew.tsx b/src/pages/movie/[movieId]/crew.tsx new file mode 100644 index 00000000..6ba59053 --- /dev/null +++ b/src/pages/movie/[movieId]/crew.tsx @@ -0,0 +1,9 @@ +import { NextPage } from 'next'; +import React from 'react'; +import MovieCrew from '../../../components/MovieDetails/MovieCrew'; + +const MovieCrewPage: NextPage = () => { + return ; +}; + +export default MovieCrewPage; diff --git a/src/pages/tv/[tvId]/crew.tsx b/src/pages/tv/[tvId]/crew.tsx new file mode 100644 index 00000000..aec04080 --- /dev/null +++ b/src/pages/tv/[tvId]/crew.tsx @@ -0,0 +1,9 @@ +import { NextPage } from 'next'; +import React from 'react'; +import TvCrew from '../../../components/TvDetails/TvCrew'; + +const TvCrewPage: NextPage = () => { + return ; +}; + +export default TvCrewPage; From 16e48c49199459a657899b27fbdfd8d1c57d621c Mon Sep 17 00:00:00 2001 From: sct Date: Tue, 22 Dec 2020 09:45:40 +0900 Subject: [PATCH 22/23] build: remove yml from dockerignore [skip ci] --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 389ce773..4d49270e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,4 +13,3 @@ config/db/db.sqlite3 config/db/logs/overseerr.log Dockerfil** **.md -**.yml From 4b505223b881a750007e3fbc7d4bcb9677d4d412 Mon Sep 17 00:00:00 2001 From: sct Date: Tue, 22 Dec 2020 11:12:41 +0900 Subject: [PATCH 23/23] fix(logs): improve logging when adding to sonarr/radarr --- server/api/radarr.ts | 15 ++++++++++++++- server/api/sonarr.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/server/api/radarr.ts b/server/api/radarr.ts index 4797ef5d..968cb21d 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -78,7 +78,7 @@ class RadarrAPI { public addMovie = async (options: RadarrMovieOptions): Promise => { try { - await this.axios.post(`/movie`, { + const response = await this.axios.post(`/movie`, { title: options.title, qualityProfileId: options.qualityProfileId, profileId: options.profileId, @@ -92,6 +92,19 @@ class RadarrAPI { searchForMovie: options.searchNow, }, }); + + if (response.data.id) { + logger.info('Radarr accepted request', { label: 'Radarr' }); + logger.debug('Radarr add details', { + label: 'Radarr', + movie: response.data, + }); + } else { + logger.error('Failed to add movie to Radarr', { + label: 'Radarr', + options, + }); + } } catch (e) { logger.error( 'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.', diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 903cd4cc..a4937876 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -126,7 +126,7 @@ class SonarrAPI { series.addOptions = { ignoreEpisodesWithFiles: true, - searchForMissingEpisodes: true, + searchForMissingEpisodes: options.searchNow, }; const newSeriesResponse = await this.axios.put( @@ -134,6 +134,21 @@ class SonarrAPI { series ); + if (newSeriesResponse.data.id) { + logger.info('Sonarr accepted request. Updated existing series', { + label: 'Sonarr', + }); + logger.debug('Sonarr add details', { + label: 'Sonarr', + movie: newSeriesResponse.data, + }); + } else { + logger.error('Failed to add movie to Sonarr', { + label: 'Sonarr', + options, + }); + } + return newSeriesResponse.data; } @@ -162,6 +177,19 @@ class SonarrAPI { } as Partial ); + if (createdSeriesResponse.data.id) { + logger.info('Sonarr accepted request', { label: 'Sonarr' }); + logger.debug('Sonarr add details', { + label: 'Sonarr', + movie: createdSeriesResponse.data, + }); + } else { + logger.error('Failed to add movie to Sonarr', { + label: 'Sonarr', + options, + }); + } + return createdSeriesResponse.data; } catch (e) { logger.error('Something went wrong adding a series to Sonarr', {