diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 0e373c855f..63c2a98d93 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -6,6 +6,10 @@ import PreparedEmail from '@server/lib/email'; import type { NotificationAgentEmail } from '@server/lib/settings'; import { NotificationAgentKey, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { + getAvailableMediaServerName, + getAvailableMediaServerUrl, +} from '@server/utils/mediaServerHelper'; import type { EmailOptions } from 'email-templates'; import path from 'path'; import validator from 'validator'; @@ -17,6 +21,11 @@ class EmailAgent extends BaseAgent implements NotificationAgent { + /** + * Returns this agents notification settings + * Uses a cached copy when available + * @protected + */ protected getSettings(): NotificationAgentEmail { if (this.settings) { return this.settings; @@ -27,6 +36,10 @@ class EmailAgent return settings.notifications.agents.email; } + /** + * Determines whether this agent is able to send email notifications + * Returns true only if the agent is enabled and the required SMTP settings are configured + */ public shouldSend(): boolean { const settings = this.getSettings(); @@ -42,6 +55,18 @@ class EmailAgent return false; } + /** + * Builds the email payload for an email notification + * + * For all notifications it has a button to take you to the media in Seerr + * For request notifications it includes a button that links to the media in the chosen media server + * @param type Type of notification being sent + * @param payload Notification context + * @param recipientEmail The recipient's email address + * @param recipientName The recipient's name + * @returns Email notification payload + * @private + */ private buildMessage( type: Notification, payload: NotificationPayload, @@ -118,6 +143,10 @@ class EmailAgent break; } + const { mediaServerType } = settings.main; + const mediaServerName = getAvailableMediaServerName(mediaServerType); + const mediaServerUrl = getAvailableMediaServerUrl(payload); + return { template: path.join( __dirname, @@ -141,6 +170,8 @@ class EmailAgent applicationTitle, recipientName, recipientEmail, + mediaServerActionUrl: mediaServerUrl, + mediaServerName, }, }; } else if (payload.issue) { @@ -194,6 +225,14 @@ class EmailAgent return undefined; } + /** + * Sends an email notification to the recipients in the payload. + * + * Respects per-user notification settings, and it validates the email address before sending the notification + * @param type The type of notification being sent + * @param payload Notification context + * @returns True if the notification has successfully sent, else returns False + */ public async send( type: Notification, payload: NotificationPayload diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index 71360d63ab..2d62af76f3 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -2,6 +2,10 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue'; import type { NotificationAgentSlack } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { + getAvailableMediaServerName, + getAvailableMediaServerUrl, +} from '@server/utils/mediaServerHelper'; import axios from 'axios'; import { Notification, hasNotificationType } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; @@ -49,6 +53,11 @@ class SlackAgent extends BaseAgent implements NotificationAgent { + /** + * Returns this agent's notification settings + * Uses a cached copy when available + * @protected + */ protected getSettings(): NotificationAgentSlack { if (this.settings) { return this.settings; @@ -59,12 +68,21 @@ class SlackAgent return settings.notifications.agents.slack; } + /** + * Builds the Slack Embed for the Slack notification that will be sent + * + * For all notifications it has a button to take you to the media in Seerr + * For request notifications it includes a button that links to the media in the chosen media server + * @param type The type of notification being sent + * @param payload Notification context + * @returns The Slack embed payload + */ public buildEmbed( type: Notification, payload: NotificationPayload ): SlackBlockEmbed { const settings = getSettings(); - const { applicationUrl, applicationTitle } = settings.main; + const { applicationUrl, applicationTitle, mediaServerType } = settings.main; const { embedPoster } = settings.notifications.agents.slack; const fields: EmbedField[] = []; @@ -187,22 +205,43 @@ class SlackAgent : undefined : undefined; + const actionElements: Element[] = []; + if (url) { + actionElements.push({ + action_id: 'open-in-seerr', + type: 'button', + url, + text: { + type: 'plain_text', + text: `View ${ + payload.issue ? 'Issue' : 'Media' + } in ${applicationTitle}`, + }, + }); + } + + if (!payload.issue) { + const mediaServerName = getAvailableMediaServerName(mediaServerType); + const mediaServerUrl = getAvailableMediaServerUrl(payload); + + if (mediaServerUrl) { + actionElements.push({ + action_id: 'open-in-mediaServer', + type: 'button', + url: mediaServerUrl, + text: { + type: 'plain_text', + text: `Play on ${mediaServerName}`, + }, + }); + } + } + + if (actionElements.length > 0) { blocks.push({ type: 'actions', - elements: [ - { - action_id: 'open-in-seerr', - type: 'button', - url, - text: { - type: 'plain_text', - text: `View ${ - payload.issue ? 'Issue' : 'Media' - } in ${applicationTitle}`, - }, - }, - ], + elements: actionElements, }); } @@ -212,6 +251,10 @@ class SlackAgent }; } + /** + * Determines whether this agent is able to send Slack notifications + * Returns true only if the agent is enabled and a webhook URL is configured + */ public shouldSend(): boolean { const settings = this.getSettings(); @@ -222,6 +265,16 @@ class SlackAgent return false; } + /** + * Sends a Slack notification to the configured Slack webhook + * + * Returns true when notifications are skipped, disabled/type not enabled, or when the webhook call succeeds + * Returns false only when the webhook call fails + * + * @param type The type of notification being sent + * @param payload Notification context + * @returns True if the notification was sent successfully, otherwise False + */ public async send( type: Notification, payload: NotificationPayload diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index cd54a86d63..dcba4b7b8e 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -5,6 +5,10 @@ import { User } from '@server/entity/User'; import type { NotificationAgentTelegram } from '@server/lib/settings'; import { NotificationAgentKey, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { + getAvailableMediaServerName, + getAvailableMediaServerUrl, +} from '@server/utils/mediaServerHelper'; import axios from 'axios'; import { Notification, @@ -37,6 +41,11 @@ class TelegramAgent { private baseUrl = 'https://api.telegram.org/'; + /** + * Returns this agent's notification settings. + * Uses a cached copy when available. + * @protected + */ protected getSettings(): NotificationAgentTelegram { if (this.settings) { return this.settings; @@ -47,6 +56,10 @@ class TelegramAgent return settings.notifications.agents.telegram; } + /** + * Determines whether this agent is able to send Telegram notifications + * Returns True only if Telegram notifications are enabled and the bot API token is filled in, else returns False + */ public shouldSend(): boolean { const settings = this.getSettings(); @@ -57,16 +70,35 @@ class TelegramAgent return false; } + /** + * Escapes text for Telegram MarkdownV2 + * Telegram requires some characters to be escaped to prevent any formatting issues + * @param text The unescaped input + * @returns Escaped text that is safe for MarkdownV2 + * @private + */ private escapeText(text: string | undefined): string { - return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : ''; + return text + ? text.replace(/[_*[\]()~`\\>#+=|{}.!-]/gi, (x) => '\\' + x) + : ''; } + /** + * Builds the Telegram notification payload that will be sent + * + * For all notifications it has a button to take you to the media in Seerr + * For request notifications it includes a button that links to the media in the chosen media server + * @param type The type of notification being sent + * @param payload Notification context + * @returns Telegram notification payload + * @private + */ private getNotificationPayload( type: Notification, payload: NotificationPayload ): Partial { const settings = getSettings(); - const { applicationUrl, applicationTitle } = settings.main; + const { applicationUrl, applicationTitle, mediaServerType } = settings.main; const { embedPoster } = settings.notifications.agents.telegram; /* eslint-disable no-useless-escape */ @@ -142,6 +174,15 @@ class TelegramAgent payload.issue ? 'Issue' : 'Media' } in ${this.escapeText(applicationTitle)}\]\(${url}\)`; } + + if (!payload.issue) { + const mediaServerName = getAvailableMediaServerName(mediaServerType); + const mediaServerUrl = getAvailableMediaServerUrl(payload); + + if (mediaServerUrl) { + message += `\n\[Play on ${this.escapeText(mediaServerName)}\]\(${mediaServerUrl.replace(/\)/g, '\\)')}\)`; + } + } /* eslint-enable */ return embedPoster && payload.image @@ -156,6 +197,13 @@ class TelegramAgent }; } + /** + * Sends the Telegram notification for the provided type using the payload + * + * @param type The type of notification being sent + * @param payload Notification payload + * @returns True if the notification was sent successfully, otherwise False + */ public async send( type: Notification, payload: NotificationPayload diff --git a/server/templates/email/media-request/html.pug b/server/templates/email/media-request/html.pug index bd7c9aa61e..57b3b91ba4 100644 --- a/server/templates/email/media-request/html.pug +++ b/server/templates/email/media-request/html.pug @@ -67,3 +67,9 @@ div(style='display: block; background-color: #111827; padding: 2.5rem 0;') a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') | View Media in #{applicationTitle} + if mediaServerActionUrl + tr + td + a(href=mediaServerActionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') + span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') + | Play on #{mediaServerName} diff --git a/server/utils/mediaServerHelper.ts b/server/utils/mediaServerHelper.ts new file mode 100644 index 0000000000..0622332cc6 --- /dev/null +++ b/server/utils/mediaServerHelper.ts @@ -0,0 +1,47 @@ +import { MediaServerType } from '@server/constants/server'; + +/** + * Returns the display name of the media server being used in the seerr installation + * If neither Emby nor Plex is detected it falls back to Jellyfin + * @param currentMediaServer The currently configured media server type from settings + * @returns Human readable media server name + */ +export function getAvailableMediaServerName( + currentMediaServer: MediaServerType +): string { + if (currentMediaServer === MediaServerType.EMBY) { + return 'Emby'; + } + + if (currentMediaServer === MediaServerType.PLEX) { + return 'Plex'; + } + + return 'Jellyfin'; +} + +/** + * This function returns the URL of the media directly in the media server + * Used later on in the email as a button to send directly to + * + * Prefers the 4K URL when the request is marked as 4K and prefers the + * standard URL otherwise. If the preferred URL is unavailable, it falls + * back to the alternative resolution URL to ensure a playable link is + * returned wherever possible. + * + * @param payload Notification payload containing media and request information + * @returns Media server URL or undefined if it's unavailable + */ +export function getAvailableMediaServerUrl(payload: { + request?: { is4k?: boolean }; + media?: { + mediaUrl?: string; + mediaUrl4k?: string; + }; +}): string | undefined { + const wants4k = payload.request?.is4k; + const url4k = payload.media?.mediaUrl4k; + const url = payload.media?.mediaUrl; + + return (wants4k ? (url4k ?? url) : (url ?? url4k)) || undefined; +}