diff --git a/overseerr-api.yml b/overseerr-api.yml index 37797c9e1..3c0ff7756 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -752,6 +752,20 @@ components: results: type: number example: 100 + DiscordSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + webhookUrl: + type: string securitySchemes: cookieAuth: @@ -1207,7 +1221,37 @@ paths: nextExecutionTime: type: string example: '2020-09-02T05:02:23.000Z' - + /settings/notifications/discord: + get: + summary: Return current discord notification settings + description: Returns current discord notification settings in JSON format + tags: + - settings + responses: + '200': + description: Returned discord settings + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + post: + summary: Update discord notification settings + description: Update current discord notification settings with provided values + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' /auth/me: get: summary: Returns the currently logged in user diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index e27f96185..6c59adbe7 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -20,6 +20,7 @@ import RadarrAPI from '../api/radarr'; import logger from '../logger'; import SeasonRequest from './SeasonRequest'; import SonarrAPI from '../api/sonarr'; +import notificationManager, { Notification } from '../lib/notifications'; @Entity() export class MediaRequest { @@ -60,6 +61,22 @@ export class MediaRequest { Object.assign(this, init); } + @AfterInsert() + private async notifyNewRequest() { + if (this.status === MediaRequestStatus.PENDING) { + const tmdb = new TheMovieDb(); + if (this.media.mediaType === MediaType.MOVIE) { + const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); + notificationManager.sendNotification(Notification.MEDIA_ADDED, { + subject: `New Request: ${movie.title}`, + message: movie.overview, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + username: this.requestedBy.username, + }); + } + } + } + @AfterUpdate() @AfterInsert() private async updateParentStatus() { diff --git a/server/index.ts b/server/index.ts index a702c4df1..f9d3d34ae 100644 --- a/server/index.ts +++ b/server/index.ts @@ -14,6 +14,8 @@ import { Session } from './entity/Session'; import { getSettings } from './lib/settings'; import logger from './logger'; import { startJobs } from './job/schedule'; +import notificationManager from './lib/notifications'; +import DiscordAgent from './lib/notifications/agents/discord'; const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); @@ -28,6 +30,9 @@ app // Load Settings getSettings().load(); + // Register Notification Agents + notificationManager.registerAgents([new DiscordAgent()]); + // Start Jobs startJobs(); diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts new file mode 100644 index 000000000..26fd7639e --- /dev/null +++ b/server/lib/notifications/agents/agent.ts @@ -0,0 +1,13 @@ +import { Notification } from '..'; + +export interface NotificationPayload { + subject: string; + username?: string; + image?: string; + message?: string; +} + +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 new file mode 100644 index 000000000..02540c1dc --- /dev/null +++ b/server/lib/notifications/agents/discord.ts @@ -0,0 +1,157 @@ +import axios from 'axios'; +import { Notification } from '..'; +import logger from '../../../logger'; +import { getSettings } from '../../settings'; +import type { NotificationAgent, NotificationPayload } from './agent'; + +enum EmbedColors { + DEFAULT = 0, + AQUA = 1752220, + GREEN = 3066993, + BLUE = 3447003, + PURPLE = 10181046, + GOLD = 15844367, + ORANGE = 15105570, + RED = 15158332, + GREY = 9807270, + DARKER_GREY = 8359053, + NAVY = 3426654, + DARK_AQUA = 1146986, + DARK_GREEN = 2067276, + DARK_BLUE = 2123412, + DARK_PURPLE = 7419530, + DARK_GOLD = 12745742, + DARK_ORANGE = 11027200, + DARK_RED = 10038562, + DARK_GREY = 9936031, + LIGHT_GREY = 12370112, + DARK_NAVY = 2899536, + LUMINOUS_VIVID_PINK = 16580705, + DARK_VIVID_PINK = 12320855, +} + +interface DiscordImageEmbed { + url?: string; + proxy_url?: string; + height?: number; + width?: number; +} + +interface DiscordRichEmbed { + title?: string; + type?: 'rich'; // Always rich for webhooks + description?: string; + url?: string; + timestamp?: string; + color?: number; + footer?: { + text: string; + icon_url?: string; + proxy_icon_url?: string; + }; + image?: DiscordImageEmbed; + thumbnail?: DiscordImageEmbed; + provider?: { + name?: string; + url?: string; + }; + author?: { + name?: string; + url?: string; + icon_url?: string; + proxy_icon_url?: string; + }; + fields?: { + name: string; + value: string; + inline?: boolean; + }[]; +} + +interface DiscordWebhookPayload { + embeds: DiscordRichEmbed[]; + username: string; + avatar_url?: string; + tts: boolean; +} + +class DiscordAgent implements NotificationAgent { + public buildEmbed( + type: Notification, + payload: NotificationPayload + ): DiscordRichEmbed { + let color = EmbedColors.DEFAULT; + + switch (type) { + case Notification.MEDIA_ADDED: + color = EmbedColors.ORANGE; + } + + return { + title: payload.subject, + description: payload.message, + color, + timestamp: new Date().toISOString(), + author: { name: 'Overseerr' }, + fields: [ + { + name: 'Requested By', + value: payload.username ?? '', + inline: true, + }, + { + name: 'Status', + value: 'Pending Approval', + inline: true, + }, + ], + thumbnail: { + url: payload.image, + }, + }; + } + + public shouldSend(type: Notification): boolean { + const settings = getSettings(); + + if ( + settings.notifications.agents.discord?.enabled && + settings.notifications.agents.discord?.options?.webhookUrl + ) { + return true; + } + + return false; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + const settings = getSettings(); + logger.debug('Sending discord notification', { label: 'Notifications' }); + try { + const webhookUrl = settings.notifications.agents.discord?.options + ?.webhookUrl as string; + + if (!webhookUrl) { + return false; + } + + await axios.post(webhookUrl, { + username: 'Overseerr', + embeds: [this.buildEmbed(type, payload)], + } as DiscordWebhookPayload); + + return true; + } catch (e) { + logger.error('Error sending Discord notification', { + label: 'Notifications', + message: e.message, + }); + return false; + } + } +} + +export default DiscordAgent; diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts new file mode 100644 index 000000000..05dc2aa64 --- /dev/null +++ b/server/lib/notifications/index.ts @@ -0,0 +1,33 @@ +import logger from '../../logger'; +import type { NotificationAgent, NotificationPayload } from './agents/agent'; + +export enum Notification { + MEDIA_ADDED = 2, +} + +class NotificationManager { + private activeAgents: NotificationAgent[] = []; + + public registerAgents = (agents: NotificationAgent[]): void => { + this.activeAgents = [...this.activeAgents, ...agents]; + logger.info('Registered Notification Agents', { label: 'Notifications' }); + }; + + public sendNotification( + type: Notification, + payload: NotificationPayload + ): void { + logger.info(`Sending notification for ${Notification[type]}`, { + label: 'Notifications', + }); + this.activeAgents.forEach((agent) => { + if (agent.shouldSend(type)) { + agent.send(type, payload); + } + }); + } +} + +const notificationManager = new NotificationManager(); + +export default notificationManager; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 148f395aa..72d65dfe4 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -50,6 +50,16 @@ interface PublicSettings { initialized: boolean; } +interface NotificationAgent { + enabled: boolean; + types: number; + options: Record; +} + +interface NotificationSettings { + agents: Record; +} + interface AllSettings { clientId?: string; main: MainSettings; @@ -57,6 +67,7 @@ interface AllSettings { radarr: RadarrSettings[]; sonarr: SonarrSettings[]; public: PublicSettings; + notifications: NotificationSettings; } const SETTINGS_PATH = path.join(__dirname, '../../config/settings.json'); @@ -80,6 +91,17 @@ class Settings { public: { initialized: false, }, + notifications: { + agents: { + discord: { + enabled: false, + types: 0, + options: { + webhookUrl: '', + }, + }, + }, + }, }; if (initialSettings) { Object.assign(this.data, initialSettings); @@ -126,6 +148,14 @@ class Settings { this.data.public = data; } + get notifications(): NotificationSettings { + return this.data.notifications; + } + + set notifications(data: NotificationSettings) { + this.data.notifications = data; + } + get clientId(): string { if (!this.data.clientId) { this.data.clientId = uuidv4(); @@ -156,6 +186,7 @@ class Settings { if (data) { this.data = Object.assign(this.data, JSON.parse(data)); + this.save(); } return this.data; } diff --git a/server/routes/settings.ts b/server/routes/settings.ts index 3e6ac12ba..5cdce2d95 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings.ts @@ -349,4 +349,19 @@ settingsRoutes.get( } ); +settingsRoutes.get('/notifications/discord', (req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.discord); +}); + +settingsRoutes.post('/notifications/discord', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.discord = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.discord); +}); + export default settingsRoutes; diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index a9f9a516f..32b728766 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -186,7 +186,7 @@ const MovieDetails: React.FC = ({ movie }) => { />
-
+
{data.mediaInfo?.status === MediaStatus.AVAILABLE && ( Available )} diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx new file mode 100644 index 000000000..1269e5cfc --- /dev/null +++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx @@ -0,0 +1,116 @@ +import React, { useState } from 'react'; +import { Field, Form, Formik } from 'formik'; +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 * as Yup from 'yup'; + +const messages = defineMessages({ + save: 'Save Changes', + saving: 'Saving...', +}); + +const NotificationsDiscord: React.FC = () => { + const intl = useIntl(); + const { data, error, revalidate } = useSWR( + '/api/v1/settings/notifications/discord' + ); + + const NotificationsDiscordSchema = Yup.object().shape({ + webhookUrl: Yup.string().required('You must provide a webhook URL'), + }); + + if (!data && !error) { + return ; + } + + return ( + { + try { + await Axios.post('/api/v1/settings/notifications/discord', { + enabled: values.enabled, + types: values.types, + options: { + webhookUrl: values.webhookUrl, + }, + }); + } catch (e) { + // TODO show error + } finally { + revalidate(); + } + }} + > + {({ errors, touched, isSubmitting }) => { + return ( +
+
+ +
+ +
+
+
+ +
+
+ +
+ {errors.webhookUrl && touched.webhookUrl && ( +
{errors.webhookUrl}
+ )} +
+
+
+
+ + + +
+
+
+ ); + }} +
+ ); +}; + +export default NotificationsDiscord; diff --git a/src/components/Settings/SettingsLayout.tsx b/src/components/Settings/SettingsLayout.tsx index f42745deb..94cda2a3c 100644 --- a/src/components/Settings/SettingsLayout.tsx +++ b/src/components/Settings/SettingsLayout.tsx @@ -24,6 +24,11 @@ const settingsRoutes: SettingsRoute[] = [ route: '/settings/services', regex: /^\/settings\/services/, }, + { + text: 'Notifications', + route: '/settings/notifications', + regex: /^\/settings\/notifications/, + }, { text: 'Logs', route: '/settings/logs', diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx new file mode 100644 index 000000000..bd533d39f --- /dev/null +++ b/src/components/Settings/SettingsNotifications.tsx @@ -0,0 +1,107 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React from 'react'; + +interface SettingsRoute { + text: string; + route: string; + regex: RegExp; +} + +const settingsRoutes: SettingsRoute[] = [ + { + text: 'General', + route: '/settings/notifications', + regex: /^\/settings\/notifications$/, + }, + { + text: 'Discord', + route: '/settings/notifications/discord', + regex: /^\/settings\/notifications\/discord/, + }, +]; + +const SettingsNotifications: React.FC = ({ children }) => { + const router = useRouter(); + + const activeLinkColor = 'bg-gray-700'; + + const inactiveLinkColor = ''; + + const SettingsLink: React.FC<{ + route: string; + regex: RegExp; + isMobile?: boolean; + }> = ({ children, route, regex, isMobile = false }) => { + if (isMobile) { + return ; + } + + return ( + + + {children} + + + ); + }; + + return ( + <> +
+
+ + +
+
+ +
+
+
{children}
+ + ); +}; + +export default SettingsNotifications; diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index be199ee56..82197adbc 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -189,7 +189,7 @@ const TvDetails: React.FC = ({ tv }) => { />
-
+
{data.mediaInfo?.status === MediaStatus.AVAILABLE && ( Available )} diff --git a/src/pages/settings/notifications/discord.tsx b/src/pages/settings/notifications/discord.tsx new file mode 100644 index 000000000..a8e725b15 --- /dev/null +++ b/src/pages/settings/notifications/discord.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import NotificationsDiscord from '../../../components/Settings/Notifications/NotificationsDiscord'; +import SettingsLayout from '../../../components/Settings/SettingsLayout'; +import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; + +const NotificationsPage: NextPage = () => { + return ( + + + + + + ); +}; + +export default NotificationsPage; diff --git a/src/pages/settings/notifications/index.tsx b/src/pages/settings/notifications/index.tsx new file mode 100644 index 000000000..10061c714 --- /dev/null +++ b/src/pages/settings/notifications/index.tsx @@ -0,0 +1,14 @@ +import { NextPage } from 'next'; +import React from 'react'; +import SettingsLayout from '../../../components/Settings/SettingsLayout'; +import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; + +const NotificationsPage: NextPage = () => { + return ( + + N/A + + ); +}; + +export default NotificationsPage;