feat: notification framework

pull/183/head
sct 4 years ago
parent fb5c791b0b
commit d8e542e5fe

@ -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

@ -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() {

@ -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();

@ -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<boolean>;
}

@ -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<boolean> {
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;

@ -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;

@ -50,6 +50,16 @@ interface PublicSettings {
initialized: boolean;
}
interface NotificationAgent {
enabled: boolean;
types: number;
options: Record<string, unknown>;
}
interface NotificationSettings {
agents: Record<string, NotificationAgent>;
}
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<AllSettings, AllSettings>(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;
}

@ -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;

@ -186,7 +186,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
/>
</div>
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
<div className="mb-2 md:mb-0">
<div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">Available</Badge>
)}

@ -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 <LoadingSpinner />;
}
return (
<Formik
initialValues={{
enabled: data.enabled,
types: data.types,
webhookUrl: data.options.webhookUrl,
}}
validationSchema={NotificationsDiscordSchema}
onSubmit={async (values) => {
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 (
<Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label
htmlFor="isDefault"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
Agent Enabled
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="enabled"
name="enabled"
className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out"
/>
</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="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
Webhook URL
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<Field
id="webhookUrl"
name="webhookUrl"
type="text"
placeholder="Server Settings -> Integrations -> Webhooks"
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
/>
</div>
{errors.webhookUrl && touched.webhookUrl && (
<div className="text-red-500 mt-2">{errors.webhookUrl}</div>
)}
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? intl.formatMessage(messages.saving)
: intl.formatMessage(messages.save)}
</Button>
</span>
</div>
</div>
</Form>
);
}}
</Formik>
);
};
export default NotificationsDiscord;

@ -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',

@ -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 <option value={route}>{children}</option>;
}
return (
<Link href={route}>
<a
className={`whitespace-nowrap ml-8 first:ml-0 px-3 py-2 font-medium text-sm rounded-md ${
!!router.pathname.match(regex) ? activeLinkColor : inactiveLinkColor
}`}
aria-current="page"
>
{children}
</a>
</Link>
);
};
return (
<>
<div>
<div className="sm:hidden">
<label htmlFor="tabs" className="sr-only">
Select a tab
</label>
<select
onChange={(e) => {
router.push(e.target.value);
}}
onBlur={(e) => {
router.push(e.target.value);
}}
defaultValue={
settingsRoutes.find(
(route) => !!router.pathname.match(route.regex)
)?.route
}
aria-label="Selected tab"
className="bg-gray-800 text-white mt-1 rounded-md form-select block w-full pl-3 pr-10 py-2 text-base leading-6 border-gray-700 focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5 transition ease-in-out duration-150"
>
{settingsRoutes.map((route, index) => (
<SettingsLink
route={route.route}
regex={route.regex}
isMobile
key={`mobile-settings-link-${index}`}
>
{route.text}
</SettingsLink>
))}
</select>
</div>
<div className="hidden sm:block">
<nav className="flex space-x-4" aria-label="Tabs">
{settingsRoutes.map((route, index) => (
<SettingsLink
route={route.route}
regex={route.regex}
key={`standard-settings-link-${index}`}
>
{route.text}
</SettingsLink>
))}
</nav>
</div>
</div>
<div className="mt-10">{children}</div>
</>
);
};
export default SettingsNotifications;

@ -189,7 +189,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
/>
</div>
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
<div className="mb-2 md:mb-0">
<div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">Available</Badge>
)}

@ -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 (
<SettingsLayout>
<SettingsNotifications>
<NotificationsDiscord />
</SettingsNotifications>
</SettingsLayout>
);
};
export default NotificationsPage;

@ -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 (
<SettingsLayout>
<SettingsNotifications>N/A</SettingsNotifications>
</SettingsLayout>
);
};
export default NotificationsPage;
Loading…
Cancel
Save