feat(i18n): add server-side i18n for notification agents (#2731)

This commit is contained in:
fallenbagel
2026-04-17 19:39:59 +08:00
committed by GitHub
parent 7861db4941
commit cd257bad2a
32 changed files with 971 additions and 320 deletions
+27 -22
View File
@@ -1,39 +1,44 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-require-imports, no-console */
/**
* Check that i18n locale files are in sync with extracted messages.
* Runs `pnpm i18n:extract` and compares en.json; exits 1 if they differ.
* Runs extract script and compares en.json files; exits 1 if they differ.
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const localePath = path.join(
__dirname,
'..',
'src',
'i18n',
'locale',
'en.json'
);
const backupPath = `${localePath}.bak`;
const targets = [
path.join(__dirname, '..', 'src', 'i18n', 'locale', 'en.json'),
path.join(__dirname, '..', 'server', 'i18n', 'locale', 'en.json'),
];
const backups = targets.map((p) => `${p}.bak`);
try {
fs.copyFileSync(localePath, backupPath);
targets.forEach((p, i) => fs.copyFileSync(p, backups[i]));
execSync('pnpm i18n:extract', { stdio: 'inherit' });
const original = fs.readFileSync(backupPath, 'utf8');
const extracted = fs.readFileSync(localePath, 'utf8');
fs.unlinkSync(backupPath);
if (original !== extracted) {
console.error(
"i18n messages are out of sync. Please run 'pnpm i18n:extract' and commit the changes."
);
let outOfSync = false;
for (let i = 0; i < targets.length; i++) {
const original = fs.readFileSync(backups[i], 'utf8');
const extracted = fs.readFileSync(targets[i], 'utf8');
fs.unlinkSync(backups[i]);
if (original !== extracted) {
console.error(
`i18n messages are out of sync for ${path.basename(path.dirname(path.dirname(targets[i])))}. Please run 'pnpm i18n:extract' and commit the changes.`
);
outOfSync = true;
}
}
if (outOfSync) {
process.exit(1);
}
} catch (err) {
if (fs.existsSync(backupPath)) {
fs.unlinkSync(backupPath);
}
backups.forEach((b) => {
if (fs.existsSync(b)) fs.unlinkSync(b);
});
throw err;
}
@@ -29,3 +29,11 @@ If you would like to override the name you configured for your bot in Discord, y
### Bot Avatar URL (optional)
Similar to the bot username, you can override the avatar for your bot.
### Use Notification Recipient Locale
When enabled, notifications will be sent in the language of the user who triggered the notification (e.g., the user who made the request or reported the issue) based on their display language setting. When disabled, the **Notification Language** setting below is used instead.
### Notification Language
Sets the language for all notifications sent to this Discord channel. This option is only available when **Use Notification Recipient Locale** is disabled.
+4
View File
@@ -12,6 +12,10 @@ sidebar_position: 1
If the [Application URL](/using-seerr/settings/general#application-title) setting is configured in **Settings → General**, Seerr will explicitly set the origin server hostname when connecting to the SMTP host.
:::
### Require User Email
When enabled, users will be required to provide a valid email address in their user settings. This is useful if you want to ensure all users can receive email notifications.
### Sender Name (optional)
Configure a friendly name for the email sender (e.g., "Seerr").
+4
View File
@@ -19,3 +19,7 @@ Add an application to your Gotify server, and set this field to the generated ap
:::info
Please refer to the [Gotify API documentation](https://gotify.net/docs) for more details on configuring these notifications.
:::
### Notification Language
Sets the language for notifications sent to this Gotify server.
+4
View File
@@ -31,3 +31,7 @@ Set the priority level for notifications. Options range from Minimum (1) to Urge
:::info
Please refer to the [ntfy.sh API documentation](https://docs.ntfy.sh/) for more details on configuring these notifications.
:::
### Notification Language
Sets the language for notifications sent to this ntfy server.
+4
View File
@@ -15,3 +15,7 @@ Simply [create a webhook](https://my.slack.com/services/new/incoming-webhook/) a
:::info
Please refer to the [Slack API documentation](https://api.slack.com/messaging/webhooks) for more details on configuring these notifications.
:::
### Notification Language
Sets the language for notifications sent to this Slack channel.
+3 -2
View File
@@ -7,14 +7,14 @@
"preinstall": "npx only-allow pnpm",
"postinstall": "next telemetry disable",
"dev": "nodemon -e ts --watch server --watch seerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && copyfiles -u 2 \"server/i18n/locale/*.json\" dist/i18n && tsc-alias -p server/tsconfig.json",
"build:next": "next build",
"build": "pnpm build:next && pnpm build:server",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
"test": "node server/test/index.mts",
"start": "NODE_ENV=production node dist/index.js",
"i18n:extract": "ts-node --project server/tsconfig.json src/i18n/extractMessages.ts",
"i18n:extract": "ts-node --project server/tsconfig.json server/i18n/extractMessages.ts",
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
@@ -36,6 +36,7 @@
"dependencies": {
"@dr.pogodin/csurf": "^1.16.6",
"@fontsource-variable/inter": "^5.2.8",
"@formatjs/intl": "^4.1.4",
"@formatjs/intl-displaynames": "6.8.13",
"@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.4.6",
+70 -1
View File
@@ -18,6 +18,9 @@ importers:
'@fontsource-variable/inter':
specifier: ^5.2.8
version: 5.2.8
'@formatjs/intl':
specifier: ^4.1.4
version: 4.1.4([email protected])
'@formatjs/intl-displaynames':
specifier: 6.8.13
version: 6.8.13
@@ -1497,6 +1500,9 @@ packages:
'@fontsource-variable/[email protected]':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-SlsbRC/RX+/zg4AApWIFNDdkLtFbkq3LNoZWXZCE/nHVKqoIJyaoQyge/I0Y38vLxowUn9KTtXgusLD91+orbg==}
@@ -1509,6 +1515,9 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-dHnqHgBo6GXYGRsepaE1wmsC2etaivOWd5VaJstZd+HI2zR3DCUjbDVZRtoPGkkXZmyHvBwrdEUuqfvzhF/DtQ==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==}
@@ -1518,18 +1527,27 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-nBZJYmhpcSX0WeJ5SDYUkZ42AgR3xiyhNCsQweFx3cz/ULJjym8bHAzWKvG5e2+1XO98dBYC0fWeeAECAVSwLA==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-HJWZ9S6JWey6iY5+YXE3Kd0ofWU1sC2KTTp56e1168g/xxWvVvr8k9G4fexIgwYV9wbtjY7kGYK5FjoWB3B2OQ==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-k4ERKgw7aKGWJZgTarIcNEmvyTVD9FYh0mTrrBMHZ1b8hUu6iOJ4SzsZlo3UNAvHYa+PnvntIwRPt1/vy4nA9Q==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-9mFp8TJ166ZM2pcjKwsBWXrDnOJGT7vMEScVgLygUODPOsE8S6f/FHoacvrlHK1B4dYZk8vSCNruyPU64AfgJQ==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-Lgx6n5KxN16B3Pb05z3NLEBQkGoXnGjkTBNCZI+Cn17YjHJ3fhCeEJJUqRlIZmJdmaXQhjcQVDp6WIiNeRYT5g==}
@@ -1561,6 +1579,9 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-2HlOq+c7KsSps829SJ3B5987coX5mzKx9NbPcNwQ07eq8FBHgB3HfMoxt5HvLsdk4oQwCjAEnocbtd+wVwZ2Kg==}
@@ -1576,6 +1597,14 @@ packages:
typescript:
optional: true
'@formatjs/[email protected]':
resolution: {integrity: sha512-y5aZ5p5zO74mZPdQMe9gq8SR1P6rlPQkaYOVFHyRzWrViif4sRKIQX01GYbKJvaGanupVVX+0WoKJqLVsP+X3Q==}
peerDependencies:
typescript: ^5.6.0
peerDependenciesMeta:
typescript:
optional: true
'@formatjs/[email protected]':
resolution: {integrity: sha512-lFDp9Rbpxk5Dt8O1/I9VG5btqKbOkjT4snSa73HO1YTJ9KGeXPKA7aWVgHFXJVVq0KluhbZiCoPJVHC4ZREgxw==}
engines: {node: '>= 20.12.0'}
@@ -5748,6 +5777,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-IjC6sI0X7YRjjyVH9aUgdftcmZK7WXdHeil4KwbjDnRWjnVitKpAx3rr6t6di1joFp5188VqKcobOPA6mCLG/w==}
[email protected]:
resolution: {integrity: sha512-IhghAA8n4KSlXuWKzYsWyWb82JoYTzShfyvdSF85oJPnNOjvv4kAo7S7Jtkm3/vJ53C7dQNRO+Gpnj3iWgTjBQ==}
[email protected]:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
@@ -7736,7 +7768,6 @@ packages:
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
deprecated: |-
You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
[email protected]:
@@ -11513,6 +11544,8 @@ snapshots:
'@fontsource-variable/[email protected]': {}
'@formatjs/[email protected]': {}
'@formatjs/[email protected]':
dependencies:
'@formatjs/intl-localematcher': 0.2.32
@@ -11537,6 +11570,12 @@ snapshots:
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/[email protected]':
dependencies:
'@formatjs/bigdecimal': 0.2.0
'@formatjs/fast-memoize': 3.1.1
'@formatjs/intl-localematcher': 0.8.2
'@formatjs/[email protected]':
dependencies:
tslib: 2.8.1
@@ -11549,6 +11588,8 @@ snapshots:
dependencies:
tslib: 2.8.1
'@formatjs/[email protected]': {}
'@formatjs/[email protected]':
dependencies:
'@formatjs/ecma402-abstract': 2.0.0
@@ -11561,6 +11602,11 @@ snapshots:
'@formatjs/icu-skeleton-parser': 2.1.1
tslib: 2.8.1
'@formatjs/[email protected]':
dependencies:
'@formatjs/ecma402-abstract': 3.2.0
'@formatjs/icu-skeleton-parser': 2.1.3
'@formatjs/[email protected]':
dependencies:
'@formatjs/ecma402-abstract': 2.0.0
@@ -11571,6 +11617,10 @@ snapshots:
'@formatjs/ecma402-abstract': 3.1.1
tslib: 2.8.1
'@formatjs/[email protected]':
dependencies:
'@formatjs/ecma402-abstract': 3.2.0
'@formatjs/[email protected]':
dependencies:
'@formatjs/ecma402-abstract': 2.0.0
@@ -11621,6 +11671,10 @@ snapshots:
'@formatjs/fast-memoize': 3.1.0
tslib: 2.8.1
'@formatjs/[email protected]':
dependencies:
'@formatjs/fast-memoize': 3.1.1
'@formatjs/[email protected]':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
@@ -11644,6 +11698,15 @@ snapshots:
optionalDependencies:
typescript: 5.4.5
'@formatjs/[email protected]([email protected])':
dependencies:
'@formatjs/ecma402-abstract': 3.2.0
'@formatjs/fast-memoize': 3.1.1
'@formatjs/icu-messageformat-parser': 3.5.3
intl-messageformat: 11.2.0
optionalDependencies:
typescript: 5.4.5
'@formatjs/[email protected]([email protected](@babel/[email protected])(@jest/[email protected])(@jest/[email protected])([email protected](@babel/[email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@swc/[email protected](@swc/[email protected]))(@types/[email protected])([email protected])))([email protected]))':
dependencies:
'@formatjs/icu-messageformat-parser': 3.5.1
@@ -16993,6 +17056,12 @@ snapshots:
'@formatjs/icu-messageformat-parser': 2.7.8
tslib: 2.8.1
[email protected]:
dependencies:
'@formatjs/ecma402-abstract': 3.2.0
'@formatjs/fast-memoize': 3.1.1
'@formatjs/icu-messageformat-parser': 3.5.3
[email protected]:
dependencies:
loose-envify: 1.4.0
@@ -1,7 +1,6 @@
import { promises as fs } from 'fs';
import { join } from 'path';
// get all file content recursively
async function getFiles(dir: string): Promise<string[]> {
const dirents = await fs.readdir(dir, { withFileTypes: true });
const files = await Promise.all(
@@ -13,7 +12,6 @@ async function getFiles(dir: string): Promise<string[]> {
return Array.prototype.concat(...files);
}
// extract the i18n messages from the file
async function extractMessages(
filePath: string
): Promise<{ namespace: string; messages: Record<string, string> } | null> {
@@ -44,11 +42,12 @@ async function extractMessages(
}
async function processMessages(dir: string): Promise<string> {
// get messages from all files and sort them by namespace
const files = await getFiles(dir);
const extractedMessagesGroups = await Promise.all(files.map(extractMessages));
const tsFiles = files.filter((f) => /\.tsx?$/.test(f));
const extractedMessagesGroups = await Promise.all(
tsFiles.map(extractMessages)
);
// group messages by namespace
const messagesByNamespace: {
namespace: string;
messages: Record<string, string>;
@@ -56,6 +55,7 @@ async function processMessages(dir: string): Promise<string> {
const namespaces = [
...new Set(extractedMessagesGroups.map((msg) => msg?.namespace)),
];
for (const namespace of namespaces) {
if (!namespace) continue;
const filteredMessagesGroups = extractedMessagesGroups
@@ -87,7 +87,6 @@ async function processMessages(dir: string): Promise<string> {
return a.namespace.localeCompare(b.namespace);
});
// add every messages from every namespace to an object
const result: Record<string, string> = {};
for (const extractedMessages of messagesByNamespace) {
const { namespace, messages } = extractedMessages;
@@ -96,15 +95,19 @@ async function processMessages(dir: string): Promise<string> {
}
}
return JSON.stringify(result, null, ' ') + '\n';
return JSON.stringify(result, Object.keys(result).sort(), ' ') + '\n';
}
async function saveMessages() {
const directoryPath = './src/';
const resultPath = './src/i18n/locale/en.json';
const targets = [
{ dir: './src/', output: './src/i18n/locale/en.json' },
{ dir: './server/', output: './server/i18n/locale/en.json' },
];
const result = await processMessages(directoryPath);
await fs.writeFile(resultPath, result);
for (const { dir, output } of targets) {
const result = await processMessages(dir);
await fs.writeFile(output, result);
}
}
saveMessages();
+26
View File
@@ -0,0 +1,26 @@
import { defineMessages } from '@server/i18n';
const globalMessages = defineMessages('notifications.common', {
requestedBy: 'Requested By',
requestStatus: 'Request Status',
pendingApproval: 'Pending Approval',
processing: 'Processing',
available: 'Available',
declined: 'Declined',
failed: 'Failed',
commentFrom: 'Comment from {userName}',
reportedBy: 'Reported By',
issueType: 'Issue Type',
issueStatus: 'Issue Status',
open: 'Open',
resolved: 'Resolved',
viewIssue: 'View Issue in {applicationTitle}',
viewMedia: 'View Media in {applicationTitle}',
openIn: 'Open in {applicationTitle}',
movie: 'movie',
series: 'series',
issue: 'issue',
issueTypeName: '{type} issue',
});
export default globalMessages;
+62
View File
@@ -0,0 +1,62 @@
import { createIntl, createIntlCache } from '@formatjs/intl';
import type { AvailableLocale } from '@server/types/languages';
import { availableLocales } from '@server/types/languages';
import fs from 'fs';
import path from 'path';
type IntlInstance = ReturnType<typeof createIntl>;
const cache = createIntlCache();
const intls = new Map<string, IntlInstance>();
export function initI18n(): void {
for (const locale of availableLocales) {
const filePath = path.join(__dirname, `locale/${locale}.json`);
if (!fs.existsSync(filePath)) continue;
const messages = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
intls.set(
locale,
createIntl(
{
locale,
messages,
defaultLocale: 'en',
},
cache
)
);
}
if (!intls.has('en')) {
throw new Error(
'Failed to initialize English locale - en.json is required'
);
}
}
export function getIntl(locale?: AvailableLocale): IntlInstance {
return intls.get(locale ?? 'en') || intls.get('en')!;
}
type MessageDescriptorMap<T extends Record<string, string>> = {
[K in keyof T]: { id: string; defaultMessage: T[K] };
};
export function defineMessages<T extends Record<string, string>>(
namespace: string,
messages: T
): MessageDescriptorMap<T> {
const result = {} as MessageDescriptorMap<T>;
for (const key of Object.keys(messages) as (keyof T)[]) {
result[key] = {
id: `${namespace}.${String(key)}`,
defaultMessage: messages[key],
};
}
return result;
}
+57
View File
@@ -0,0 +1,57 @@
{
"notifications.agents.email.approvedRequest": "Your request for the following {mediaType} has been approved:",
"notifications.agents.email.approvedRequest4k": "Your request for the following {mediaType} in 4K has been approved:",
"notifications.agents.email.autoApproved": "A new request for the following {mediaType} has been automatically approved:",
"notifications.agents.email.autoApproved4k": "A new request for the following {mediaType} in 4K has been automatically approved:",
"notifications.agents.email.autoRequested": "A new request for the following {mediaType} was automatically submitted:",
"notifications.agents.email.autoRequested4k": "A new request for the following {mediaType} in 4K was automatically submitted:",
"notifications.agents.email.availableRequest": "Your request for the following {mediaType} is now available:",
"notifications.agents.email.availableRequest4k": "Your request for the following {mediaType} in 4K is now available:",
"notifications.agents.email.declinedRequest": "Your request for the following {mediaType} was declined:",
"notifications.agents.email.declinedRequest4k": "Your request for the following {mediaType} in 4K was declined:",
"notifications.agents.email.failedRequest": "A request for the following {mediaType} failed to be added to {service}:",
"notifications.agents.email.failedRequest4k": "A request for the following {mediaType} in 4K failed to be added to {service}:",
"notifications.agents.email.issue": "issue",
"notifications.agents.email.issueComment": "{userName} commented on the {issueType} for the {mediaType} {subject}:",
"notifications.agents.email.issueCreated": "A new {issueType} has been reported by {userName} for the {mediaType} {subject}:",
"notifications.agents.email.issueReopened": "The {issueType} for the {mediaType} {subject} was reopened by {userName}.",
"notifications.agents.email.issueResolved": "The {issueType} for the {mediaType} {subject} was marked as resolved by {userName}!",
"notifications.agents.email.issueType": "{type} issue",
"notifications.agents.email.pendingRequest": "A new request for the following {mediaType} is pending approval:",
"notifications.agents.email.pendingRequest4k": "A new request for the following {mediaType} in 4K is pending approval:",
"notifications.agents.webpush.approved": "Your {quality}{mediaType} request has been approved.",
"notifications.agents.webpush.autoApproved": "Automatically approved a new {quality}{mediaType} request from {userName}.",
"notifications.agents.webpush.autoRequested": "Automatically submitted a new {quality}{mediaType} request.",
"notifications.agents.webpush.available": "Your {quality}{mediaType} request is now available!",
"notifications.agents.webpush.declined": "Your {quality}{mediaType} request was declined.",
"notifications.agents.webpush.failed": "Failed to process {quality}{mediaType} request.",
"notifications.agents.webpush.issueComment": "{userName} commented on the {issueType}.",
"notifications.agents.webpush.issueCreated": "A new {issueType} was reported by {userName}.",
"notifications.agents.webpush.issueReopened": "The {issueType} was reopened by {userName}.",
"notifications.agents.webpush.issueResolved": "The {issueType} was marked as resolved by {userName}!",
"notifications.agents.webpush.pending": "Approval required for a new {quality}{mediaType} request from {userName}.",
"notifications.agents.webpush.viewIssue": "View Issue",
"notifications.agents.webpush.viewMedia": "View Media",
"notifications.common.available": "Available",
"notifications.common.commentFrom": "Comment from {userName}",
"notifications.common.declined": "Declined",
"notifications.common.failed": "Failed",
"notifications.common.issue": "issue",
"notifications.common.issueStatus": "Issue Status",
"notifications.common.issueType": "Issue Type",
"notifications.common.issueTypeName": "{type} issue",
"notifications.common.movie": "movie",
"notifications.common.open": "Open",
"notifications.common.openIn": "Open in {applicationTitle}",
"notifications.common.pendingApproval": "Pending Approval",
"notifications.common.processing": "Processing",
"notifications.common.reportedBy": "Reported By",
"notifications.common.requestStatus": "Request Status",
"notifications.common.requestedBy": "Requested By",
"notifications.common.resolved": "Resolved",
"notifications.common.series": "series",
"notifications.common.viewIssue": "View Issue in {applicationTitle}",
"notifications.common.viewMedia": "View Media in {applicationTitle}",
"notifications.test.message": "Check check, 1, 2, 3. Are we coming in clear?",
"notifications.test.subject": "Test Notification"
}
+3
View File
@@ -4,6 +4,7 @@ import dataSource, { getRepository, isPgsql } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import { Session } from '@server/entity/Session';
import { User } from '@server/entity/User';
import { initI18n } from '@server/i18n';
import { startJobs } from '@server/job/schedule';
import notificationManager from '@server/lib/notifications';
import DiscordAgent from '@server/lib/notifications/agents/discord';
@@ -82,6 +83,8 @@ app
const settings = await getSettings().load();
restartFlag.initializeSettings(settings);
initI18n();
if (settings.network.forceIpv4First) {
axios.defaults.httpAgent = new http.Agent({ family: 4 });
axios.defaults.httpsAgent = new https.Agent({ family: 4 });
+31 -15
View File
@@ -1,9 +1,12 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { getIntl } from '@server/i18n';
import globalMessages from '@server/i18n/globalMessages';
import type { NotificationAgentDiscord } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { AvailableLocale } from '@server/types/languages';
import axios from 'axios';
import {
Notification,
@@ -53,7 +56,7 @@ interface Field {
}
interface DiscordRichEmbed {
title?: string;
type?: 'rich'; // Always rich for webhooks
type?: 'rich';
description?: string;
url?: string;
timestamp?: string;
@@ -107,8 +110,10 @@ class DiscordAgent
public buildEmbed(
type: Notification,
payload: NotificationPayload
payload: NotificationPayload,
locale?: AvailableLocale
): DiscordRichEmbed {
const intl = getIntl(locale);
const settings = getSettings();
const { applicationUrl } = settings.main;
const { embedPoster } = settings.notifications.agents.discord;
@@ -120,7 +125,7 @@ class DiscordAgent
if (payload.request) {
fields.push({
name: 'Requested By',
name: intl.formatMessage(globalMessages.requestedBy),
value: payload.request.requestedBy.displayName,
inline: true,
});
@@ -129,56 +134,60 @@ class DiscordAgent
switch (type) {
case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE;
status = `[Pending Approval](${appUrl}/requests)`;
status = `[${intl.formatMessage(globalMessages.pendingApproval)}](${appUrl}/requests)`;
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
color = EmbedColors.PURPLE;
status = 'Processing';
status = intl.formatMessage(globalMessages.processing);
break;
case Notification.MEDIA_AVAILABLE:
color = EmbedColors.GREEN;
status = 'Available';
status = intl.formatMessage(globalMessages.available);
break;
case Notification.MEDIA_DECLINED:
color = EmbedColors.RED;
status = 'Declined';
status = intl.formatMessage(globalMessages.declined);
break;
case Notification.MEDIA_FAILED:
color = EmbedColors.RED;
status = 'Failed';
status = intl.formatMessage(globalMessages.failed);
break;
}
if (status) {
fields.push({
name: 'Request Status',
name: intl.formatMessage(globalMessages.requestStatus),
value: status,
inline: true,
});
}
} else if (payload.comment) {
fields.push({
name: `Comment from ${payload.comment.user.displayName}`,
name: intl.formatMessage(globalMessages.commentFrom, {
userName: payload.comment.user.displayName,
}),
value: payload.comment.message,
inline: false,
});
} else if (payload.issue) {
fields.push(
{
name: 'Reported By',
name: intl.formatMessage(globalMessages.reportedBy),
value: payload.issue.createdBy.displayName,
inline: true,
},
{
name: 'Issue Type',
name: intl.formatMessage(globalMessages.issueType),
value: IssueTypeName[payload.issue.issueType],
inline: true,
},
{
name: 'Issue Status',
name: intl.formatMessage(globalMessages.issueStatus),
value:
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved',
payload.issue.status === IssueStatus.OPEN
? intl.formatMessage(globalMessages.open)
: intl.formatMessage(globalMessages.resolved),
inline: true,
}
);
@@ -299,12 +308,19 @@ class DiscordAgent
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
}
// Discord webhooks go to a channel, not per-user,
// so if use user locale is set, we'll use the locale of the user being notified
// if not, we'll use the default locale set in the notification settings
const locale = settings.options.useUserLocale
? (payload.notifyUser?.settings?.locale as AvailableLocale)
: (settings.options.locale as AvailableLocale);
await axios.post(settings.options.webhookUrl, {
username: settings.options.botUsername
? settings.options.botUsername
: getSettings().main.applicationTitle,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
embeds: [this.buildEmbed(type, payload, locale)],
content: userMentions.join(' '),
} as DiscordWebhookPayload);
+119 -38
View File
@@ -2,10 +2,13 @@ import { IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { defineMessages, getIntl } from '@server/i18n';
import globalMessages from '@server/i18n/globalMessages';
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 type { AvailableLocale } from '@server/types/languages';
import type { EmailOptions } from 'email-templates';
import path from 'path';
import validator from 'validator';
@@ -13,6 +16,46 @@ import { Notification, shouldSendAdminNotification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
const messages = defineMessages('notifications.agents.email', {
issueType: '{type} issue',
issue: 'issue',
pendingRequest:
'A new request for the following {mediaType} is pending approval:',
pendingRequest4k:
'A new request for the following {mediaType} in 4K is pending approval:',
autoRequested:
'A new request for the following {mediaType} was automatically submitted:',
autoRequested4k:
'A new request for the following {mediaType} in 4K was automatically submitted:',
approvedRequest:
'Your request for the following {mediaType} has been approved:',
approvedRequest4k:
'Your request for the following {mediaType} in 4K has been approved:',
autoApproved:
'A new request for the following {mediaType} has been automatically approved:',
autoApproved4k:
'A new request for the following {mediaType} in 4K has been automatically approved:',
availableRequest:
'Your request for the following {mediaType} is now available:',
availableRequest4k:
'Your request for the following {mediaType} in 4K is now available:',
declinedRequest: 'Your request for the following {mediaType} was declined:',
declinedRequest4k:
'Your request for the following {mediaType} in 4K was declined:',
failedRequest:
'A request for the following {mediaType} failed to be added to {service}:',
failedRequest4k:
'A request for the following {mediaType} in 4K failed to be added to {service}:',
issueCreated:
'A new {issueType} has been reported by {userName} for the {mediaType} {subject}:',
issueComment:
'{userName} commented on the {issueType} for the {mediaType} {subject}:',
issueResolved:
'The {issueType} for the {mediaType} {subject} was marked as resolved by {userName}!',
issueReopened:
'The {issueType} for the {mediaType} {subject} was reopened by {userName}.',
});
class EmailAgent
extends BaseAgent<NotificationAgentEmail>
implements NotificationAgent
@@ -46,8 +89,10 @@ class EmailAgent
type: Notification,
payload: NotificationPayload,
recipientEmail: string,
recipientName?: string
recipientName?: string,
locale?: AvailableLocale
): EmailOptions | undefined {
const intl = getIntl(locale);
const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.email;
@@ -70,8 +115,8 @@ class EmailAgent
const mediaType = payload.media
? payload.media.mediaType === MediaType.MOVIE
? 'movie'
: 'series'
? intl.formatMessage(globalMessages.movie)
: intl.formatMessage(globalMessages.series)
: undefined;
const is4k = payload.request?.is4k;
@@ -80,41 +125,52 @@ class EmailAgent
switch (type) {
case Notification.MEDIA_PENDING:
body = `A new request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}is pending approval:`;
body = intl.formatMessage(
is4k ? messages.pendingRequest4k : messages.pendingRequest,
{ mediaType }
);
break;
case Notification.MEDIA_AUTO_REQUESTED:
body = `A new request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}was automatically submitted:`;
body = intl.formatMessage(
is4k ? messages.autoRequested4k : messages.autoRequested,
{ mediaType }
);
break;
case Notification.MEDIA_APPROVED:
body = `Your request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}has been approved:`;
body = intl.formatMessage(
is4k ? messages.approvedRequest4k : messages.approvedRequest,
{ mediaType }
);
break;
case Notification.MEDIA_AUTO_APPROVED:
body = `A new request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}has been automatically approved:`;
body = intl.formatMessage(
is4k ? messages.autoApproved4k : messages.autoApproved,
{ mediaType }
);
break;
case Notification.MEDIA_AVAILABLE:
body = `Your request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}is now available:`;
body = intl.formatMessage(
is4k ? messages.availableRequest4k : messages.availableRequest,
{ mediaType }
);
break;
case Notification.MEDIA_DECLINED:
body = `Your request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}was declined:`;
body = intl.formatMessage(
is4k ? messages.declinedRequest4k : messages.declinedRequest,
{ mediaType }
);
break;
case Notification.MEDIA_FAILED:
body = `A request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}failed to be added to ${
payload.media?.mediaType === MediaType.MOVIE ? 'Radarr' : 'Sonarr'
}:`;
body = intl.formatMessage(
is4k ? messages.failedRequest4k : messages.failedRequest,
{
mediaType,
service:
payload.media?.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
}
);
break;
}
@@ -146,23 +202,45 @@ class EmailAgent
} else if (payload.issue) {
const issueType =
payload.issue && payload.issue.issueType !== IssueType.OTHER
? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue`
: 'issue';
? intl.formatMessage(messages.issueType, {
type: IssueTypeName[payload.issue.issueType].toLowerCase(),
})
: intl.formatMessage(messages.issue);
let body = '';
switch (type) {
case Notification.ISSUE_CREATED:
body = `A new ${issueType} has been reported by ${payload.issue.createdBy.displayName} for the ${mediaType} ${payload.subject}:`;
body = intl.formatMessage(messages.issueCreated, {
issueType,
userName: payload.issue.createdBy.displayName,
mediaType,
subject: payload.subject,
});
break;
case Notification.ISSUE_COMMENT:
body = `${payload.comment?.user.displayName} commented on the ${issueType} for the ${mediaType} ${payload.subject}:`;
body = intl.formatMessage(messages.issueComment, {
userName: payload.comment?.user.displayName,
issueType,
mediaType,
subject: payload.subject,
});
break;
case Notification.ISSUE_RESOLVED:
body = `The ${issueType} for the ${mediaType} ${payload.subject} was marked as resolved by ${payload.issue.modifiedBy?.displayName}!`;
body = intl.formatMessage(messages.issueResolved, {
issueType,
userName: payload.issue.modifiedBy?.displayName,
mediaType,
subject: payload.subject,
});
break;
case Notification.ISSUE_REOPENED:
body = `The ${issueType} for the ${mediaType} ${payload.subject} was reopened by ${payload.issue.modifiedBy?.displayName}.`;
body = intl.formatMessage(messages.issueReopened, {
issueType,
userName: payload.issue.modifiedBy?.displayName,
mediaType,
subject: payload.subject,
});
break;
}
@@ -201,8 +279,6 @@ class EmailAgent
if (payload.notifyUser) {
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
@@ -229,7 +305,8 @@ class EmailAgent
type,
payload,
payload.notifyUser.email,
payload.notifyUser.displayName
payload.notifyUser.displayName,
payload.notifyUser.settings?.locale as AvailableLocale
)
);
} else {
@@ -263,8 +340,6 @@ class EmailAgent
.filter(
(user) =>
(!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
@@ -287,7 +362,13 @@ class EmailAgent
);
if (validator.isEmail(user.email, { require_tld: false })) {
await email.send(
this.buildMessage(type, payload, user.email, user.displayName)
this.buildMessage(
type,
payload,
user.email,
user.displayName,
user.settings?.locale as AvailableLocale
)
);
} else {
logger.warn('Invalid email address provided for user', {
+19 -16
View File
@@ -1,4 +1,6 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { getIntl } from '@server/i18n';
import globalMessages from '@server/i18n/globalMessages';
import type { NotificationAgentGotify } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -47,8 +49,9 @@ class GotifyAgent
type: Notification,
payload: NotificationPayload
): GotifyPayload {
const { applicationUrl, applicationTitle } = getSettings().main;
const settings = this.getSettings();
const intl = getIntl(settings.options.locale);
const { applicationUrl, applicationTitle } = getSettings().main;
const priority = settings.options.priority ?? 1;
const title = payload.event
@@ -58,40 +61,40 @@ class GotifyAgent
let message = payload.message ? `${payload.message} \n\n` : '';
if (payload.request) {
message += `\n**Requested By:** ${payload.request.requestedBy.displayName} `;
message += `\n**${intl.formatMessage(globalMessages.requestedBy)}:** ${payload.request.requestedBy.displayName} `;
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
status = intl.formatMessage(globalMessages.pendingApproval);
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
status = 'Processing';
status = intl.formatMessage(globalMessages.processing);
break;
case Notification.MEDIA_AVAILABLE:
status = 'Available';
status = intl.formatMessage(globalMessages.available);
break;
case Notification.MEDIA_DECLINED:
status = 'Declined';
status = intl.formatMessage(globalMessages.declined);
break;
case Notification.MEDIA_FAILED:
status = 'Failed';
status = intl.formatMessage(globalMessages.failed);
break;
}
if (status) {
message += `\n**Request Status:** ${status} `;
message += `\n**${intl.formatMessage(globalMessages.requestStatus)}:** ${status} `;
}
} else if (payload.comment) {
message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message} `;
message += `\n${intl.formatMessage(globalMessages.commentFrom, { userName: payload.comment.user.displayName })}:\n${payload.comment.message} `;
} else if (payload.issue) {
message += `\n\n**Reported By:** ${payload.issue.createdBy.displayName} `;
message += `\n**Issue Type:** ${
IssueTypeName[payload.issue.issueType]
} `;
message += `\n**Issue Status:** ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
message += `\n\n**${intl.formatMessage(globalMessages.reportedBy)}:** ${payload.issue.createdBy.displayName} `;
message += `\n**${intl.formatMessage(globalMessages.issueType)}:** ${IssueTypeName[payload.issue.issueType]} `;
message += `\n**${intl.formatMessage(globalMessages.issueStatus)}:** ${
payload.issue.status === IssueStatus.OPEN
? intl.formatMessage(globalMessages.open)
: intl.formatMessage(globalMessages.resolved)
} `;
}
@@ -103,7 +106,7 @@ class GotifyAgent
const actionUrl = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
const displayUrl =
actionUrl.length > 40 ? `${actionUrl.slice(0, 41)}...` : actionUrl;
message += `\n\n**Open in ${applicationTitle}:** [${displayUrl}](${actionUrl}) `;
message += `\n\n**${intl.formatMessage(globalMessages.openIn, { applicationTitle })}:** [${displayUrl}](${actionUrl}) `;
}
return {
+27 -27
View File
@@ -1,7 +1,10 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { getIntl } from '@server/i18n';
import globalMessages from '@server/i18n/globalMessages';
import type { NotificationAgentNtfy } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { AvailableLocale } from '@server/types/languages';
import axios from 'axios';
import { Notification, hasNotificationType } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
@@ -26,12 +29,13 @@ class NtfyAgent
}
private buildPayload(type: Notification, payload: NotificationPayload) {
const settings = getSettings();
const { applicationUrl } = settings.main;
const { embedPoster } = settings.notifications.agents.ntfy;
const settings = this.getSettings();
const intl = getIntl(settings.options.locale as AvailableLocale);
const { applicationUrl } = getSettings().main;
const embedPoster = settings.embedPoster;
const topic = this.getSettings().options.topic;
const priority = this.getSettings().options.priority ?? 3;
const topic = settings.options.topic;
const priority = settings.options.priority ?? 3;
const title = payload.event
? `${payload.event} - ${payload.subject}`
@@ -39,48 +43,44 @@ class NtfyAgent
let message = payload.message ?? '';
if (payload.request) {
if (message) {
message = `**Description:**\n${message}`;
}
message += `${message ? '\n\n' : ''}**Requested By:** ${this.escapeMarkdown(payload.request.requestedBy.displayName)}`;
message += `${message ? '\n\n' : ''}**${intl.formatMessage(globalMessages.requestedBy)}:** ${this.escapeMarkdown(payload.request.requestedBy.displayName)}`;
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
status = intl.formatMessage(globalMessages.pendingApproval);
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
status = 'Processing';
status = intl.formatMessage(globalMessages.processing);
break;
case Notification.MEDIA_AVAILABLE:
status = 'Available';
status = intl.formatMessage(globalMessages.available);
break;
case Notification.MEDIA_DECLINED:
status = 'Declined';
status = intl.formatMessage(globalMessages.declined);
break;
case Notification.MEDIA_FAILED:
status = 'Failed';
status = intl.formatMessage(globalMessages.failed);
break;
}
if (status) {
message += `\n**Request Status:** ${status}`;
message += `\n**${intl.formatMessage(globalMessages.requestStatus)}:** ${status}`;
}
} else if (payload.comment) {
if (message) {
message = `**Description:**\n${message}\n\n`;
}
message += `**Comment:**\n${payload.comment.message}`;
message += `\n\n**Comment from:** ${this.escapeMarkdown(payload.comment.user.displayName)}`;
message += `\n**${this.escapeMarkdown(
intl.formatMessage(globalMessages.commentFrom, {
userName: payload.comment.user.displayName,
})
)}:**\n${payload.comment.message}`;
} else if (payload.issue) {
if (message) {
message = `**Description:**\n${message}`;
}
message += `${message ? '\n\n' : ''}**Reported By:** ${this.escapeMarkdown(payload.issue.createdBy.displayName)}`;
message += `\n**Issue Type:** ${IssueTypeName[payload.issue.issueType]}`;
message += `\n**Issue Status:** ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
message += `\n\n**${intl.formatMessage(globalMessages.reportedBy)}:** ${this.escapeMarkdown(payload.issue.createdBy.displayName)}`;
message += `\n**${intl.formatMessage(globalMessages.issueType)}:** ${IssueTypeName[payload.issue.issueType]}`;
message += `\n**${intl.formatMessage(globalMessages.issueStatus)}:** ${
payload.issue.status === IssueStatus.OPEN
? intl.formatMessage(globalMessages.open)
: intl.formatMessage(globalMessages.resolved)
}`;
}
+36 -16
View File
@@ -2,9 +2,12 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { getIntl } from '@server/i18n';
import globalMessages from '@server/i18n/globalMessages';
import type { NotificationAgentPushbullet } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { AvailableLocale } from '@server/types/languages';
import axios from 'axios';
import {
Notification,
@@ -41,52 +44,56 @@ class PushbulletAgent
private getNotificationPayload(
type: Notification,
payload: NotificationPayload
payload: NotificationPayload,
locale?: AvailableLocale
): PushbulletPayload {
const intl = getIntl(locale);
const title = payload.event
? `${payload.event} - ${payload.subject}`
: payload.subject;
let body = payload.message ?? '';
if (payload.request) {
body += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
body += `\n\n${intl.formatMessage(globalMessages.requestedBy)}: ${payload.request.requestedBy.displayName}`;
let status = '';
switch (type) {
case Notification.MEDIA_AUTO_REQUESTED:
status =
payload.media?.status === MediaStatus.PENDING
? 'Pending Approval'
: 'Processing';
? intl.formatMessage(globalMessages.pendingApproval)
: intl.formatMessage(globalMessages.processing);
break;
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
status = intl.formatMessage(globalMessages.pendingApproval);
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
status = 'Processing';
status = intl.formatMessage(globalMessages.processing);
break;
case Notification.MEDIA_AVAILABLE:
status = 'Available';
status = intl.formatMessage(globalMessages.available);
break;
case Notification.MEDIA_DECLINED:
status = 'Declined';
status = intl.formatMessage(globalMessages.declined);
break;
case Notification.MEDIA_FAILED:
status = 'Failed';
status = intl.formatMessage(globalMessages.failed);
break;
}
if (status) {
body += `\nRequest Status: ${status}`;
body += `\n${intl.formatMessage(globalMessages.requestStatus)}: ${status}`;
}
} else if (payload.comment) {
body += `\n\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
body += `\n\n${intl.formatMessage(globalMessages.commentFrom, { userName: payload.comment.user.displayName })}:\n${payload.comment.message}`;
} else if (payload.issue) {
body += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
body += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
body += `\nIssue Status: ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
body += `\n\n${intl.formatMessage(globalMessages.reportedBy)}: ${payload.issue.createdBy.displayName}`;
body += `\n${intl.formatMessage(globalMessages.issueType)}: ${IssueTypeName[payload.issue.issueType]}`;
body += `\n${intl.formatMessage(globalMessages.issueStatus)}: ${
payload.issue.status === IssueStatus.OPEN
? intl.formatMessage(globalMessages.open)
: intl.formatMessage(globalMessages.resolved)
}`;
}
@@ -107,7 +114,6 @@ class PushbulletAgent
): Promise<boolean> {
const settings = this.getSettings();
const endpoint = 'https://api.pushbullet.com/v2/pushes';
const notificationPayload = this.getNotificationPayload(type, payload);
// Send system notification
if (
@@ -123,6 +129,8 @@ class PushbulletAgent
});
try {
const notificationPayload = this.getNotificationPayload(type, payload);
await axios.post(
endpoint,
{ ...notificationPayload, channel_tag: settings.options.channelTag },
@@ -163,6 +171,12 @@ class PushbulletAgent
});
try {
const notificationPayload = this.getNotificationPayload(
type,
payload,
payload.notifyUser.settings?.locale as AvailableLocale
);
await axios.post(endpoint, notificationPayload, {
headers: {
'Access-Token': payload.notifyUser.settings.pushbulletAccessToken,
@@ -211,6 +225,12 @@ class PushbulletAgent
});
try {
const notificationPayload = this.getNotificationPayload(
type,
payload,
user.settings?.locale as AvailableLocale
);
await axios.post(endpoint, notificationPayload, {
headers: {
'Access-Token': user.settings.pushbulletAccessToken,
+43 -22
View File
@@ -2,9 +2,12 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { getIntl } from '@server/i18n';
import globalMessages from '@server/i18n/globalMessages';
import type { NotificationAgentPushover } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { AvailableLocale } from '@server/types/languages';
import axios from 'axios';
import {
Notification,
@@ -86,8 +89,10 @@ class PushoverAgent
private async getNotificationPayload(
type: Notification,
payload: NotificationPayload
payload: NotificationPayload,
locale?: AvailableLocale
): Promise<Partial<PushoverPayload>> {
const intl = getIntl(locale);
const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.pushover;
@@ -101,48 +106,48 @@ class PushoverAgent
}
if (payload.request) {
message += `<small>\n\n<b>Requested By:</b> ${payload.request.requestedBy.displayName}</small>`;
message += `<small>\n\n<b>${intl.formatMessage(globalMessages.requestedBy)}:</b> ${payload.request.requestedBy.displayName}</small>`;
let status = '';
switch (type) {
case Notification.MEDIA_AUTO_REQUESTED:
status =
payload.media?.status === MediaStatus.PENDING
? 'Pending Approval'
: 'Processing';
? intl.formatMessage(globalMessages.pendingApproval)
: intl.formatMessage(globalMessages.processing);
break;
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
status = intl.formatMessage(globalMessages.pendingApproval);
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
status = 'Processing';
status = intl.formatMessage(globalMessages.processing);
break;
case Notification.MEDIA_AVAILABLE:
status = 'Available';
status = intl.formatMessage(globalMessages.available);
break;
case Notification.MEDIA_DECLINED:
status = 'Declined';
status = intl.formatMessage(globalMessages.declined);
priority = 1;
break;
case Notification.MEDIA_FAILED:
status = 'Failed';
status = intl.formatMessage(globalMessages.failed);
priority = 1;
break;
}
if (status) {
message += `<small>\n<b>Request Status:</b> ${status}</small>`;
message += `<small>\n<b>${intl.formatMessage(globalMessages.requestStatus)}:</b> ${status}</small>`;
}
} else if (payload.comment) {
message += `<small>\n\n<b>Comment from ${payload.comment.user.displayName}:</b> ${payload.comment.message}</small>`;
message += `<small>\n\n<b>${intl.formatMessage(globalMessages.commentFrom, { userName: payload.comment.user.displayName })}:</b> ${payload.comment.message}</small>`;
} else if (payload.issue) {
message += `<small>\n\n<b>Reported By:</b> ${payload.issue.createdBy.displayName}</small>`;
message += `<small>\n<b>Issue Type:</b> ${
IssueTypeName[payload.issue.issueType]
}</small>`;
message += `<small>\n<b>Issue Status:</b> ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
message += `<small>\n\n<b>${intl.formatMessage(globalMessages.reportedBy)}:</b> ${payload.issue.createdBy.displayName}</small>`;
message += `<small>\n<b>${intl.formatMessage(globalMessages.issueType)}:</b> ${IssueTypeName[payload.issue.issueType]}</small>`;
message += `<small>\n<b>${intl.formatMessage(globalMessages.issueStatus)}:</b> ${
payload.issue.status === IssueStatus.OPEN
? intl.formatMessage(globalMessages.open)
: intl.formatMessage(globalMessages.resolved)
}</small>`;
if (type === Notification.ISSUE_CREATED) {
@@ -162,7 +167,10 @@ class PushoverAgent
: undefined
: undefined;
const url_title = url
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
? intl.formatMessage(
payload.issue ? globalMessages.viewIssue : globalMessages.viewMedia,
{ applicationTitle }
)
: undefined;
let attachment_base64;
@@ -193,10 +201,6 @@ class PushoverAgent
): Promise<boolean> {
const settings = this.getSettings();
const endpoint = 'https://api.pushover.net/1/messages.json';
const notificationPayload = await this.getNotificationPayload(
type,
payload
);
// Send system notification
if (
@@ -213,6 +217,11 @@ class PushoverAgent
});
try {
const notificationPayload = await this.getNotificationPayload(
type,
payload
);
await axios.post(endpoint, {
...notificationPayload,
token: settings.options.accessToken,
@@ -253,6 +262,12 @@ class PushoverAgent
});
try {
const notificationPayload = await this.getNotificationPayload(
type,
payload,
payload.notifyUser.settings?.locale as AvailableLocale
);
await axios.post(endpoint, {
...notificationPayload,
token: payload.notifyUser.settings.pushoverApplicationToken,
@@ -303,6 +318,12 @@ class PushoverAgent
});
try {
const notificationPayload = await this.getNotificationPayload(
type,
payload,
user.settings?.locale as AvailableLocale
);
await axios.post(endpoint, {
...notificationPayload,
token: user.settings.pushoverApplicationToken,
+26 -18
View File
@@ -1,4 +1,6 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { getIntl } from '@server/i18n';
import globalMessages from '@server/i18n/globalMessages';
import type { NotificationAgentSlack } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -63,63 +65,66 @@ class SlackAgent
type: Notification,
payload: NotificationPayload
): SlackBlockEmbed {
const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.slack;
const settings = this.getSettings();
const intl = getIntl(settings.options.locale);
const { applicationUrl, applicationTitle } = getSettings().main;
const embedPoster = settings.embedPoster;
const fields: EmbedField[] = [];
if (payload.request) {
fields.push({
type: 'mrkdwn',
text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
text: `*${intl.formatMessage(globalMessages.requestedBy)}*\n${payload.request.requestedBy.displayName}`,
});
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
status = intl.formatMessage(globalMessages.pendingApproval);
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
status = 'Processing';
status = intl.formatMessage(globalMessages.processing);
break;
case Notification.MEDIA_AVAILABLE:
status = 'Available';
status = intl.formatMessage(globalMessages.available);
break;
case Notification.MEDIA_DECLINED:
status = 'Declined';
status = intl.formatMessage(globalMessages.declined);
break;
case Notification.MEDIA_FAILED:
status = 'Failed';
status = intl.formatMessage(globalMessages.failed);
break;
}
if (status) {
fields.push({
type: 'mrkdwn',
text: `*Request Status*\n${status}`,
text: `*${intl.formatMessage(globalMessages.requestStatus)}*\n${status}`,
});
}
} else if (payload.comment) {
fields.push({
type: 'mrkdwn',
text: `*Comment from ${payload.comment.user.displayName}*\n${payload.comment.message}`,
text: `*${intl.formatMessage(globalMessages.commentFrom, { userName: payload.comment.user.displayName })}*\n${payload.comment.message}`,
});
} else if (payload.issue) {
fields.push(
{
type: 'mrkdwn',
text: `*Reported By*\n${payload.issue.createdBy.displayName}`,
text: `*${intl.formatMessage(globalMessages.reportedBy)}*\n${payload.issue.createdBy.displayName}`,
},
{
type: 'mrkdwn',
text: `*Issue Type*\n${IssueTypeName[payload.issue.issueType]}`,
text: `*${intl.formatMessage(globalMessages.issueType)}*\n${IssueTypeName[payload.issue.issueType]}`,
},
{
type: 'mrkdwn',
text: `*Issue Status*\n${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
text: `*${intl.formatMessage(globalMessages.issueStatus)}*\n${
payload.issue.status === IssueStatus.OPEN
? intl.formatMessage(globalMessages.open)
: intl.formatMessage(globalMessages.resolved)
}`,
}
);
@@ -197,9 +202,12 @@ class SlackAgent
url,
text: {
type: 'plain_text',
text: `View ${
payload.issue ? 'Issue' : 'Media'
} in ${applicationTitle}`,
text: intl.formatMessage(
payload.issue
? globalMessages.viewIssue
: globalMessages.viewMedia,
{ applicationTitle }
),
},
},
],
+55 -24
View File
@@ -2,9 +2,12 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { getIntl } from '@server/i18n';
import globalMessages from '@server/i18n/globalMessages';
import type { NotificationAgentTelegram } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { AvailableLocale } from '@server/types/languages';
import axios from 'axios';
import {
Notification,
@@ -63,8 +66,10 @@ class TelegramAgent
private getNotificationPayload(
type: Notification,
payload: NotificationPayload
payload: NotificationPayload,
locale?: AvailableLocale
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
const intl = getIntl(locale);
const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.telegram;
@@ -78,51 +83,61 @@ class TelegramAgent
}
if (payload.request) {
message += `\n\n\*Requested By:\* ${this.escapeText(
payload.request?.requestedBy.displayName
)}`;
message += `\n\n\*${this.escapeText(
intl.formatMessage(globalMessages.requestedBy)
)}:\* ${this.escapeText(payload.request?.requestedBy.displayName)}`;
let status = '';
switch (type) {
case Notification.MEDIA_AUTO_REQUESTED:
status =
payload.media?.status === MediaStatus.PENDING
? 'Pending Approval'
: 'Processing';
? intl.formatMessage(globalMessages.pendingApproval)
: intl.formatMessage(globalMessages.processing);
break;
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
status = intl.formatMessage(globalMessages.pendingApproval);
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
status = 'Processing';
status = intl.formatMessage(globalMessages.processing);
break;
case Notification.MEDIA_AVAILABLE:
status = 'Available';
status = intl.formatMessage(globalMessages.available);
break;
case Notification.MEDIA_DECLINED:
status = 'Declined';
status = intl.formatMessage(globalMessages.declined);
break;
case Notification.MEDIA_FAILED:
status = 'Failed';
status = intl.formatMessage(globalMessages.failed);
break;
}
if (status) {
message += `\n\*Request Status:\* ${status}`;
message += `\n\*${this.escapeText(
intl.formatMessage(globalMessages.requestStatus)
)}:\* ${this.escapeText(status)}`;
}
} else if (payload.comment) {
message += `\n\n\*Comment from ${this.escapeText(
payload.comment.user.displayName
message += `\n\n\*${this.escapeText(
intl.formatMessage(globalMessages.commentFrom, {
userName: payload.comment.user.displayName,
})
)}:\* ${this.escapeText(payload.comment.message)}`;
} else if (payload.issue) {
message += `\n\n\*Reported By:\* ${this.escapeText(
payload.issue.createdBy.displayName
message += `\n\n\*${this.escapeText(
intl.formatMessage(globalMessages.reportedBy)
)}:\* ${this.escapeText(payload.issue.createdBy.displayName)}`;
message += `\n\*${this.escapeText(
intl.formatMessage(globalMessages.issueType)
)}:\* ${this.escapeText(IssueTypeName[payload.issue.issueType])}`;
message += `\n\*${this.escapeText(
intl.formatMessage(globalMessages.issueStatus)
)}:\* ${this.escapeText(
payload.issue.status === IssueStatus.OPEN
? intl.formatMessage(globalMessages.open)
: intl.formatMessage(globalMessages.resolved)
)}`;
message += `\n\*Issue Type:\* ${IssueTypeName[payload.issue.issueType]}`;
message += `\n\*Issue Status:\* ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
}`;
}
for (const extra of payload.extra ?? []) {
@@ -138,9 +153,12 @@ class TelegramAgent
: undefined;
if (url) {
message += `\n\n\[View ${
payload.issue ? 'Issue' : 'Media'
} in ${this.escapeText(applicationTitle)}\]\(${url}\)`;
message += `\n\n\[${this.escapeText(
intl.formatMessage(
payload.issue ? globalMessages.viewIssue : globalMessages.viewMedia,
{ applicationTitle }
)
)}\]\(${url}\)`;
}
/* eslint-enable */
@@ -164,7 +182,6 @@ class TelegramAgent
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
settings.embedPoster && payload.image ? 'sendPhoto' : 'sendMessage'
}`;
const notificationPayload = this.getNotificationPayload(type, payload);
// Send system notification
if (
@@ -179,6 +196,8 @@ class TelegramAgent
});
try {
const notificationPayload = this.getNotificationPayload(type, payload);
await axios.post(endpoint, {
...notificationPayload,
chat_id: settings.options.chatId,
@@ -215,6 +234,12 @@ class TelegramAgent
});
try {
const notificationPayload = this.getNotificationPayload(
type,
payload,
payload.notifyUser.settings?.locale as AvailableLocale
);
await axios.post(endpoint, {
...notificationPayload,
chat_id: payload.notifyUser.settings.telegramChatId,
@@ -264,6 +289,12 @@ class TelegramAgent
});
try {
const notificationPayload = this.getNotificationPayload(
type,
payload,
user.settings?.locale as AvailableLocale
);
await axios.post(endpoint, {
...notificationPayload,
chat_id: user.settings.telegramChatId,
+120 -56
View File
@@ -4,14 +4,35 @@ import { getRepository } from '@server/datasource';
import MediaRequest from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
import { defineMessages, getIntl } from '@server/i18n';
import globalMessages from '@server/i18n/globalMessages';
import type { NotificationAgentConfig } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { AvailableLocale } from '@server/types/languages';
import webpush from 'web-push';
import { Notification, shouldSendAdminNotification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
const messages = defineMessages('notifications.agents.webpush', {
autoRequested: 'Automatically submitted a new {quality}{mediaType} request.',
approved: 'Your {quality}{mediaType} request has been approved.',
autoApproved:
'Automatically approved a new {quality}{mediaType} request from {userName}.',
available: 'Your {quality}{mediaType} request is now available!',
declined: 'Your {quality}{mediaType} request was declined.',
failed: 'Failed to process {quality}{mediaType} request.',
pending:
'Approval required for a new {quality}{mediaType} request from {userName}.',
issueCreated: 'A new {issueType} was reported by {userName}.',
issueComment: '{userName} commented on the {issueType}.',
issueResolved: 'The {issueType} was marked as resolved by {userName}!',
issueReopened: 'The {issueType} was reopened by {userName}.',
viewIssue: 'View Issue',
viewMedia: 'View Media',
});
interface PushNotificationPayload {
notificationType: string;
subject: string;
@@ -49,21 +70,26 @@ class WebPushAgent
private getNotificationPayload(
type: Notification,
payload: NotificationPayload
payload: NotificationPayload,
locale?: AvailableLocale
): PushNotificationPayload {
const intl = getIntl(locale);
const { embedPoster } = getSettings().notifications.agents.webpush;
const mediaType = payload.media
? payload.media.mediaType === MediaType.MOVIE
? 'movie'
: 'series'
? intl.formatMessage(globalMessages.movie)
: intl.formatMessage(globalMessages.series)
: undefined;
const is4k = payload.request?.is4k;
const quality = is4k ? '4K ' : '';
const issueType = payload.issue
? payload.issue.issueType !== IssueType.OTHER
? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue`
: 'issue'
? intl.formatMessage(globalMessages.issueTypeName, {
type: IssueTypeName[payload.issue.issueType].toLowerCase(),
})
: intl.formatMessage(globalMessages.issue)
: undefined;
let message: string | undefined;
@@ -72,51 +98,72 @@ class WebPushAgent
message = payload.message;
break;
case Notification.MEDIA_AUTO_REQUESTED:
message = `Automatically submitted a new ${
is4k ? '4K ' : ''
}${mediaType} request.`;
message = intl.formatMessage(messages.autoRequested, {
quality,
mediaType,
});
break;
case Notification.MEDIA_APPROVED:
message = `Your ${
is4k ? '4K ' : ''
}${mediaType} request has been approved.`;
message = intl.formatMessage(messages.approved, {
quality,
mediaType,
});
break;
case Notification.MEDIA_AUTO_APPROVED:
message = `Automatically approved a new ${
is4k ? '4K ' : ''
}${mediaType} request from ${
payload.request?.requestedBy.displayName
}.`;
message = intl.formatMessage(messages.autoApproved, {
quality,
mediaType,
userName: payload.request?.requestedBy.displayName,
});
break;
case Notification.MEDIA_AVAILABLE:
message = `Your ${
is4k ? '4K ' : ''
}${mediaType} request is now available!`;
message = intl.formatMessage(messages.available, {
quality,
mediaType,
});
break;
case Notification.MEDIA_DECLINED:
message = `Your ${is4k ? '4K ' : ''}${mediaType} request was declined.`;
message = intl.formatMessage(messages.declined, {
quality,
mediaType,
});
break;
case Notification.MEDIA_FAILED:
message = `Failed to process ${is4k ? '4K ' : ''}${mediaType} request.`;
message = intl.formatMessage(messages.failed, {
quality,
mediaType,
});
break;
case Notification.MEDIA_PENDING:
message = `Approval required for a new ${
is4k ? '4K ' : ''
}${mediaType} request from ${
payload.request?.requestedBy.displayName
}.`;
message = intl.formatMessage(messages.pending, {
quality,
mediaType,
userName: payload.request?.requestedBy.displayName,
});
break;
case Notification.ISSUE_CREATED:
message = `A new ${issueType} was reported by ${payload.issue?.createdBy.displayName}.`;
message = intl.formatMessage(messages.issueCreated, {
issueType,
userName: payload.issue?.createdBy.displayName,
});
break;
case Notification.ISSUE_COMMENT:
message = `${payload.comment?.user.displayName} commented on the ${issueType}.`;
message = intl.formatMessage(messages.issueComment, {
userName: payload.comment?.user.displayName,
issueType,
});
break;
case Notification.ISSUE_RESOLVED:
message = `The ${issueType} was marked as resolved by ${payload.issue?.modifiedBy?.displayName}!`;
message = intl.formatMessage(messages.issueResolved, {
issueType,
userName: payload.issue?.modifiedBy?.displayName,
});
break;
case Notification.ISSUE_REOPENED:
message = `The ${issueType} was reopened by ${payload.issue?.modifiedBy?.displayName}.`;
message = intl.formatMessage(messages.issueReopened, {
issueType,
userName: payload.issue?.modifiedBy?.displayName,
});
break;
default:
return {
@@ -132,7 +179,9 @@ class WebPushAgent
: undefined;
const actionUrlTitle = actionUrl
? `View ${payload.issue ? 'Issue' : 'Media'}`
? intl.formatMessage(
payload.issue ? messages.viewIssue : messages.viewMedia
)
: undefined;
return {
@@ -164,7 +213,8 @@ class WebPushAgent
const userPushSubRepository = getRepository(UserPushSubscription);
const settings = getSettings();
const pushSubs: UserPushSubscription[] = [];
const pushSubs: { sub: UserPushSubscription; locale?: AvailableLocale }[] =
[];
const mainUser = await userRepository.findOne({ where: { id: 1 } });
@@ -238,7 +288,12 @@ class WebPushAgent
where: { user: { id: payload.notifyUser.id } },
});
pushSubs.push(...notifySubs);
pushSubs.push(
...notifySubs.map((sub) => ({
sub,
locale: payload.notifyUser?.settings?.locale as AvailableLocale,
}))
);
}
if (
@@ -265,6 +320,7 @@ class WebPushAgent
? await userPushSubRepository
.createQueryBuilder('pushSub')
.leftJoinAndSelect('pushSub.user', 'user')
.leftJoinAndSelect('user.settings', 'settings')
.where('pushSub.userId IN (:...users)', {
users: manageUsers.map((user) => user.id),
})
@@ -284,28 +340,37 @@ class WebPushAgent
settings.vapidPrivate
);
// Custom payload only for updating the app badge
const notificationBadgePayload = Buffer.from(
JSON.stringify(
this.getNotificationPayload(type, {
subject: payload.subject,
notifySystem: false,
notifyAdmin: true,
isAdmin: true,
pendingRequestsCount: pendingRequests.length,
})
),
'utf-8'
);
await Promise.all(
allSubs.map(async (sub) => {
webPushNotification(sub, notificationBadgePayload);
const locale = sub.user?.settings?.locale as AvailableLocale;
// Custom payload only for updating the app badge
const notificationBadgePayload = Buffer.from(
JSON.stringify(
this.getNotificationPayload(
type,
{
subject: payload.subject,
notifySystem: false,
notifyAdmin: true,
isAdmin: true,
pendingRequestsCount: pendingRequests.length,
},
locale
)
),
'utf-8'
);
await webPushNotification(sub, notificationBadgePayload);
})
);
}
} else {
pushSubs.push(...allSubs);
pushSubs.push(
...allSubs.map((sub) => ({
sub,
locale: sub.user?.settings?.locale as AvailableLocale,
}))
);
}
}
@@ -320,14 +385,13 @@ class WebPushAgent
payload = { ...payload, pendingRequestsCount: pendingRequests.length };
}
const notificationPayload = Buffer.from(
JSON.stringify(this.getNotificationPayload(type, payload)),
'utf-8'
);
await Promise.all(
pushSubs.map(async (sub) => {
webPushNotification(sub, notificationPayload);
pushSubs.map(async ({ sub, locale }) => {
const notificationPayload = Buffer.from(
JSON.stringify(this.getNotificationPayload(type, payload, locale)),
'utf-8'
);
await webPushNotification(sub, notificationPayload);
})
);
}
+11
View File
@@ -1,6 +1,7 @@
import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
import type { AvailableLocale } from '@server/types/languages';
import { randomBytes, randomUUID } from 'crypto';
import fs from 'fs/promises';
import { mergeWith } from 'lodash';
@@ -229,12 +230,15 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
webhookUrl: string;
webhookRoleId?: string;
enableMentions: boolean;
locale: AvailableLocale;
useUserLocale: boolean;
};
}
export interface NotificationAgentSlack extends NotificationAgentConfig {
options: {
webhookUrl: string;
locale: AvailableLocale;
};
}
@@ -296,6 +300,7 @@ export interface NotificationAgentGotify extends NotificationAgentConfig {
url: string;
token: string;
priority: number;
locale: AvailableLocale;
};
}
@@ -309,6 +314,7 @@ export interface NotificationAgentNtfy extends NotificationAgentConfig {
authMethodToken?: boolean;
token?: string;
priority?: number;
locale: AvailableLocale;
};
}
@@ -476,6 +482,8 @@ class Settings {
webhookUrl: '',
webhookRoleId: '',
enableMentions: true,
locale: 'en',
useUserLocale: true,
},
},
slack: {
@@ -484,6 +492,7 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
locale: 'en',
},
},
telegram: {
@@ -538,6 +547,7 @@ class Settings {
url: '',
token: '',
priority: 0,
locale: 'en',
},
},
ntfy: {
@@ -548,6 +558,7 @@ class Settings {
url: '',
topic: '',
priority: 3,
locale: 'en',
},
},
},
+14 -4
View File
@@ -1,4 +1,5 @@
import type { User } from '@server/entity/User';
import { defineMessages, getIntl } from '@server/i18n';
import { Notification } from '@server/lib/notifications';
import type { NotificationAgent } from '@server/lib/notifications/agents/agent';
import DiscordAgent from '@server/lib/notifications/agents/discord';
@@ -12,18 +13,27 @@ import TelegramAgent from '@server/lib/notifications/agents/telegram';
import WebhookAgent from '@server/lib/notifications/agents/webhook';
import WebPushAgent from '@server/lib/notifications/agents/webpush';
import { getSettings } from '@server/lib/settings';
import type { AvailableLocale } from '@server/types/languages';
import { Router } from 'express';
const notificationRoutes = Router();
const sendTestNotification = async (agent: NotificationAgent, user: User) =>
await agent.send(Notification.TEST_NOTIFICATION, {
const messages = defineMessages('notifications.test', {
subject: 'Test Notification',
message: 'Check check, 1, 2, 3. Are we coming in clear?',
});
const sendTestNotification = async (agent: NotificationAgent, user: User) => {
const intl = getIntl(user.settings?.locale as AvailableLocale);
return await agent.send(Notification.TEST_NOTIFICATION, {
notifySystem: true,
notifyAdmin: false,
notifyUser: user,
subject: 'Test Notification',
message: 'Check check, 1, 2, 3. Are we coming in clear?',
subject: intl.formatMessage(messages.subject),
message: intl.formatMessage(messages.message),
});
};
notificationRoutes.get('/discord', (_req, res) => {
const settings = getSettings();
-38
View File
@@ -1,38 +0,0 @@
export type AvailableLocale =
| 'ar'
| 'bg'
| 'ca'
| 'cs'
| 'da'
| 'de'
| 'en'
| 'el'
| 'es'
| 'es-MX'
| 'et'
| 'fi'
| 'fr'
| 'hr'
| 'he'
| 'hi'
| 'hu'
| 'it'
| 'ja'
| 'ko'
| 'lb'
| 'lt'
| 'nb-NO'
| 'nl'
| 'pl'
| 'pt-BR'
| 'pt-PT'
| 'ro'
| 'ru'
| 'sq'
| 'sr'
| 'sv'
| 'tr'
| 'uk'
| 'zh-CN'
| 'zh-TW'
| 'vi';
+41
View File
@@ -0,0 +1,41 @@
export const availableLocales = [
'ar',
'bg',
'ca',
'cs',
'da',
'de',
'en',
'el',
'es',
'es-MX',
'et',
'fi',
'fr',
'hr',
'he',
'hi',
'hu',
'it',
'ja',
'ko',
'lb',
'lt',
'nb-NO',
'nl',
'pl',
'pt-BR',
'pt-PT',
'ro',
'ru',
'sq',
'sr',
'sv',
'tr',
'uk',
'zh-CN',
'zh-TW',
'vi',
] as const;
export type AvailableLocale = (typeof availableLocales)[number];
@@ -1,6 +1,7 @@
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import { availableLanguages } from '@app/context/LanguageContext';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
@@ -24,6 +25,7 @@ const messages = defineMessages('components.Settings.Notifications', {
webhookRoleId: 'Notification Role ID',
webhookRoleIdTip:
'The role ID to mention in the webhook message. Leave empty to disable mentions',
useUserLocale: 'Use Notification Recipient Locale',
discordsettingssaved: 'Discord notification settings saved successfully!',
discordsettingsfailed: 'Discord notification settings failed to save.',
toastDiscordTestSending: 'Sending Discord test notification…',
@@ -82,6 +84,8 @@ const NotificationsDiscord = () => {
webhookUrl: data.options.webhookUrl,
webhookRoleId: data?.options.webhookRoleId,
enableMentions: data?.options.enableMentions,
locale: data?.options.locale || 'en',
useUserLocale: data?.options.useUserLocale ?? false,
}}
validationSchema={NotificationsDiscordSchema}
onSubmit={async (values) => {
@@ -96,6 +100,8 @@ const NotificationsDiscord = () => {
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
locale: values.locale,
useUserLocale: values.useUserLocale,
},
});
@@ -146,6 +152,8 @@ const NotificationsDiscord = () => {
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
locale: values.locale,
useUserLocale: values.useUserLocale,
},
});
@@ -295,6 +303,44 @@ const NotificationsDiscord = () => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="useUserLocale" className="checkbox-label">
{intl.formatMessage(messages.useUserLocale)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="useUserLocale"
name="useUserLocale"
/>
</div>
</div>
{!values.useUserLocale && (
<div className="form-row">
<label htmlFor="locale" className="text-label">
{intl.formatMessage(globalMessages.notificationLocale)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="locale" name="locale">
{(
Object.keys(
availableLanguages
) as (keyof typeof availableLanguages)[]
).map((key) => (
<option
key={key}
value={availableLanguages[key].code}
lang={availableLanguages[key].code}
>
{availableLanguages[key].display}
</option>
))}
</Field>
</div>
</div>
</div>
)}
<NotificationTypeSelector
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
@@ -1,6 +1,7 @@
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import { availableLanguages } from '@app/context/LanguageContext';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { isValidURL } from '@app/utils/urlValidationHelper';
@@ -92,6 +93,7 @@ const NotificationsGotify = () => {
url: data?.options.url,
token: data?.options.token,
priority: data?.options.priority,
locale: data?.options.locale ?? 'en',
}}
validationSchema={NotificationsGotifySchema}
onSubmit={async (values) => {
@@ -103,6 +105,7 @@ const NotificationsGotify = () => {
url: values.url,
token: values.token,
priority: Number(values.priority),
locale: values.locale,
},
});
addToast(intl.formatMessage(messages.gotifysettingssaved), {
@@ -149,6 +152,7 @@ const NotificationsGotify = () => {
url: values.url,
token: values.token,
priority: Number(values.priority),
locale: values.locale,
},
});
@@ -239,6 +243,30 @@ const NotificationsGotify = () => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="locale" className="text-label">
{intl.formatMessage(globalMessages.notificationLocale)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="locale" name="locale">
{(
Object.keys(
availableLanguages
) as (keyof typeof availableLanguages)[]
).map((key) => (
<option
key={key}
value={availableLanguages[key].code}
lang={availableLanguages[key].code}
>
{availableLanguages[key].display}
</option>
))}
</Field>
</div>
</div>
</div>
<NotificationTypeSelector
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import { availableLanguages } from '@app/context/LanguageContext';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { isValidURL } from '@app/utils/urlValidationHelper';
@@ -101,6 +102,7 @@ const NotificationsNtfy = () => {
authMethodToken: data?.options.authMethodToken,
token: data?.options.token,
priority: data?.options.priority,
locale: data?.options.locale ?? 'en',
}}
validationSchema={NotificationsNtfySchema}
onSubmit={async (values) => {
@@ -118,6 +120,7 @@ const NotificationsNtfy = () => {
authMethodToken: values.authMethodToken,
token: values.token,
priority: values.priority,
locale: values.locale,
},
});
@@ -170,6 +173,7 @@ const NotificationsNtfy = () => {
authMethodToken: values.authMethodToken,
token: values.token,
priority: values.priority,
locale: values.locale,
},
});
@@ -342,6 +346,30 @@ const NotificationsNtfy = () => {
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="locale" className="text-label">
{intl.formatMessage(globalMessages.notificationLocale)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="locale" name="locale">
{(
Object.keys(
availableLanguages
) as (keyof typeof availableLanguages)[]
).map((key) => (
<option
key={key}
value={availableLanguages[key].code}
lang={availableLanguages[key].code}
>
{availableLanguages[key].display}
</option>
))}
</Field>
</div>
</div>
</div>
<NotificationTypeSelector
currentTypes={values.enabled ? values.types || 0 : 0}
onUpdate={(newTypes) => {
@@ -1,6 +1,7 @@
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import { availableLanguages } from '@app/context/LanguageContext';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
@@ -63,6 +64,7 @@ const NotificationsSlack = () => {
embedPoster: data.embedPoster,
types: data.types,
webhookUrl: data.options.webhookUrl,
locale: data?.options.locale ?? 'en',
}}
validationSchema={NotificationsSlackSchema}
onSubmit={async (values) => {
@@ -73,6 +75,7 @@ const NotificationsSlack = () => {
types: values.types,
options: {
webhookUrl: values.webhookUrl,
locale: values.locale,
},
});
addToast(intl.formatMessage(messages.slacksettingssaved), {
@@ -118,6 +121,7 @@ const NotificationsSlack = () => {
types: values.types,
options: {
webhookUrl: values.webhookUrl,
locale: values.locale,
},
});
@@ -195,6 +199,30 @@ const NotificationsSlack = () => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="locale" className="text-label">
{intl.formatMessage(globalMessages.notificationLocale)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="locale" name="locale">
{(
Object.keys(
availableLanguages
) as (keyof typeof availableLanguages)[]
).map((key) => (
<option
key={key}
value={availableLanguages[key].code}
lang={availableLanguages[key].code}
>
{availableLanguages[key].display}
</option>
))}
</Field>
</div>
</div>
</div>
<NotificationTypeSelector
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
+1
View File
@@ -70,6 +70,7 @@ const globalMessages = defineMessages('i18n', {
addToBlocklist: 'Add to Blocklist',
removefromBlocklist: 'Remove from Blocklist',
specials: 'Specials',
notificationLocale: 'Notification Language',
});
export default globalMessages;
+12 -10
View File
@@ -102,12 +102,11 @@
"components.Discover.NetworkSlider.networks": "Networks",
"components.Discover.PlexWatchlistSlider.emptywatchlist": "Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.",
"components.Discover.PlexWatchlistSlider.plexwatchlist": "Your Watchlist",
"components.Discover.RecentlyAddedSlider.recentlyAdded": "Recently Added",
"components.Discover.RecentRequestsSlider.unableToConnect": "Unable to connect to {services}. Some information may be unavailable.",
"components.Discover.RecentlyAddedSlider.recentlyAdded": "Recently Added",
"components.Discover.StudioSlider.studios": "Studios",
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
"components.Discover.createnewslider": "Create New Slider",
"components.Discover.customizediscover": "Customize Discover",
"components.Discover.discover": "Discover",
@@ -143,6 +142,7 @@
"components.Discover.upcomingtv": "Upcoming Series",
"components.Discover.updatefailed": "Something went wrong updating the discover customization settings.",
"components.Discover.updatesuccess": "Updated discover customization settings.",
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
"components.DownloadBlock.estimatedtime": "Estimated {time}",
"components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}",
"components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?",
@@ -695,6 +695,14 @@
"components.Settings.Notifications.NotificationsSlack.validationWebhookUrl": "You must provide a valid URL",
"components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL",
"components.Settings.Notifications.NotificationsSlack.webhookUrlTip": "Create an <WebhookLink>Incoming Webhook</WebhookLink> integration",
"components.Settings.Notifications.NotificationsWebPush.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsWebPush.embedPoster": "Embed Poster",
"components.Settings.Notifications.NotificationsWebPush.httpsRequirement": "In order to receive web push notifications, Seerr must be served over HTTPS.",
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestFailed": "Web push test notification failed to send.",
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSending": "Sending web push test notification…",
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSuccess": "Web push test notification sent!",
"components.Settings.Notifications.NotificationsWebPush.webpushsettingsfailed": "Web push notification settings failed to save.",
"components.Settings.Notifications.NotificationsWebPush.webpushsettingssaved": "Web push notification settings saved successfully!",
"components.Settings.Notifications.NotificationsWebhook.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsWebhook.authheader": "Authorization Header",
"components.Settings.Notifications.NotificationsWebhook.customHeaders": "Custom Headers",
@@ -721,14 +729,6 @@
"components.Settings.Notifications.NotificationsWebhook.webhookUrlTip": "Test Notification URL is set to {testUrl} instead of the actual webhook URL.",
"components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Webhook notification settings failed to save.",
"components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Webhook notification settings saved successfully!",
"components.Settings.Notifications.NotificationsWebPush.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsWebPush.embedPoster": "Embed Poster",
"components.Settings.Notifications.NotificationsWebPush.httpsRequirement": "In order to receive web push notifications, Seerr must be served over HTTPS.",
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestFailed": "Web push test notification failed to send.",
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSending": "Sending web push test notification…",
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSuccess": "Web push test notification sent!",
"components.Settings.Notifications.NotificationsWebPush.webpushsettingsfailed": "Web push notification settings failed to save.",
"components.Settings.Notifications.NotificationsWebPush.webpushsettingssaved": "Web push notification settings saved successfully!",
"components.Settings.Notifications.agentenabled": "Enable Agent",
"components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates",
"components.Settings.Notifications.authPass": "SMTP Password",
@@ -775,6 +775,7 @@
"components.Settings.Notifications.toastTelegramTestFailed": "Telegram test notification failed to send.",
"components.Settings.Notifications.toastTelegramTestSending": "Sending Telegram test notification…",
"components.Settings.Notifications.toastTelegramTestSuccess": "Telegram test notification sent!",
"components.Settings.Notifications.useUserLocale": "Use Notification Recipient Locale",
"components.Settings.Notifications.userEmailRequired": "Require user email",
"components.Settings.Notifications.validationBotAPIRequired": "You must provide a bot authorization token",
"components.Settings.Notifications.validationChatIdRequired": "You must provide a valid chat ID",
@@ -1627,6 +1628,7 @@
"i18n.movies": "Movies",
"i18n.next": "Next",
"i18n.noresults": "No results.",
"i18n.notificationLocale": "Notification Language",
"i18n.notrequested": "Not Requested",
"i18n.open": "Open",
"i18n.partiallyavailable": "Partially Available",