Merge branch 'develop' of https://github.com/sct/overseerr into jellyfin-support

This commit is contained in:
Juan D. Jara
2021-09-27 02:24:30 +02:00
411 changed files with 35232 additions and 20531 deletions

View File

@@ -1,6 +1,12 @@
import NodeCache from 'node-cache';
export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt';
export type AvailableCacheIds =
| 'tmdb'
| 'radarr'
| 'sonarr'
| 'rt'
| 'github'
| 'plexguid';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -44,6 +50,14 @@ class CacheManager {
stdTtl: 43200,
checkPeriod: 60 * 30,
}),
github: new Cache('github', 'GitHub API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
plexguid: new Cache('plexguid', 'Plex GUID Cache', {
stdTtl: 86400 * 7, // 1 week cache
checkPeriod: 60 * 30,
}),
};
public getCache(id: AvailableCacheIds): Cache {

View File

@@ -1,6 +1,6 @@
import { uniqWith } from 'lodash';
import RadarrAPI from '../api/radarr';
import SonarrAPI from '../api/sonarr';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import { MediaType } from '../constants/media';
import logger from '../logger';
import { getSettings } from './settings';
@@ -73,7 +73,7 @@ class DownloadTracker {
if (server.syncEnabled) {
const radarr = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
@@ -140,7 +140,7 @@ class DownloadTracker {
if (server.syncEnabled) {
const radarr = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();

View File

@@ -1,14 +1,20 @@
import nodemailer from 'nodemailer';
import Email from 'email-templates';
import { getSettings } from '../settings';
import nodemailer from 'nodemailer';
import { URL } from 'url';
import { getSettings, NotificationAgentEmail } from '../settings';
import { openpgpEncrypt } from './openpgpEncrypt';
class PreparedEmail extends Email {
public constructor() {
const settings = getSettings().notifications.agents.email;
public constructor(settings: NotificationAgentEmail, pgpKey?: string) {
const { applicationUrl } = getSettings().main;
const transport = nodemailer.createTransport({
name: applicationUrl ? new URL(applicationUrl).hostname : undefined,
host: settings.options.smtpHost,
port: settings.options.smtpPort,
secure: settings.options.secure,
ignoreTLS: settings.options.ignoreTls,
requireTLS: settings.options.requireTls,
tls: settings.options.allowSelfSigned
? {
rejectUnauthorized: false,
@@ -22,6 +28,18 @@ class PreparedEmail extends Email {
}
: undefined,
});
if (pgpKey) {
transport.use(
'stream',
openpgpEncrypt({
signingKey: settings.options.pgpPrivateKey,
password: settings.options.pgpPassword,
encryptionKeys: [pgpKey],
})
);
}
super({
message: {
from: {

View File

@@ -0,0 +1,183 @@
import { randomBytes } from 'crypto';
import * as openpgp from 'openpgp';
import { Transform, TransformCallback } from 'stream';
interface EncryptorOptions {
signingKey?: string;
password?: string;
encryptionKeys: string[];
}
class PGPEncryptor extends Transform {
private _messageChunks: Uint8Array[] = [];
private _messageLength = 0;
private _signingKey?: string;
private _password?: string;
private _encryptionKeys: string[];
constructor(options: EncryptorOptions) {
super();
this._signingKey = options.signingKey;
this._password = options.password;
this._encryptionKeys = options.encryptionKeys;
}
// just save the whole message
_transform = (
chunk: any,
_encoding: BufferEncoding,
callback: TransformCallback
): void => {
this._messageChunks.push(chunk);
this._messageLength += chunk.length;
callback();
};
// Actually do stuff
_flush = async (callback: TransformCallback): Promise<void> => {
// Reconstruct message as buffer
const message = Buffer.concat(this._messageChunks, this._messageLength);
const validPublicKeys = await Promise.all(
this._encryptionKeys.map((armoredKey) => openpgp.readKey({ armoredKey }))
);
let privateKey: openpgp.PrivateKey | undefined;
// Just return the message if there is no one to encrypt for
if (!validPublicKeys.length) {
this.push(message);
return callback();
}
// Only sign the message if private key and password exist
if (this._signingKey && this._password) {
privateKey = await openpgp.readPrivateKey({
armoredKey: this._signingKey,
});
await openpgp.decryptKey({ privateKey, passphrase: this._password });
}
const emailPartDelimiter = '\r\n\r\n';
const messageParts = message.toString().split(emailPartDelimiter);
/**
* In this loop original headers are split up into two parts,
* one for the email that is sent
* and one for the encrypted content
*/
const header = messageParts.shift() as string;
const emailHeaders: string[][] = [];
const contentHeaders: string[][] = [];
const linesInHeader = header.split('\r\n');
let previousHeader: string[] = [];
for (let i = 0; i < linesInHeader.length; i++) {
const line = linesInHeader[i];
/**
* If it is a multi-line header (current line starts with whitespace)
* or it's the first line in the iteration
* add the current line with previous header and move on
*/
if (/^\s/.test(line) || i === 0) {
previousHeader.push(line);
continue;
}
/**
* This is done to prevent the last header
* from being missed
*/
if (i === linesInHeader.length - 1) {
previousHeader.push(line);
}
/**
* We need to seperate the actual content headers
* so that we can add it as a header for the encrypted content
* So that the content will be displayed properly after decryption
*/
if (
/^(content-type|content-transfer-encoding):/i.test(previousHeader[0])
) {
contentHeaders.push(previousHeader);
} else {
emailHeaders.push(previousHeader);
}
previousHeader = [line];
}
// Generate a new boundary for the email content
const boundary = 'nm_' + randomBytes(14).toString('hex');
/**
* Concatenate everything into single strings
* and add pgp headers to the email headers
*/
const emailHeadersRaw =
emailHeaders.map((line) => line.join('\r\n')).join('\r\n') +
'\r\n' +
'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";' +
'\r\n' +
' boundary="' +
boundary +
'"' +
'\r\n' +
'Content-Description: OpenPGP encrypted message' +
'\r\n' +
'Content-Transfer-Encoding: 7bit';
const contentHeadersRaw = contentHeaders
.map((line) => line.join('\r\n'))
.join('\r\n');
const encryptedMessage = await openpgp.encrypt({
message: await openpgp.createMessage({
text:
contentHeadersRaw +
emailPartDelimiter +
messageParts.join(emailPartDelimiter),
}),
encryptionKeys: validPublicKeys,
signingKeys: privateKey,
});
const body =
'--' +
boundary +
'\r\n' +
'Content-Type: application/pgp-encrypted\r\n' +
'Content-Transfer-Encoding: 7bit\r\n' +
'\r\n' +
'Version: 1\r\n' +
'\r\n' +
'--' +
boundary +
'\r\n' +
'Content-Type: application/octet-stream; name=encrypted.asc\r\n' +
'Content-Disposition: inline; filename=encrypted.asc\r\n' +
'Content-Transfer-Encoding: 7bit\r\n' +
'\r\n' +
encryptedMessage +
'\r\n--' +
boundary +
'--\r\n';
this.push(Buffer.from(emailHeadersRaw + emailPartDelimiter + body));
callback();
};
}
export const openpgpEncrypt = (options: EncryptorOptions) => {
return function (mail: any, callback: () => unknown): void {
if (!options.encryptionKeys.length) {
setImmediate(callback);
}
mail.message.transform(
() =>
new PGPEncryptor({
signingKey: options.signingKey,
password: options.password,
encryptionKeys: options.encryptionKeys,
})
);
setImmediate(callback);
};
};

View File

@@ -6,7 +6,7 @@ import { NotificationAgentConfig } from '../../settings';
export interface NotificationPayload {
subject: string;
notifyUser: User;
notifyUser?: User;
media?: Media;
image?: string;
message?: string;
@@ -24,6 +24,6 @@ export abstract class BaseAgent<T extends NotificationAgentConfig> {
}
export interface NotificationAgent {
shouldSend(type: Notification, payload: NotificationPayload): boolean;
shouldSend(): boolean;
send(type: Notification, payload: NotificationPayload): Promise<boolean>;
}

View File

@@ -1,7 +1,14 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { getSettings, NotificationAgentDiscord } from '../../settings';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentDiscord,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
enum EmbedColors {
@@ -71,7 +78,7 @@ interface DiscordRichEmbed {
interface DiscordWebhookPayload {
embeds: DiscordRichEmbed[];
username: string;
username?: string;
avatar_url?: string;
tts: boolean;
content?: string;
@@ -107,7 +114,7 @@ class DiscordAgent
if (payload.request) {
fields.push({
name: 'Requested By',
value: payload.notifyUser.displayName ?? '',
value: payload.request.requestedBy.displayName,
inline: true,
});
}
@@ -122,6 +129,7 @@ class DiscordAgent
});
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
color = EmbedColors.PURPLE;
fields.push({
name: 'Status',
@@ -155,15 +163,14 @@ class DiscordAgent
break;
}
if (settings.main.applicationUrl && payload.media) {
fields.push({
name: `Open in ${settings.main.applicationTitle}`,
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
const url =
settings.main.applicationUrl && payload.media
? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined;
return {
title: payload.subject,
url,
description: payload.message,
color,
timestamp: new Date().toISOString(),
@@ -185,12 +192,10 @@ class DiscordAgent
};
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@@ -201,42 +206,72 @@ class DiscordAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending discord notification', { label: 'Notifications' });
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Discord notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
let content = undefined;
try {
const settings = getSettings();
const webhookUrl = this.getSettings().options.webhookUrl;
if (payload.notifyUser) {
// Mention user who submitted the request
if (
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.DISCORD,
type
) &&
payload.notifyUser.settings?.discordId
) {
content = `<@${payload.notifyUser.settings.discordId}>`;
}
} else {
// Mention all users with the Manage Requests permission
const userRepository = getRepository(User);
const users = await userRepository.find();
if (!webhookUrl) {
return false;
content = users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType(
NotificationAgentKey.DISCORD,
type
) &&
user.settings?.discordId &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
)
.map((user) => `<@${user.settings?.discordId}>`)
.join(' ');
}
const mentionedUsers: string[] = [];
let content = undefined;
if (
payload.notifyUser.settings?.enableNotifications &&
payload.notifyUser.settings?.discordId
) {
mentionedUsers.push(payload.notifyUser.settings.discordId);
content = `<@${payload.notifyUser.settings.discordId}>`;
}
await axios.post(webhookUrl, {
username: settings.main.applicationTitle,
await axios.post(settings.options.webhookUrl, {
username: settings.options.botUsername,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content,
allowed_mentions: {
users: mentionedUsers,
},
} as DiscordWebhookPayload);
return true;
} catch (e) {
logger.error('Error sending Discord notification', {
label: 'Notifications',
message: e.message,
response: e.response.data,
mentions: content,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}

View File

@@ -1,12 +1,18 @@
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { hasNotificationType, Notification } from '..';
import { EmailOptions } from 'email-templates';
import path from 'path';
import { getSettings, NotificationAgentEmail } from '../../settings';
import logger from '../../../logger';
import { getRepository } from 'typeorm';
import { Notification } from '..';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import { Permission } from '../../permissions';
import logger from '../../../logger';
import PreparedEmail from '../../email';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentEmail,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class EmailAgent
extends BaseAgent<NotificationAgentEmail>
@@ -21,13 +27,14 @@ class EmailAgent
return settings.notifications.agents.email;
}
public shouldSend(type: Notification, payload: NotificationPayload): boolean {
public shouldSend(): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
hasNotificationType(type, this.getSettings().types) &&
(payload.notifyUser.settings?.enableNotifications ?? true)
settings.options.emailFrom &&
settings.options.smtpHost &&
settings.options.smtpPort
) {
return true;
}
@@ -35,265 +42,205 @@ class EmailAgent
return false;
}
private async sendMediaRequestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
private buildMessage(
type: Notification,
payload: NotificationPayload,
toEmail: string
): EmailOptions | undefined {
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
// Send to all users with the manage requests permission (or admins)
users
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
.forEach((user) => {
const email = new PreparedEmail();
email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: user.email,
},
locals: {
body: 'A user has requested new media!',
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'New Request',
},
});
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
private async sendMediaFailedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
// Send to all users with the manage requests permission (or admins)
users
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
.forEach((user) => {
const email = new PreparedEmail();
email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: user.email,
},
locals: {
body:
"A user's new request has failed to add to Sonarr or Radarr",
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Failed Request',
},
});
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
private async sendMediaApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
await email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: payload.notifyUser.email,
},
locals: {
body: 'Your request for the following media has been approved:',
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Request Approved',
},
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
private async sendMediaDeclinedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
await email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: payload.notifyUser.email,
},
locals: {
body: 'Your request for the following media was declined:',
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Request Declined',
},
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
private async sendMediaAvailableEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
await email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: payload.notifyUser.email,
},
locals: {
body: 'Your requested media is now available!',
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Now Available',
},
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
private async sendTestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
await email.send({
if (type === Notification.TEST_NOTIFICATION) {
return {
template: path.join(__dirname, '../../../templates/email/test-email'),
message: {
to: payload.notifyUser.email,
to: toEmail,
},
locals: {
body: payload.message,
applicationUrl,
applicationTitle,
},
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
};
}
if (payload.media) {
let requestType = '';
let body = '';
switch (type) {
case Notification.MEDIA_PENDING:
requestType = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
body = `A user has requested a new ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
}!`;
break;
case Notification.MEDIA_APPROVED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
body = `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been approved:`;
break;
case Notification.MEDIA_AUTO_APPROVED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
body = `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been automatically approved:`;
break;
case Notification.MEDIA_AVAILABLE:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
body = `The following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} you requested is now available!`;
break;
case Notification.MEDIA_DECLINED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
body = `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} was declined:`;
break;
case Notification.MEDIA_FAILED:
requestType = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
body = `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} could not be added to ${
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
}:`;
break;
}
return {
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: toEmail,
},
locals: {
requestType,
body,
mediaName: payload.subject,
mediaPlot: payload.message,
mediaExtra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
},
};
}
return undefined;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending email notification', { label: 'Notifications' });
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
!payload.notifyUser.settings ||
// Check if user has email notifications enabled and fallback to true if undefined
// since email should default to true
(payload.notifyUser.settings.hasNotificationType(
NotificationAgentKey.EMAIL,
type
) ??
true)
) {
logger.debug('Sending email notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
});
switch (type) {
case Notification.MEDIA_PENDING:
this.sendMediaRequestEmail(payload);
break;
case Notification.MEDIA_APPROVED:
this.sendMediaApprovedEmail(payload);
break;
case Notification.MEDIA_DECLINED:
this.sendMediaDeclinedEmail(payload);
break;
case Notification.MEDIA_AVAILABLE:
this.sendMediaAvailableEmail(payload);
break;
case Notification.MEDIA_FAILED:
this.sendMediaFailedEmail(payload);
break;
case Notification.TEST_NOTIFICATION:
this.sendTestEmail(payload);
break;
try {
const email = new PreparedEmail(
this.getSettings(),
payload.notifyUser.settings?.pgpKey
);
await email.send(
this.buildMessage(type, payload, payload.notifyUser.email)
);
} catch (e) {
logger.error('Error sending email notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
});
return false;
}
}
} else {
// Send notifications to all users with the Manage Requests permission
const userRepository = getRepository(User);
const users = await userRepository.find();
await Promise.all(
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
(!user.settings ||
// Check if user has email notifications enabled and fallback to true if undefined
// since email should default to true
(user.settings.hasNotificationType(
NotificationAgentKey.EMAIL,
type
) ??
true)) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
)
.map(async (user) => {
logger.debug('Sending email notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
const email = new PreparedEmail(
this.getSettings(),
user.settings?.pgpKey
);
await email.send(this.buildMessage(type, payload, user.email));
} catch (e) {
logger.error('Error sending email notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
});
return false;
}
})
);
}
return true;

View File

@@ -0,0 +1,108 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentLunaSea } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class LunaSeaAgent
extends BaseAgent<NotificationAgentLunaSea>
implements NotificationAgent {
protected getSettings(): NotificationAgentLunaSea {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.lunasea;
}
private buildPayload(type: Notification, payload: NotificationPayload) {
return {
notification_type: Notification[type],
subject: payload.subject,
message: payload.message,
image: payload.image ?? null,
email: payload.notifyUser?.email,
username: payload.notifyUser?.username,
avatar: payload.notifyUser?.avatar,
media: payload.media
? {
media_type: payload.media.mediaType,
tmdbId: payload.media.tmdbId,
imdbId: payload.media.imdbId,
tvdbId: payload.media.tvdbId,
status: MediaStatus[payload.media.status],
status4k: MediaStatus[payload.media.status4k],
}
: null,
extra: payload.extra ?? [],
request: payload.request
? {
request_id: payload.request.id,
requestedBy_email: payload.request.requestedBy.email,
requestedBy_username: payload.request.requestedBy.displayName,
requestedBy_avatar: payload.request.requestedBy.avatar,
}
: null,
};
}
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
return false;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending LunaSea notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
await axios.post(
settings.options.webhookUrl,
this.buildPayload(type, payload),
settings.options.profileName
? {
headers: {
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
}
: undefined
);
return true;
} catch (e) {
logger.error('Error sending LunaSea notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}
}
export default LunaSeaAgent;

View File

@@ -1,5 +1,6 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushbullet } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@@ -22,12 +23,10 @@ class PushbulletAgent
return settings.notifications.agents.pushbullet;
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.accessToken &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.accessToken) {
return true;
}
@@ -46,11 +45,13 @@ class PushbulletAgent
const title = payload.subject;
const plot = payload.message;
const username = payload.notifyUser.displayName;
const username = payload.request?.requestedBy.displayName;
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = 'New Request';
messageTitle = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
@@ -59,7 +60,20 @@ class PushbulletAgent
message += `\nStatus: Pending Approval`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = 'Request Approved';
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
@@ -68,7 +82,9 @@ class PushbulletAgent
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = 'Now Available';
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
@@ -77,7 +93,9 @@ class PushbulletAgent
message += `\nStatus: Available`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = 'Request Declined';
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
@@ -86,7 +104,9 @@ class PushbulletAgent
message += `\nStatus: Declined`;
break;
case Notification.MEDIA_FAILED:
messageTitle = 'Failed Request';
messageTitle = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
@@ -100,6 +120,10 @@ class PushbulletAgent
break;
}
for (const extra of payload.extra ?? []) {
message += `\n${extra.name}: ${extra.value}`;
}
return {
title: messageTitle,
body: message,
@@ -110,16 +134,23 @@ class PushbulletAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Pushbullet notification', { label: 'Notifications' });
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Pushbullet notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
const endpoint = 'https://api.pushbullet.com/v2/pushes';
const { accessToken } = this.getSettings().options;
const { title, body } = this.constructMessageDetails(type, payload);
await axios.post(
endpoint,
'https://api.pushbullet.com/v2/pushes',
{
type: 'note',
title: title,
@@ -127,7 +158,7 @@ class PushbulletAgent
} as PushbulletPayload,
{
headers: {
'Access-Token': accessToken,
'Access-Token': settings.options.accessToken,
},
}
);
@@ -136,8 +167,12 @@ class PushbulletAgent
} catch (e) {
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
message: e.message,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}

View File

@@ -1,5 +1,6 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushover } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@@ -28,12 +29,13 @@ class PushoverAgent
return settings.notifications.agents.pushover;
}
public shouldSend(type: Notification): boolean {
public shouldSend(): boolean {
const settings = this.getSettings();
if (
this.getSettings().enabled &&
this.getSettings().options.accessToken &&
this.getSettings().options.userToken &&
hasNotificationType(type, this.getSettings().types)
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
return true;
}
@@ -60,62 +62,87 @@ class PushoverAgent
const title = payload.subject;
const plot = payload.message;
const username = payload.notifyUser.displayName;
const username = payload.request?.requestedBy.displayName;
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = 'New Request';
messageTitle = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
message += `<small>\n${plot}</small>`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nPending Approval`;
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nPending Approval</small>`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = 'Request Approved';
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
message += `<small>\n${plot}</small>`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nProcessing`;
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = 'Now Available';
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
message += `<small>\n${plot}</small>`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nAvailable`;
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nAvailable</small>`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = 'Request Declined';
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
message += `<small>\n${plot}</small>`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nDeclined`;
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nDeclined</small>`;
priority = 1;
break;
case Notification.MEDIA_FAILED:
messageTitle = 'Failed Request';
messageTitle = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
message += `<small>\n${plot}</small>`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nFailed`;
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nFailed</small>`;
priority = 1;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${plot}`;
message += `<small>${plot}</small>`;
break;
}
for (const extra of payload.extra ?? []) {
message += `<small>\n\n<b>${extra.name}</b>\n${extra.value}</small>`;
}
if (settings.main.applicationUrl && payload.media) {
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
url_title = `Open in ${settings.main.applicationTitle}`;
@@ -134,12 +161,20 @@ class PushoverAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Pushover notification', { label: 'Notifications' });
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Pushover notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
const endpoint = 'https://api.pushover.net/1/messages.json';
const { accessToken, userToken } = this.getSettings().options;
const {
title,
message,
@@ -149,8 +184,8 @@ class PushoverAgent
} = this.constructMessageDetails(type, payload);
await axios.post(endpoint, {
token: accessToken,
user: userToken,
token: settings.options.accessToken,
user: settings.options.userToken,
title: title,
message: message,
url: url,
@@ -163,8 +198,12 @@ class PushoverAgent
} catch (e) {
logger.error('Error sending Pushover notification', {
label: 'Notifications',
message: e.message,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}

View File

@@ -1,5 +1,6 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@@ -66,41 +67,60 @@ class SlackAgent
if (payload.request) {
fields.push({
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
});
}
switch (type) {
case Notification.MEDIA_PENDING:
header = 'New Request';
header = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nPending Approval',
});
break;
case Notification.MEDIA_APPROVED:
header = 'Request Approved';
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nProcessing',
});
break;
case Notification.MEDIA_AUTO_APPROVED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nProcessing',
});
break;
case Notification.MEDIA_AVAILABLE:
header = 'Now Available';
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nAvailable',
});
break;
case Notification.MEDIA_DECLINED:
header = 'Request Declined';
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nDeclined',
});
break;
case Notification.MEDIA_FAILED:
header = 'Failed Request';
header = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nFailed',
@@ -111,6 +131,13 @@ class SlackAgent
break;
}
for (const extra of payload.extra ?? []) {
fields.push({
type: 'mrkdwn',
text: `*${extra.name}*\n${extra.value}`,
});
}
if (settings.main.applicationUrl && payload.media) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
@@ -190,12 +217,10 @@ class SlackAgent
};
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@@ -206,22 +231,33 @@ class SlackAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending slack notification', { label: 'Notifications' });
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Slack notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
const webhookUrl = this.getSettings().options.webhookUrl;
if (!webhookUrl) {
return false;
}
await axios.post(webhookUrl, this.buildEmbed(type, payload));
await axios.post(
settings.options.webhookUrl,
this.buildEmbed(type, payload)
);
return true;
} catch (e) {
logger.error('Error sending Slack notification', {
label: 'Notifications',
message: e.message,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}

View File

@@ -1,16 +1,32 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { getSettings, NotificationAgentTelegram } from '../../settings';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentKey,
NotificationAgentTelegram,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface TelegramPayload {
interface TelegramMessagePayload {
text: string;
parse_mode: string;
chat_id: string;
disable_notification: boolean;
}
interface TelegramPhotoPayload {
photo: string;
caption: string;
parse_mode: string;
chat_id: string;
disable_notification: boolean;
}
class TelegramAgent
extends BaseAgent<NotificationAgentTelegram>
implements NotificationAgent {
@@ -26,12 +42,13 @@ class TelegramAgent
return settings.notifications.agents.telegram;
}
public shouldSend(type: Notification): boolean {
public shouldSend(): boolean {
const settings = this.getSettings();
if (
this.getSettings().enabled &&
this.getSettings().options.botAPI &&
this.getSettings().options.chatId &&
hasNotificationType(type, this.getSettings().types)
settings.enabled &&
settings.options.botAPI &&
settings.options.chatId
) {
return true;
}
@@ -45,20 +62,24 @@ class TelegramAgent
private buildMessage(
type: Notification,
payload: NotificationPayload
): string {
payload: NotificationPayload,
chatId: string,
sendSilently: boolean
): TelegramMessagePayload | TelegramPhotoPayload {
const settings = getSettings();
let message = '';
const title = this.escapeText(payload.subject);
const plot = this.escapeText(payload.message);
const user = this.escapeText(payload.notifyUser.displayName);
const user = this.escapeText(payload.request?.requestedBy.displayName);
const applicationTitle = this.escapeText(settings.main.applicationTitle);
/* eslint-disable no-useless-escape */
switch (type) {
case Notification.MEDIA_PENDING:
message += `\*New Request\*`;
message += `\*New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
@@ -67,7 +88,20 @@ class TelegramAgent
message += `\n\n\*Status\*\nPending Approval`;
break;
case Notification.MEDIA_APPROVED:
message += `\*Request Approved\*`;
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nProcessing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
@@ -76,7 +110,9 @@ class TelegramAgent
message += `\n\n\*Status\*\nProcessing`;
break;
case Notification.MEDIA_AVAILABLE:
message += `\*Now Available\*`;
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
@@ -85,7 +121,9 @@ class TelegramAgent
message += `\n\n\*Status\*\nAvailable`;
break;
case Notification.MEDIA_DECLINED:
message += `\*Request Declined\*`;
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
@@ -94,7 +132,9 @@ class TelegramAgent
message += `\n\n\*Status\*\nDeclined`;
break;
case Notification.MEDIA_FAILED:
message += `\*Failed Request\*`;
message += `\*Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
@@ -108,40 +148,171 @@ class TelegramAgent
break;
}
for (const extra of payload.extra ?? []) {
message += `\n\n\*${extra.name}\*\n${extra.value}`;
}
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`;
}
/* eslint-enable */
return message;
return payload.image
? ({
photo: payload.image,
caption: message,
parse_mode: 'MarkdownV2',
chat_id: chatId,
disable_notification: !!sendSilently,
} as TelegramPhotoPayload)
: ({
text: message,
parse_mode: 'MarkdownV2',
chat_id: chatId,
disable_notification: !!sendSilently,
} as TelegramMessagePayload);
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending telegram notification', { label: 'Notifications' });
try {
const endpoint = `${this.baseUrl}bot${
this.getSettings().options.botAPI
}/sendMessage`;
const settings = this.getSettings();
await axios.post(endpoint, {
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramPayload);
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
payload.image ? 'sendPhoto' : 'sendMessage'
}`;
return true;
} catch (e) {
logger.error('Error sending Telegram notification', {
// Send system notification
if (hasNotificationType(type, settings.types ?? 0)) {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
message: e.message,
type: Notification[type],
subject: payload.subject,
});
return false;
try {
await axios.post(
endpoint,
this.buildMessage(
type,
payload,
settings.options.chatId,
settings.options.sendSilently
)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
type
) &&
payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !== settings.options.chatId
) {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
await axios.post(
endpoint,
this.buildMessage(
type,
payload,
payload.notifyUser.settings.telegramChatId,
!!payload.notifyUser.settings.telegramSendSilently
)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}
} else {
// Send notifications to all users with the Manage Requests permission
const userRepository = getRepository(User);
const users = await userRepository.find();
await Promise.all(
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
type
) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
)
.map(async (user) => {
if (
user.settings?.telegramChatId &&
user.settings.telegramChatId !== settings.options.chatId
) {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
await axios.post(
endpoint,
this.buildMessage(
type,
payload,
user.settings.telegramChatId,
!!user.settings?.telegramSendSilently
)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}
})
);
}
return true;
}
}

View File

@@ -20,6 +20,7 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
notifyuser_email: 'notifyUser.email',
notifyuser_avatar: 'notifyUser.avatar',
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
media_tmdbid: 'media.tmdbId',
media_imdbid: 'media.imdbId',
media_tvdbid: 'media.tvdbId',
@@ -29,6 +30,12 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
media_status4k: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
request_id: 'request.id',
requestedBy_username: 'request.requestedBy.displayName',
requestedBy_email: 'request.requestedBy.email',
requestedBy_avatar: 'request.requestedBy.avatar',
requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
requestedBy_settings_telegramChatId:
'request.requestedBy.settings.telegramChatId',
};
class WebhookAgent
@@ -105,12 +112,10 @@ class WebhookAgent
return this.parseKeys(parsedJSON, payload, type);
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@@ -121,26 +126,41 @@ class WebhookAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending webhook notification', { label: 'Notifications' });
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending webhook notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
const { webhookUrl, authHeader } = this.getSettings().options;
if (!webhookUrl) {
return false;
}
await axios.post(webhookUrl, this.buildPayload(type, payload), {
headers: {
Authorization: authHeader,
},
});
await axios.post(
settings.options.webhookUrl,
this.buildPayload(type, payload),
settings.options.authHeader
? {
headers: {
Authorization: settings.options.authHeader,
},
}
: undefined
);
return true;
} catch (e) {
logger.error('Error sending Webhook notification', {
logger.error('Error sending webhook notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}

View File

@@ -0,0 +1,254 @@
import { getRepository } from 'typeorm';
import webpush from 'web-push';
import { Notification } from '..';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentConfig,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushNotificationPayload {
notificationType: string;
mediaType?: 'movie' | 'tv';
tmdbId?: number;
subject: string;
message?: string;
image?: string;
actionUrl?: string;
requestId?: number;
}
class WebPushAgent
extends BaseAgent<NotificationAgentConfig>
implements NotificationAgent {
protected getSettings(): NotificationAgentConfig {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.webpush;
}
private getNotificationPayload(
type: Notification,
payload: NotificationPayload
): PushNotificationPayload {
switch (type) {
case Notification.NONE:
return {
notificationType: Notification[type],
subject: 'Unknown',
};
case Notification.TEST_NOTIFICATION:
return {
notificationType: Notification[type],
subject: payload.subject,
message: payload.message,
};
case Notification.MEDIA_APPROVED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request has been approved.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_AUTO_APPROVED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Automatically approved a new ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request from ${payload.request?.requestedBy.displayName}.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_AVAILABLE:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request is now available!`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_DECLINED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request was declined.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_FAILED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Failed to process ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_PENDING:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Approval required for new ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request from ${payload.request?.requestedBy.displayName}.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
}
}
public shouldSend(): boolean {
if (this.getSettings().enabled) {
return true;
}
return false;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const userRepository = getRepository(User);
const userPushSubRepository = getRepository(UserPushSubscription);
const settings = getSettings();
let pushSubs: UserPushSubscription[] = [];
const mainUser = await userRepository.findOne({ where: { id: 1 } });
if (
payload.notifyUser &&
// Check if user has webpush notifications enabled and fallback to true if undefined
// since web push should default to true
(payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.WEBPUSH,
type
) ??
true)
) {
const notifySubs = await userPushSubRepository.find({
where: { user: payload.notifyUser.id },
});
pushSubs = notifySubs;
} else if (!payload.notifyUser) {
const users = await userRepository.find();
const manageUsers = users.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
// Check if user has webpush notifications enabled and fallback to true if undefined
// since web push should default to true
(user.settings?.hasNotificationType(
NotificationAgentKey.WEBPUSH,
type
) ??
true) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
);
const allSubs = await userPushSubRepository
.createQueryBuilder('pushSub')
.leftJoinAndSelect('pushSub.user', 'user')
.where('pushSub.userId IN (:users)', {
users: manageUsers.map((user) => user.id),
})
.getMany();
pushSubs = allSubs;
}
if (mainUser && pushSubs.length > 0) {
webpush.setVapidDetails(
`mailto:${mainUser.email}`,
settings.vapidPublic,
settings.vapidPrivate
);
await Promise.all(
pushSubs.map(async (sub) => {
logger.debug('Sending web push notification', {
label: 'Notifications',
recipient: sub.user.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
auth: sub.auth,
p256dh: sub.p256dh,
},
},
Buffer.from(
JSON.stringify(this.getNotificationPayload(type, payload)),
'utf-8'
)
);
} catch (e) {
logger.error(
'Error sending web push notification; removing subscription',
{
label: 'Notifications',
recipient: sub.user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
}
);
// Failed to send notification so we need to remove the subscription
userPushSubRepository.remove(sub);
}
})
);
}
return true;
}
}
export default WebPushAgent;

View File

@@ -1,14 +1,15 @@
import logger from '../../logger';
import { getSettings } from '../settings';
import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification {
NONE = 0,
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
MEDIA_FAILED = 16,
TEST_NOTIFICATION = 32,
MEDIA_DECLINED = 64,
MEDIA_AUTO_APPROVED = 128,
}
export const hasNotificationType = (
@@ -29,6 +30,11 @@ export const hasNotificationType = (
total = types;
}
// Test notifications don't need to be enabled
if (!(value & Notification.TEST_NOTIFICATION)) {
value += Notification.TEST_NOTIFICATION;
}
return !!(value & total);
};
@@ -37,19 +43,20 @@ class NotificationManager {
public registerAgents = (agents: NotificationAgent[]): void => {
this.activeAgents = [...this.activeAgents, ...agents];
logger.info('Registered Notification Agents', { label: 'Notifications' });
logger.info('Registered notification agents', { label: 'Notifications' });
};
public sendNotification(
type: Notification,
payload: NotificationPayload
): void {
const settings = getSettings().notifications;
logger.info(`Sending notification for ${Notification[type]}`, {
logger.info(`Sending notification(s) for ${Notification[type]}`, {
label: 'Notifications',
subject: payload.subject,
});
this.activeAgents.forEach((agent) => {
if (settings.enabled && agent.shouldSend(type, payload)) {
if (agent.shouldSend()) {
agent.send(type, payload);
}
});

View File

@@ -17,6 +17,8 @@ export enum Permission {
AUTO_APPROVE_4K = 32768,
AUTO_APPROVE_4K_MOVIE = 65536,
AUTO_APPROVE_4K_TV = 131072,
REQUEST_MOVIE = 262144,
REQUEST_TV = 524288,
}
export interface PermissionCheckOptions {

View File

@@ -0,0 +1,627 @@
import { randomUUID } from 'crypto';
import { getRepository } from 'typeorm';
import TheMovieDb from '../../api/themoviedb';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import logger from '../../logger';
import AsyncLock from '../../utils/asyncLock';
import { getSettings } from '../settings';
// Default scan rates (can be overidden)
const BUNDLE_SIZE = 20;
const UPDATE_RATE = 4 * 1000;
export type StatusBase = {
running: boolean;
progress: number;
total: number;
};
export interface RunnableScanner<T> {
run: () => Promise<void>;
status: () => T & StatusBase;
}
export interface MediaIds {
tmdbId: number;
imdbId?: string;
tvdbId?: number;
isHama?: boolean;
}
interface ProcessOptions {
is4k?: boolean;
mediaAddedAt?: Date;
ratingKey?: string;
serviceId?: number;
externalServiceId?: number;
externalServiceSlug?: string;
title?: string;
processing?: boolean;
}
export interface ProcessableSeason {
seasonNumber: number;
totalEpisodes: number;
episodes: number;
episodes4k: number;
is4kOverride?: boolean;
processing?: boolean;
}
class BaseScanner<T> {
private bundleSize;
private updateRate;
protected progress = 0;
protected items: T[] = [];
protected totalSize?: number = 0;
protected scannerName: string;
protected enable4kMovie = false;
protected enable4kShow = false;
protected sessionId: string;
protected running = false;
readonly asyncLock = new AsyncLock();
readonly tmdb = new TheMovieDb();
protected constructor(
scannerName: string,
{
updateRate,
bundleSize,
}: {
updateRate?: number;
bundleSize?: number;
} = {}
) {
this.scannerName = scannerName;
this.bundleSize = bundleSize ?? BUNDLE_SIZE;
this.updateRate = updateRate ?? UPDATE_RATE;
}
private async getExisting(tmdbId: number, mediaType: MediaType) {
const mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId, mediaType },
});
return existing;
}
protected async processMovie(
tmdbId: number,
{
is4k = false,
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
externalServiceSlug,
processing = false,
title = 'Unknown Title',
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbId, async () => {
const existing = await this.getExisting(tmdbId, MediaType.MOVIE);
if (existing) {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
changedExisting = true;
}
if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
changedExisting = true;
}
if (
ratingKey &&
existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
) {
existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
changedExisting = true;
}
if (
serviceId !== undefined &&
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
) {
existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
changedExisting = true;
}
if (
externalServiceId !== undefined &&
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
externalServiceId
) {
existing[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
changedExisting = true;
}
if (
externalServiceSlug !== undefined &&
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
externalServiceSlug
) {
existing[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Media for ${title} exists. Changes were detected and the title will be updated.`,
'info'
);
} else {
this.log(`Title already exists and no changes detected for ${title}`);
}
} else {
const newMedia = new Media();
newMedia.tmdbId = tmdbId;
newMedia.status =
!is4k && !processing
? MediaStatus.AVAILABLE
: !is4k && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.status4k =
is4k && this.enable4kMovie && !processing
? MediaStatus.AVAILABLE
: is4k && this.enable4kMovie && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.serviceId = !is4k ? serviceId : undefined;
newMedia.serviceId4k = is4k ? serviceId : undefined;
newMedia.externalServiceId = !is4k ? externalServiceId : undefined;
newMedia.externalServiceId4k = is4k ? externalServiceId : undefined;
newMedia.externalServiceSlug = !is4k ? externalServiceSlug : undefined;
newMedia.externalServiceSlug4k = is4k ? externalServiceSlug : undefined;
if (mediaAddedAt) {
newMedia.mediaAddedAt = mediaAddedAt;
}
if (ratingKey) {
newMedia.ratingKey = !is4k ? ratingKey : undefined;
newMedia.ratingKey4k =
is4k && this.enable4kMovie ? ratingKey : undefined;
}
await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`);
}
});
}
/**
* processShow takes a TMDb ID and an array of ProcessableSeasons, which
* should include the total episodes a sesaon has + the total available
* episodes that each season currently has. Unlike processMovie, this method
* does not take an `is4k` option. We handle both the 4k _and_ non 4k status
* in one method.
*
* Note: If 4k is not enable, ProcessableSeasons should combine their episode counts
* into the normal episodes properties and avoid using the 4k properties.
*/
protected async processShow(
tmdbId: number,
tvdbId: number,
seasons: ProcessableSeason[],
{
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
externalServiceSlug,
is4k = false,
title = 'Unknown Title',
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbId, async () => {
const media = await this.getExisting(tmdbId, MediaType.TV);
const newSeasons: Season[] = [];
const currentStandardSeasonsAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const current4kSeasonsAvailable = (
media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
for (const season of seasons) {
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.seasonNumber
);
// We update the rating keys in the seasons loop because we need episode counts
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
media.ratingKey = ratingKey;
}
if (
media &&
season.episodes4k > 0 &&
this.enable4kShow &&
media.ratingKey4k !== ratingKey
) {
media.ratingKey4k = ratingKey;
}
if (existingSeason) {
// Here we update seasons if they already exist.
// If the season is already marked as available, we
// force it to stay available (to avoid competing scanners)
existingSeason.status =
(season.totalEpisodes === season.episodes && season.episodes > 0) ||
existingSeason.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: existingSeason.status;
// Same thing here, except we only do updates if 4k is enabled
existingSeason.status4k =
(this.enable4kShow &&
season.episodes4k === season.totalEpisodes &&
season.episodes4k > 0) ||
existingSeason.status4k === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
seasonNumber: season.seasonNumber,
status:
season.totalEpisodes === season.episodes && season.episodes > 0
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow &&
season.totalEpisodes === season.episodes4k &&
season.episodes4k > 0
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
})
);
}
}
const isAllStandardSeasons =
seasons.length &&
seasons.every(
(season) =>
season.episodes === season.totalEpisodes && season.episodes > 0
);
const isAll4kSeasons =
seasons.length &&
seasons.every(
(season) =>
season.episodes4k === season.totalEpisodes && season.episodes4k > 0
);
if (media) {
media.seasons = [...media.seasons, ...newSeasons];
const newStandardSeasonsAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const new4kSeasonsAvailable = (
media.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newStandardSeasonsAvailable > currentStandardSeasonsAvailable) {
this.log(
`Detected ${
newStandardSeasonsAvailable - currentStandardSeasonsAvailable
} new standard season(s) for ${title}`,
'debug'
);
media.lastSeasonChange = new Date();
if (mediaAddedAt) {
media.mediaAddedAt = mediaAddedAt;
}
}
if (new4kSeasonsAvailable > current4kSeasonsAvailable) {
this.log(
`Detected ${
new4kSeasonsAvailable - current4kSeasonsAvailable
} new 4K season(s) for ${title}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (!media.mediaAddedAt && mediaAddedAt) {
media.mediaAddedAt = mediaAddedAt;
}
if (serviceId !== undefined) {
media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
}
if (externalServiceId !== undefined) {
media[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
}
if (externalServiceSlug !== undefined) {
media[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
.length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
.length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: !seasons.length ||
media.seasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: !seasons.length ||
media.seasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${title}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId,
tvdbId,
mediaAddedAt,
serviceId: !is4k ? serviceId : undefined,
serviceId4k: is4k ? serviceId : undefined,
externalServiceId: !is4k ? externalServiceId : undefined,
externalServiceId4k: is4k ? externalServiceId : undefined,
externalServiceSlug: !is4k ? externalServiceSlug : undefined,
externalServiceSlug4k: is4k ? externalServiceSlug : undefined,
ratingKey: newSeasons.some(
(sn) =>
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status === MediaStatus.AVAILABLE
)
? ratingKey
: undefined,
ratingKey4k:
this.enable4kShow &&
newSeasons.some(
(sn) =>
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status4k === MediaStatus.AVAILABLE
)
? ratingKey
: undefined,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${title}`);
}
});
}
/**
* Call startRun from child class whenever a run is starting to
* ensure required values are set
*
* Returns the session ID which is requried for the cleanup method
*/
protected startRun(): string {
const settings = getSettings();
const sessionId = randomUUID();
this.sessionId = sessionId;
this.log('Scan starting', 'info', { sessionId });
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4kMovie) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
'info'
);
}
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4kShow) {
this.log(
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
'info'
);
}
this.running = true;
return sessionId;
}
/**
* Call at end of run loop to perform cleanup
*/
protected endRun(sessionId: string): void {
if (this.sessionId === sessionId) {
this.running = false;
}
}
public cancel(): void {
this.running = false;
}
protected async loop(
processFn: (item: T) => Promise<void>,
{
start = 0,
end = this.bundleSize,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}
): Promise<void> {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(processFn, slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop(processFn, {
start: start + this.bundleSize,
end: end + this.bundleSize,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, this.updateRate)
);
}
}
private async processItems(
processFn: (items: T) => Promise<void>,
items: T[]
) {
await Promise.all(
items.map(async (item) => {
await processFn(item);
})
);
}
protected log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: this.scannerName, ...optional });
}
get protectedUpdateRate(): number {
return this.updateRate;
}
get protectedBundleSize(): number {
return this.bundleSize;
}
}
export default BaseScanner;

View File

@@ -0,0 +1,548 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import animeList from '../../../api/animelist';
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import { User } from '../../../entity/User';
import cacheManager from '../../cache';
import { getSettings, Library } from '../../settings';
import BaseScanner, {
MediaIds,
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '../baseScanner';
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//);
// Hama agent uses ASS naming, see details here:
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/);
const HAMA_AGENT = 'com.plexapp.agents.hama';
type SyncStatus = StatusBase & {
currentLibrary: Library;
libraries: Library[];
};
class PlexScanner
extends BaseScanner<PlexLibraryItem>
implements RunnableScanner<SyncStatus> {
private plexClient: PlexAPI;
private libraries: Library[];
private currentLibrary: Library;
private isRecentOnly = false;
public constructor(isRecentOnly = false) {
super('Plex Scan', { bundleSize: 50 });
this.isRecentOnly = isRecentOnly;
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.totalSize ?? 0,
currentLibrary: this.currentLibrary,
libraries: this.libraries,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
try {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
if (!admin) {
return this.log('No admin configured. Plex scan skipped.', 'warn');
}
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
const hasHama = await this.hasHamaAgent();
if (hasHama) {
await animeList.sync();
}
if (this.isRecentOnly) {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(
`Beginning to process recently added for library: ${library.name}`,
'info',
{ lastScan: library.lastScan }
);
const libraryItems = await this.plexClient.getRecentlyAdded(
library.id,
library.lastScan
? {
// We remove 10 minutes from the last scan as a buffer
addedAt: library.lastScan - 1000 * 60 * 10,
}
: undefined
);
// Bundle items up by rating keys
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
return (
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
);
}
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
return mediaA.parentRatingKey === mediaB.parentRatingKey;
}
return mediaA.ratingKey === mediaB.ratingKey;
});
await this.loop(this.processItem.bind(this), { sessionId });
// After run completes, update last scan time
const newLibraries = settings.plex.libraries.map((lib) => {
if (lib.id === library.id) {
return {
...lib,
lastScan: Date.now(),
};
}
return lib;
});
settings.plex.libraries = newLibraries;
settings.save();
}
} else {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');
await this.paginateLibrary(library, { sessionId });
}
}
this.log(
this.isRecentOnly
? 'Recently Added Scan Complete'
: 'Full Scan Complete',
'info'
);
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async paginateLibrary(
library: Library,
{ start = 0, sessionId }: { start?: number; sessionId: string }
) {
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
const response = await this.plexClient.getLibraryContents(library.id, {
size: this.protectedBundleSize,
offset: start,
});
this.progress = start;
this.totalSize = response.totalSize;
if (response.items.length === 0) {
return;
}
await Promise.all(
response.items.map(async (item) => {
await this.processItem(item);
})
);
if (response.items.length < this.protectedBundleSize) {
return;
}
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.paginateLibrary(library, {
start: start + this.protectedBundleSize,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, this.protectedUpdateRate)
);
}
private async processItem(plexitem: PlexLibraryItem) {
try {
if (plexitem.type === 'movie') {
await this.processPlexMovie(plexitem);
} else if (
plexitem.type === 'show' ||
plexitem.type === 'episode' ||
plexitem.type === 'season'
) {
await this.processPlexShow(plexitem);
}
} catch (e) {
this.log('Failed to process Plex media', 'error', {
errorMessage: e.message,
title: plexitem.title,
});
}
}
private async processPlexMovie(plexitem: PlexLibraryItem) {
const mediaIds = await this.getMediaIds(plexitem);
const has4k = plexitem.Media.some(
(media) => media.videoResolution === '4k'
);
await this.processMovie(mediaIds.tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
}
private async processPlexMovieByTmdbId(
plexitem: PlexMetadata,
tmdbId: number
) {
const has4k = plexitem.Media.some(
(media) => media.videoResolution === '4k'
);
await this.processMovie(tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
}
private async processPlexShow(plexitem: PlexLibraryItem) {
const ratingKey =
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey;
const metadata = await this.plexClient.getMetadata(ratingKey, {
includeChildren: true,
});
const mediaIds = await this.getMediaIds(metadata);
// If the media is from HAMA, and doesn't have a TVDb ID, we will treat it
// as a special HAMA movie
if (mediaIds.tmdbId && !mediaIds.tvdbId && mediaIds.isHama) {
this.processHamaMovie(metadata, mediaIds.tmdbId);
return;
}
// If the media is from HAMA and we have a TVDb ID, we will attempt
// to process any specials that may exist
if (mediaIds.tvdbId && mediaIds.isHama) {
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
}
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
const seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = [];
const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0);
for (const season of filteredSeasons) {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
);
if (matchedPlexSeason) {
// If we have a matched Plex season, get its children metadata so we can check details
const episodes = await this.plexClient.getChildrenMetadata(
matchedPlexSeason.ratingKey
);
// Total episodes that are in standard definition (not 4k)
const totalStandard = episodes.filter((episode) =>
!this.enable4kShow
? true
: episode.Media.some((media) => media.videoResolution !== '4k')
).length;
// Total episodes that are in 4k
const total4k = this.enable4kShow
? episodes.filter((episode) =>
episode.Media.some((media) => media.videoResolution === '4k')
).length
: 0;
processableSeasons.push({
seasonNumber: season.season_number,
episodes: totalStandard,
episodes4k: total4k,
totalEpisodes: season.episode_count,
});
} else {
processableSeasons.push({
seasonNumber: season.season_number,
episodes: 0,
episodes4k: 0,
totalEpisodes: season.episode_count,
});
}
}
if (mediaIds.tvdbId) {
await this.processShow(
mediaIds.tmdbId,
mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
processableSeasons,
{
mediaAddedAt: new Date(metadata.addedAt * 1000),
ratingKey: ratingKey,
title: metadata.title,
}
);
}
}
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
let mediaIds: Partial<MediaIds> = {};
// Check if item is using new plex movie/tv agent
if (plexitem.guid.match(plexRegex)) {
const guidCache = cacheManager.getCache('plexguid');
const cachedGuids = guidCache.data.get<MediaIds>(plexitem.ratingKey);
if (cachedGuids) {
this.log('GUIDs are cached. Skipping metadata request.', 'debug', {
mediaIds: cachedGuids,
title: plexitem.title,
});
mediaIds = cachedGuids;
}
const metadata =
plexitem.Guid && plexitem.Guid.length > 0
? plexitem
: await this.plexClient.getMetadata(plexitem.ratingKey);
// If there is no Guid field at all, then we bail
if (!metadata.Guid) {
throw new Error(
'No Guid metadata for this title. Skipping. (Try refreshing the metadata in Plex for this media!)'
);
}
// Map all IDs to MediaId object
metadata.Guid.forEach((ref) => {
if (ref.id.match(imdbRegex)) {
mediaIds.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
} else if (ref.id.match(tmdbRegex)) {
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
mediaIds.tmdbId = Number(tmdbMatch);
} else if (ref.id.match(tvdbRegex)) {
const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
mediaIds.tvdbId = Number(tvdbMatch);
}
});
// If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID
if (mediaIds.imdbId && !mediaIds.tmdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: mediaIds.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
}
// Cache GUIDs
guidCache.data.set(plexitem.ratingKey, mediaIds);
// Check if the agent is IMDb
} else if (plexitem.guid.match(imdbRegex)) {
const imdbMatch = plexitem.guid.match(imdbRegex);
if (imdbMatch) {
mediaIds.imdbId = imdbMatch[1];
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: mediaIds.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
}
// Check if the agent is TMDb
} else if (plexitem.guid.match(tmdbRegex)) {
const tmdbMatch = plexitem.guid.match(tmdbRegex);
if (tmdbMatch) {
mediaIds.tmdbId = Number(tmdbMatch[1]);
}
// Check if the agent is TVDb
} else if (plexitem.guid.match(tvdbRegex)) {
const matchedtvdb = plexitem.guid.match(tvdbRegex);
// If we can find a tvdb Id, use it to get the full tmdb show details
if (matchedtvdb) {
const show = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
mediaIds.tvdbId = Number(matchedtvdb[1]);
mediaIds.tmdbId = show.id;
}
// Check if the agent (for shows) is TMDb
} else if (plexitem.guid.match(tmdbShowRegex)) {
const matchedtmdb = plexitem.guid.match(tmdbShowRegex);
if (matchedtmdb) {
mediaIds.tmdbId = Number(matchedtmdb[1]);
}
// Check for HAMA (with TVDb guid)
} else if (plexitem.guid.match(hamaTvdbRegex)) {
const matchedtvdb = plexitem.guid.match(hamaTvdbRegex);
if (matchedtvdb) {
const show = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
mediaIds.tvdbId = Number(matchedtvdb[1]);
mediaIds.tmdbId = show.id;
// Set isHama to true, so we can know to add special processing to this item
mediaIds.isHama = true;
}
// Check for HAMA (with anidb guid)
} else if (plexitem.guid.match(hamaAnidbRegex)) {
const matchedhama = plexitem.guid.match(hamaAnidbRegex);
if (!animeList.isLoaded()) {
this.log(
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
'warn',
{ title: plexitem.title }
);
} else if (matchedhama) {
const anidbId = Number(matchedhama[1]);
const result = animeList.getFromAnidbId(anidbId);
let tvShow: TmdbTvDetails | null = null;
// Set isHama to true, so we can know to add special processing to this item
mediaIds.isHama = true;
// First try to lookup the show by TVDb ID
if (result?.tvdbId) {
const extResponse = await this.tmdb.getByExternalId({
externalId: result.tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
tvShow = await this.tmdb.getTvShow({
tvId: extResponse.tv_results[0].id,
});
mediaIds.tvdbId = result.tvdbId;
mediaIds.tmdbId = tvShow.id;
} else {
this.log(
`Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
);
}
}
if (!tvShow) {
// if lookup of tvshow above failed, then try movie with tmdbid/imdbid
// note - some tv shows have imdbid set too, that's why this need to go second
if (result?.tmdbId) {
mediaIds.tmdbId = result.tmdbId;
mediaIds.imdbId = result?.imdbId;
} else if (result?.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: result.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
mediaIds.imdbId = result.imdbId;
}
}
}
}
if (!mediaIds.tmdbId) {
throw new Error('Unable to find TMDb ID');
}
// We check above if we have the TMDb ID, so we can safely assert the type below
return mediaIds as MediaIds;
}
// movies with hama agent actually are tv shows with at least one episode in it
// try to get first episode of any season - cannot hardcode season or episode number
// because sometimes user can have it in other season/ep than s01e01
private async processHamaMovie(metadata: PlexMetadata, tmdbId: number) {
const season = metadata.Children?.Metadata[0];
if (season) {
const episodes = await this.plexClient.getChildrenMetadata(
season.ratingKey
);
if (episodes) {
await this.processPlexMovieByTmdbId(episodes[0], tmdbId);
}
}
}
// this adds all movie episodes from specials season for Hama agent
private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) {
const specials = metadata.Children?.Metadata.find(
(md) => Number(md.index) === 0
);
if (specials) {
const episodes = await this.plexClient.getChildrenMetadata(
specials.ratingKey
);
if (episodes) {
for (const episode of episodes) {
const special = animeList.getSpecialEpisode(tvdbId, episode.index);
if (special) {
if (special.tmdbId) {
await this.processPlexMovieByTmdbId(episode, special.tmdbId);
} else if (special.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: special.imdbId,
});
await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);
}
}
}
}
}
}
// checks if any of this.libraries has Hama agent set in Plex
private async hasHamaAgent() {
const plexLibraries = await this.plexClient.getLibraries();
return this.libraries.some((library) =>
plexLibraries.some(
(plexLibrary) =>
plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key
)
);
}
}
export const plexFullScanner = new PlexScanner();
export const plexRecentScanner = new PlexScanner(true);

View File

@@ -0,0 +1,105 @@
import { uniqWith } from 'lodash';
import RadarrAPI, { RadarrMovie } from '../../../api/servarr/radarr';
import { getSettings, RadarrSettings } from '../../settings';
import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner';
type SyncStatus = StatusBase & {
currentServer: RadarrSettings;
servers: RadarrSettings[];
};
class RadarrScanner
extends BaseScanner<RadarrMovie>
implements RunnableScanner<SyncStatus> {
private servers: RadarrSettings[];
private currentServer: RadarrSettings;
private radarrApi: RadarrAPI;
constructor() {
super('Radarr Scan', { bundleSize: 50 });
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
try {
this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
return (
radarrA.hostname === radarrB.hostname &&
radarrA.port === radarrB.port &&
radarrA.baseUrl === radarrB.baseUrl
);
});
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Radarr server: ${server.name}`,
'info'
);
this.radarrApi = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
this.items = await this.radarrApi.getMovies();
await this.loop(this.processRadarrMovie.bind(this), { sessionId });
} else {
this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
}
}
this.log('Radarr scan complete', 'info');
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async processRadarrMovie(radarrMovie: RadarrMovie): Promise<void> {
if (!radarrMovie.monitored && !radarrMovie.downloaded) {
this.log(
'Title is unmonitored and has not been downloaded. Skipping item.',
'debug',
{
title: radarrMovie.title,
}
);
return;
}
try {
const server4k = this.enable4kMovie && this.currentServer.is4k;
await this.processMovie(radarrMovie.tmdbId, {
is4k: server4k,
serviceId: this.currentServer.id,
externalServiceId: radarrMovie.id,
externalServiceSlug: radarrMovie.titleSlug,
title: radarrMovie.title,
processing: !radarrMovie.downloaded,
});
} catch (e) {
this.log('Failed to process Radarr media', 'error', {
errorMessage: e.message,
title: radarrMovie.title,
});
}
}
}
export const radarrScanner = new RadarrScanner();

View File

@@ -0,0 +1,134 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr';
import Media from '../../../entity/Media';
import { getSettings, SonarrSettings } from '../../settings';
import BaseScanner, {
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '../baseScanner';
type SyncStatus = StatusBase & {
currentServer: SonarrSettings;
servers: SonarrSettings[];
};
class SonarrScanner
extends BaseScanner<SonarrSeries>
implements RunnableScanner<SyncStatus> {
private servers: SonarrSettings[];
private currentServer: SonarrSettings;
private sonarrApi: SonarrAPI;
constructor() {
super('Sonarr Scan', { bundleSize: 50 });
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
try {
this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
return (
sonarrA.hostname === sonarrB.hostname &&
sonarrA.port === sonarrB.port &&
sonarrA.baseUrl === sonarrB.baseUrl
);
});
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Sonarr server: ${server.name}`,
'info'
);
this.sonarrApi = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
this.items = await this.sonarrApi.getSeries();
await this.loop(this.processSonarrSeries.bind(this), { sessionId });
} else {
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
}
}
this.log('Sonarr scan complete', 'info');
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async processSonarrSeries(sonarrSeries: SonarrSeries) {
try {
const mediaRepository = getRepository(Media);
const server4k = this.enable4kShow && this.currentServer.is4k;
const processableSeasons: ProcessableSeason[] = [];
let tmdbId: number;
const media = await mediaRepository.findOne({
where: { tvdbId: sonarrSeries.tvdbId },
});
if (!media || !media.tmdbId) {
const tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: sonarrSeries.tvdbId,
});
tmdbId = tvShow.id;
} else {
tmdbId = media.tmdbId;
}
const filteredSeasons = sonarrSeries.seasons.filter(
(sn) => sn.seasonNumber !== 0
);
for (const season of filteredSeasons) {
const totalAvailableEpisodes = season.statistics?.episodeFileCount ?? 0;
processableSeasons.push({
seasonNumber: season.seasonNumber,
episodes: !server4k ? totalAvailableEpisodes : 0,
episodes4k: server4k ? totalAvailableEpisodes : 0,
totalEpisodes: season.statistics?.totalEpisodeCount ?? 0,
processing: season.monitored && totalAvailableEpisodes === 0,
is4kOverride: server4k,
});
}
await this.processShow(tmdbId, sonarrSeries.tvdbId, processableSeasons, {
serviceId: this.currentServer.id,
externalServiceId: sonarrSeries.id,
externalServiceSlug: sonarrSeries.titleSlug,
title: sonarrSeries.title,
is4k: server4k,
});
} catch (e) {
this.log('Failed to process Sonarr media', 'error', {
errorMessage: e.message,
title: sonarrSeries.title,
});
}
}
}
export const sonarrScanner = new SonarrScanner();

View File

@@ -1,19 +1,23 @@
import { randomUUID } from 'crypto';
import fs from 'fs';
import path from 'path';
import { merge } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Permission } from './permissions';
import path from 'path';
import webpush from 'web-push';
import { MediaServerType } from '../constants/server';
import { Permission } from './permissions';
export interface Library {
id: string;
name: string;
enabled: boolean;
type: 'show' | 'movie';
lastScan?: number;
}
export interface Region {
iso_3166_1: string;
english_name: string;
name?: string;
}
export interface Language {
@@ -29,6 +33,7 @@ export interface PlexSettings {
port: number;
useSsl?: boolean;
libraries: Library[];
webAppUrl?: string;
}
export interface JellyfinSettings {
@@ -38,7 +43,7 @@ export interface JellyfinSettings {
serverId: string;
}
interface DVRSettings {
export interface DVRSettings {
id: number;
name: string;
hostname: string;
@@ -49,6 +54,7 @@ interface DVRSettings {
activeProfileId: number;
activeProfileName: string;
activeDirectory: string;
tags: number[];
is4k: boolean;
isDefault: boolean;
externalUrl?: string;
@@ -66,21 +72,35 @@ export interface SonarrSettings extends DVRSettings {
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
activeLanguageProfileId?: number;
animeTags?: number[];
enableSeasonFolders: boolean;
}
interface Quota {
quotaLimit?: number;
quotaDays?: number;
}
export interface MainSettings {
apiKey: string;
applicationTitle: string;
applicationUrl: string;
csrfProtection: boolean;
cacheImages: boolean;
defaultPermissions: number;
defaultQuotas: {
movie: Quota;
tv: Quota;
};
hideAvailable: boolean;
localLogin: boolean;
newPlexLogin: boolean;
region: string;
originalLanguage: string;
trustProxy: boolean;
mediaServerType: number;
partialRequestsEnabled: boolean;
locale: string;
}
interface PublicSettings {
@@ -89,6 +109,7 @@ interface PublicSettings {
interface FullPublicSettings extends PublicSettings {
applicationTitle: string;
applicationUrl: string;
hideAvailable: boolean;
localLogin: boolean;
movie4kEnabled: boolean;
@@ -98,15 +119,23 @@ interface FullPublicSettings extends PublicSettings {
mediaServerType: number;
jellyfinHost?: string;
jellyfinServerName?: string;
partialRequestsEnabled: boolean;
cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;
locale: string;
emailEnabled: boolean;
}
export interface NotificationAgentConfig {
enabled: boolean;
types: number;
types?: number;
options: Record<string, unknown>;
}
export interface NotificationAgentDiscord extends NotificationAgentConfig {
options: {
botUsername?: string;
botAvatarUrl?: string;
webhookUrl: string;
};
}
@@ -123,15 +152,27 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
smtpHost: string;
smtpPort: number;
secure: boolean;
ignoreTls: boolean;
requireTls: boolean;
authUser?: string;
authPass?: string;
allowSelfSigned: boolean;
senderName: string;
pgpPrivateKey?: string;
pgpPassword?: string;
};
}
export interface NotificationAgentLunaSea extends NotificationAgentConfig {
options: {
webhookUrl: string;
profileName?: string;
};
}
export interface NotificationAgentTelegram extends NotificationAgentConfig {
options: {
botUsername?: string;
botAPI: string;
chatId: string;
sendSilently: boolean;
@@ -148,7 +189,6 @@ export interface NotificationAgentPushover extends NotificationAgentConfig {
options: {
accessToken: string;
userToken: string;
priority: number;
};
}
@@ -156,28 +196,41 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
options: {
webhookUrl: string;
jsonPayload: string;
authHeader: string;
authHeader?: string;
};
}
export enum NotificationAgentKey {
DISCORD = 'discord',
EMAIL = 'email',
PUSHBULLET = 'pushbullet',
PUSHOVER = 'pushover',
SLACK = 'slack',
TELEGRAM = 'telegram',
WEBHOOK = 'webhook',
WEBPUSH = 'webpush',
}
interface NotificationAgents {
discord: NotificationAgentDiscord;
email: NotificationAgentEmail;
lunasea: NotificationAgentLunaSea;
pushbullet: NotificationAgentPushbullet;
pushover: NotificationAgentPushover;
slack: NotificationAgentSlack;
telegram: NotificationAgentTelegram;
webhook: NotificationAgentWebhook;
webpush: NotificationAgentConfig;
}
interface NotificationSettings {
enabled: boolean;
autoapprovalEnabled: boolean;
agents: NotificationAgents;
}
interface AllSettings {
clientId: string;
vapidPublic: string;
vapidPrivate: string;
main: MainSettings;
plex: PlexSettings;
jellyfin: JellyfinSettings;
@@ -196,23 +249,33 @@ class Settings {
constructor(initialSettings?: AllSettings) {
this.data = {
clientId: uuidv4(),
clientId: randomUUID(),
vapidPrivate: '',
vapidPublic: '',
main: {
apiKey: '',
applicationTitle: 'Overseerr',
applicationUrl: '',
csrfProtection: false,
cacheImages: false,
defaultPermissions: Permission.REQUEST,
defaultQuotas: {
movie: {},
tv: {},
},
hideAvailable: false,
localLogin: true,
newPlexLogin: true,
region: '',
originalLanguage: '',
trustProxy: false,
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
locale: 'en',
},
plex: {
name: '',
ip: '127.0.0.1',
ip: '',
port: 32400,
useSsl: false,
libraries: [],
@@ -229,17 +292,16 @@ class Settings {
initialized: false,
},
notifications: {
enabled: true,
autoapprovalEnabled: false,
agents: {
email: {
enabled: false,
types: 0,
options: {
emailFrom: '',
smtpHost: '127.0.0.1',
smtpHost: '',
smtpPort: 587,
secure: false,
ignoreTls: false,
requireTls: false,
allowSelfSigned: false,
senderName: 'Overseerr',
},
@@ -251,6 +313,13 @@ class Settings {
webhookUrl: '',
},
},
lunasea: {
enabled: false,
types: 0,
options: {
webhookUrl: '',
},
},
slack: {
enabled: false,
types: 0,
@@ -280,7 +349,6 @@ class Settings {
options: {
accessToken: '',
userToken: '',
priority: 0,
},
},
webhook: {
@@ -288,11 +356,14 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
authHeader: '',
jsonPayload:
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i',
},
},
webpush: {
enabled: false,
options: {},
},
},
},
};
@@ -357,6 +428,7 @@ class Settings {
return {
...this.data.public,
applicationTitle: this.data.main.applicationTitle,
applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable,
localLogin: this.data.main.localLogin,
movie4kEnabled: this.data.radarr.some(
@@ -369,6 +441,12 @@ class Settings {
originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType,
jellyfinHost: this.jellyfin.hostname,
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic,
enablePushRegistration: this.data.notifications.agents.webpush.enabled,
locale: this.data.main.locale,
emailEnabled: this.data.notifications.agents.email.enabled,
};
}
@@ -382,13 +460,25 @@ class Settings {
get clientId(): string {
if (!this.data.clientId) {
this.data.clientId = uuidv4();
this.data.clientId = randomUUID();
this.save();
}
return this.data.clientId;
}
get vapidPublic(): string {
this.generateVapidKeys();
return this.data.vapidPublic;
}
get vapidPrivate(): string {
this.generateVapidKeys();
return this.data.vapidPrivate;
}
public regenerateApiKey(): MainSettings {
this.main.apiKey = this.generateApiKey();
this.save();
@@ -396,7 +486,16 @@ class Settings {
}
private generateApiKey(): string {
return Buffer.from(`${Date.now()}${uuidv4()})`).toString('base64');
return Buffer.from(`${Date.now()}${randomUUID()})`).toString('base64');
}
private generateVapidKeys(force = false): void {
if (!this.data.vapidPublic || !this.data.vapidPrivate || force) {
const vapidKeys = webpush.generateVAPIDKeys();
this.data.vapidPrivate = vapidKeys.privateKey;
this.data.vapidPublic = vapidKeys.publicKey;
this.save();
}
}
/**