From cd257bad2aad9545a4764b069134449e6e78320e Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:39:59 +0800 Subject: [PATCH] feat(i18n): add server-side i18n for notification agents (#2731) --- bin/check-i18n.js | 49 ++--- docs/using-seerr/notifications/discord.md | 8 + docs/using-seerr/notifications/email.md | 4 + docs/using-seerr/notifications/gotify.md | 4 + docs/using-seerr/notifications/ntfy.md | 4 + docs/using-seerr/notifications/slack.md | 4 + package.json | 5 +- pnpm-lock.yaml | 71 ++++++- {src => server}/i18n/extractMessages.ts | 25 +-- server/i18n/globalMessages.ts | 26 +++ server/i18n/index.ts | 62 ++++++ server/i18n/locale/en.json | 57 ++++++ server/index.ts | 3 + server/lib/notifications/agents/discord.ts | 46 +++-- server/lib/notifications/agents/email.ts | 157 ++++++++++++---- server/lib/notifications/agents/gotify.ts | 35 ++-- server/lib/notifications/agents/ntfy.ts | 54 +++--- server/lib/notifications/agents/pushbullet.ts | 52 ++++-- server/lib/notifications/agents/pushover.ts | 65 ++++--- server/lib/notifications/agents/slack.ts | 44 +++-- server/lib/notifications/agents/telegram.ts | 79 +++++--- server/lib/notifications/agents/webpush.ts | 176 ++++++++++++------ server/lib/settings/index.ts | 11 ++ server/routes/settings/notifications.ts | 18 +- server/types/languages.d.ts | 38 ---- server/types/languages.ts | 41 ++++ .../Notifications/NotificationsDiscord.tsx | 46 +++++ .../NotificationsGotify/index.tsx | 28 +++ .../Notifications/NotificationsNtfy/index.tsx | 28 +++ .../NotificationsSlack/index.tsx | 28 +++ src/i18n/globalMessages.ts | 1 + src/i18n/locale/en.json | 22 ++- 32 files changed, 971 insertions(+), 320 deletions(-) rename {src => server}/i18n/extractMessages.ts (85%) create mode 100644 server/i18n/globalMessages.ts create mode 100644 server/i18n/index.ts create mode 100644 server/i18n/locale/en.json delete mode 100644 server/types/languages.d.ts create mode 100644 server/types/languages.ts diff --git a/bin/check-i18n.js b/bin/check-i18n.js index 07bbf56cb..a214a5964 100644 --- a/bin/check-i18n.js +++ b/bin/check-i18n.js @@ -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; } diff --git a/docs/using-seerr/notifications/discord.md b/docs/using-seerr/notifications/discord.md index b39e283a8..3988d93ce 100644 --- a/docs/using-seerr/notifications/discord.md +++ b/docs/using-seerr/notifications/discord.md @@ -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. \ No newline at end of file diff --git a/docs/using-seerr/notifications/email.md b/docs/using-seerr/notifications/email.md index e81c3ff6c..183356b5b 100644 --- a/docs/using-seerr/notifications/email.md +++ b/docs/using-seerr/notifications/email.md @@ -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"). diff --git a/docs/using-seerr/notifications/gotify.md b/docs/using-seerr/notifications/gotify.md index 6baaea9d7..39586b690 100644 --- a/docs/using-seerr/notifications/gotify.md +++ b/docs/using-seerr/notifications/gotify.md @@ -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. \ No newline at end of file diff --git a/docs/using-seerr/notifications/ntfy.md b/docs/using-seerr/notifications/ntfy.md index 72a3790f5..6aa466492 100644 --- a/docs/using-seerr/notifications/ntfy.md +++ b/docs/using-seerr/notifications/ntfy.md @@ -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. \ No newline at end of file diff --git a/docs/using-seerr/notifications/slack.md b/docs/using-seerr/notifications/slack.md index 50797a5b9..509e3dcab 100644 --- a/docs/using-seerr/notifications/slack.md +++ b/docs/using-seerr/notifications/slack.md @@ -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. \ No newline at end of file diff --git a/package.json b/package.json index 419b8c2ea..68fc917bb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be456f18a..8a4cc4f16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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(typescript@5.4.5) '@formatjs/intl-displaynames': specifier: 6.8.13 version: 6.8.13 @@ -1497,6 +1500,9 @@ packages: '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + '@formatjs/bigdecimal@0.2.0': + resolution: {integrity: sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==} + '@formatjs/ecma402-abstract@1.14.3': resolution: {integrity: sha512-SlsbRC/RX+/zg4AApWIFNDdkLtFbkq3LNoZWXZCE/nHVKqoIJyaoQyge/I0Y38vLxowUn9KTtXgusLD91+orbg==} @@ -1509,6 +1515,9 @@ packages: '@formatjs/ecma402-abstract@3.1.1': resolution: {integrity: sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==} + '@formatjs/ecma402-abstract@3.2.0': + resolution: {integrity: sha512-dHnqHgBo6GXYGRsepaE1wmsC2etaivOWd5VaJstZd+HI2zR3DCUjbDVZRtoPGkkXZmyHvBwrdEUuqfvzhF/DtQ==} + '@formatjs/fast-memoize@2.2.0': resolution: {integrity: sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==} @@ -1518,18 +1527,27 @@ packages: '@formatjs/fast-memoize@3.1.0': resolution: {integrity: sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==} + '@formatjs/fast-memoize@3.1.1': + resolution: {integrity: sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==} + '@formatjs/icu-messageformat-parser@2.7.8': resolution: {integrity: sha512-nBZJYmhpcSX0WeJ5SDYUkZ42AgR3xiyhNCsQweFx3cz/ULJjym8bHAzWKvG5e2+1XO98dBYC0fWeeAECAVSwLA==} '@formatjs/icu-messageformat-parser@3.5.1': resolution: {integrity: sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==} + '@formatjs/icu-messageformat-parser@3.5.3': + resolution: {integrity: sha512-HJWZ9S6JWey6iY5+YXE3Kd0ofWU1sC2KTTp56e1168g/xxWvVvr8k9G4fexIgwYV9wbtjY7kGYK5FjoWB3B2OQ==} + '@formatjs/icu-skeleton-parser@1.8.2': resolution: {integrity: sha512-k4ERKgw7aKGWJZgTarIcNEmvyTVD9FYh0mTrrBMHZ1b8hUu6iOJ4SzsZlo3UNAvHYa+PnvntIwRPt1/vy4nA9Q==} '@formatjs/icu-skeleton-parser@2.1.1': resolution: {integrity: sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==} + '@formatjs/icu-skeleton-parser@2.1.3': + resolution: {integrity: sha512-9mFp8TJ166ZM2pcjKwsBWXrDnOJGT7vMEScVgLygUODPOsE8S6f/FHoacvrlHK1B4dYZk8vSCNruyPU64AfgJQ==} + '@formatjs/intl-displaynames@6.6.8': resolution: {integrity: sha512-Lgx6n5KxN16B3Pb05z3NLEBQkGoXnGjkTBNCZI+Cn17YjHJ3fhCeEJJUqRlIZmJdmaXQhjcQVDp6WIiNeRYT5g==} @@ -1561,6 +1579,9 @@ packages: '@formatjs/intl-localematcher@0.8.1': resolution: {integrity: sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==} + '@formatjs/intl-localematcher@0.8.2': + resolution: {integrity: sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==} + '@formatjs/intl-pluralrules@5.4.6': resolution: {integrity: sha512-2HlOq+c7KsSps829SJ3B5987coX5mzKx9NbPcNwQ07eq8FBHgB3HfMoxt5HvLsdk4oQwCjAEnocbtd+wVwZ2Kg==} @@ -1576,6 +1597,14 @@ packages: typescript: optional: true + '@formatjs/intl@4.1.4': + resolution: {integrity: sha512-y5aZ5p5zO74mZPdQMe9gq8SR1P6rlPQkaYOVFHyRzWrViif4sRKIQX01GYbKJvaGanupVVX+0WoKJqLVsP+X3Q==} + peerDependencies: + typescript: ^5.6.0 + peerDependenciesMeta: + typescript: + optional: true + '@formatjs/ts-transformer@4.4.0': resolution: {integrity: sha512-lFDp9Rbpxk5Dt8O1/I9VG5btqKbOkjT4snSa73HO1YTJ9KGeXPKA7aWVgHFXJVVq0KluhbZiCoPJVHC4ZREgxw==} engines: {node: '>= 20.12.0'} @@ -5748,6 +5777,9 @@ packages: intl-messageformat@10.5.14: resolution: {integrity: sha512-IjC6sI0X7YRjjyVH9aUgdftcmZK7WXdHeil4KwbjDnRWjnVitKpAx3rr6t6di1joFp5188VqKcobOPA6mCLG/w==} + intl-messageformat@11.2.0: + resolution: {integrity: sha512-IhghAA8n4KSlXuWKzYsWyWb82JoYTzShfyvdSF85oJPnNOjvv4kAo7S7Jtkm3/vJ53C7dQNRO+Gpnj3iWgTjBQ==} + invariant@2.2.4: 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) qs@6.13.0: @@ -11513,6 +11544,8 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} + '@formatjs/bigdecimal@0.2.0': {} + '@formatjs/ecma402-abstract@1.14.3': dependencies: '@formatjs/intl-localematcher': 0.2.32 @@ -11537,6 +11570,12 @@ snapshots: decimal.js: 10.6.0 tslib: 2.8.1 + '@formatjs/ecma402-abstract@3.2.0': + dependencies: + '@formatjs/bigdecimal': 0.2.0 + '@formatjs/fast-memoize': 3.1.1 + '@formatjs/intl-localematcher': 0.8.2 + '@formatjs/fast-memoize@2.2.0': dependencies: tslib: 2.8.1 @@ -11549,6 +11588,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@formatjs/fast-memoize@3.1.1': {} + '@formatjs/icu-messageformat-parser@2.7.8': dependencies: '@formatjs/ecma402-abstract': 2.0.0 @@ -11561,6 +11602,11 @@ snapshots: '@formatjs/icu-skeleton-parser': 2.1.1 tslib: 2.8.1 + '@formatjs/icu-messageformat-parser@3.5.3': + dependencies: + '@formatjs/ecma402-abstract': 3.2.0 + '@formatjs/icu-skeleton-parser': 2.1.3 + '@formatjs/icu-skeleton-parser@1.8.2': dependencies: '@formatjs/ecma402-abstract': 2.0.0 @@ -11571,6 +11617,10 @@ snapshots: '@formatjs/ecma402-abstract': 3.1.1 tslib: 2.8.1 + '@formatjs/icu-skeleton-parser@2.1.3': + dependencies: + '@formatjs/ecma402-abstract': 3.2.0 + '@formatjs/intl-displaynames@6.6.8': dependencies: '@formatjs/ecma402-abstract': 2.0.0 @@ -11621,6 +11671,10 @@ snapshots: '@formatjs/fast-memoize': 3.1.0 tslib: 2.8.1 + '@formatjs/intl-localematcher@0.8.2': + dependencies: + '@formatjs/fast-memoize': 3.1.1 + '@formatjs/intl-pluralrules@5.4.6': dependencies: '@formatjs/ecma402-abstract': 2.3.6 @@ -11644,6 +11698,15 @@ snapshots: optionalDependencies: typescript: 5.4.5 + '@formatjs/intl@4.1.4(typescript@5.4.5)': + 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/ts-transformer@4.4.0(ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)))(typescript@5.4.5))': dependencies: '@formatjs/icu-messageformat-parser': 3.5.1 @@ -16993,6 +17056,12 @@ snapshots: '@formatjs/icu-messageformat-parser': 2.7.8 tslib: 2.8.1 + intl-messageformat@11.2.0: + dependencies: + '@formatjs/ecma402-abstract': 3.2.0 + '@formatjs/fast-memoize': 3.1.1 + '@formatjs/icu-messageformat-parser': 3.5.3 + invariant@2.2.4: dependencies: loose-envify: 1.4.0 diff --git a/src/i18n/extractMessages.ts b/server/i18n/extractMessages.ts similarity index 85% rename from src/i18n/extractMessages.ts rename to server/i18n/extractMessages.ts index 1148fa186..57ddd6ab3 100644 --- a/src/i18n/extractMessages.ts +++ b/server/i18n/extractMessages.ts @@ -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 { const dirents = await fs.readdir(dir, { withFileTypes: true }); const files = await Promise.all( @@ -13,7 +12,6 @@ async function getFiles(dir: string): Promise { return Array.prototype.concat(...files); } -// extract the i18n messages from the file async function extractMessages( filePath: string ): Promise<{ namespace: string; messages: Record } | null> { @@ -44,11 +42,12 @@ async function extractMessages( } async function processMessages(dir: string): Promise { - // 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; @@ -56,6 +55,7 @@ async function processMessages(dir: string): Promise { 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 { return a.namespace.localeCompare(b.namespace); }); - // add every messages from every namespace to an object const result: Record = {}; for (const extractedMessages of messagesByNamespace) { const { namespace, messages } = extractedMessages; @@ -96,15 +95,19 @@ async function processMessages(dir: string): Promise { } } - 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(); diff --git a/server/i18n/globalMessages.ts b/server/i18n/globalMessages.ts new file mode 100644 index 000000000..38f53f0df --- /dev/null +++ b/server/i18n/globalMessages.ts @@ -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; diff --git a/server/i18n/index.ts b/server/i18n/index.ts new file mode 100644 index 000000000..ad4fc6fcc --- /dev/null +++ b/server/i18n/index.ts @@ -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; + +const cache = createIntlCache(); +const intls = new Map(); + +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> = { + [K in keyof T]: { id: string; defaultMessage: T[K] }; +}; + +export function defineMessages>( + namespace: string, + messages: T +): MessageDescriptorMap { + const result = {} as MessageDescriptorMap; + + for (const key of Object.keys(messages) as (keyof T)[]) { + result[key] = { + id: `${namespace}.${String(key)}`, + defaultMessage: messages[key], + }; + } + + return result; +} diff --git a/server/i18n/locale/en.json b/server/i18n/locale/en.json new file mode 100644 index 000000000..459ed5fad --- /dev/null +++ b/server/i18n/locale/en.json @@ -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" +} diff --git a/server/index.ts b/server/index.ts index cd34d7d52..f671deaec 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 }); diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index e4c106ae6..d37bc6e58 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -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); diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 0e373c855..4a82b3943 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -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 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', { diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts index 91db54618..0470e156e 100644 --- a/server/lib/notifications/agents/gotify.ts +++ b/server/lib/notifications/agents/gotify.ts @@ -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 { diff --git a/server/lib/notifications/agents/ntfy.ts b/server/lib/notifications/agents/ntfy.ts index cc585508f..50f429622 100644 --- a/server/lib/notifications/agents/ntfy.ts +++ b/server/lib/notifications/agents/ntfy.ts @@ -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) }`; } diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index 7dd309dc3..971c0b3d8 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -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 { 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, diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index cd5511a02..065401dc4 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -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> { + 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 += `\n\nRequested By: ${payload.request.requestedBy.displayName}`; + message += `\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); priority = 1; break; case Notification.MEDIA_FAILED: - status = 'Failed'; + status = intl.formatMessage(globalMessages.failed); priority = 1; break; } if (status) { - message += `\nRequest Status: ${status}`; + message += `\n${intl.formatMessage(globalMessages.requestStatus)}: ${status}`; } } else if (payload.comment) { - message += `\n\nComment from ${payload.comment.user.displayName}: ${payload.comment.message}`; + message += `\n\n${intl.formatMessage(globalMessages.commentFrom, { userName: payload.comment.user.displayName })}: ${payload.comment.message}`; } else if (payload.issue) { - message += `\n\nReported By: ${payload.issue.createdBy.displayName}`; - message += `\nIssue Type: ${ - IssueTypeName[payload.issue.issueType] - }`; - message += `\nIssue 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) }`; 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 { 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, diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index 71360d63a..33320ff5f 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -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 } + ), }, }, ], diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index cd54a86d6..6d1bf4d83 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -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 { + 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, diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index 6b98185d3..8586dd296 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -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); }) ); } diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 328876159..bb287c8e0 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -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', }, }, }, diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 612321347..f987b9e3d 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -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(); diff --git a/server/types/languages.d.ts b/server/types/languages.d.ts deleted file mode 100644 index 570a7c4be..000000000 --- a/server/types/languages.d.ts +++ /dev/null @@ -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'; diff --git a/server/types/languages.ts b/server/types/languages.ts new file mode 100644 index 000000000..99e1bc395 --- /dev/null +++ b/server/types/languages.ts @@ -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]; diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx index ad38b46f6..be895b093 100644 --- a/src/components/Settings/Notifications/NotificationsDiscord.tsx +++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx @@ -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 = () => { /> +
+ +
+ +
+
+ {!values.useUserLocale && ( +
+ +
+
+ + {( + Object.keys( + availableLanguages + ) as (keyof typeof availableLanguages)[] + ).map((key) => ( + + ))} + +
+
+
+ )} { diff --git a/src/components/Settings/Notifications/NotificationsGotify/index.tsx b/src/components/Settings/Notifications/NotificationsGotify/index.tsx index 9616ca83c..5273c0952 100644 --- a/src/components/Settings/Notifications/NotificationsGotify/index.tsx +++ b/src/components/Settings/Notifications/NotificationsGotify/index.tsx @@ -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 = () => { )} +
+ +
+
+ + {( + Object.keys( + availableLanguages + ) as (keyof typeof availableLanguages)[] + ).map((key) => ( + + ))} + +
+
+
{ diff --git a/src/components/Settings/Notifications/NotificationsNtfy/index.tsx b/src/components/Settings/Notifications/NotificationsNtfy/index.tsx index a678aa944..f30d71898 100644 --- a/src/components/Settings/Notifications/NotificationsNtfy/index.tsx +++ b/src/components/Settings/Notifications/NotificationsNtfy/index.tsx @@ -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 = () => { +
+ +
+
+ + {( + Object.keys( + availableLanguages + ) as (keyof typeof availableLanguages)[] + ).map((key) => ( + + ))} + +
+
+
{ diff --git a/src/components/Settings/Notifications/NotificationsSlack/index.tsx b/src/components/Settings/Notifications/NotificationsSlack/index.tsx index 2c8574504..bafabee18 100644 --- a/src/components/Settings/Notifications/NotificationsSlack/index.tsx +++ b/src/components/Settings/Notifications/NotificationsSlack/index.tsx @@ -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 = () => { )} +
+ +
+
+ + {( + Object.keys( + availableLanguages + ) as (keyof typeof availableLanguages)[] + ).map((key) => ( + + ))} + +
+
+
{ diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 352c30ea7..a150afa99 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -70,6 +70,7 @@ const globalMessages = defineMessages('i18n', { addToBlocklist: 'Add to Blocklist', removefromBlocklist: 'Remove from Blocklist', specials: 'Specials', + notificationLocale: 'Notification Language', }); export default globalMessages; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 477c00428..31ef03389 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -102,12 +102,11 @@ "components.Discover.NetworkSlider.networks": "Networks", "components.Discover.PlexWatchlistSlider.emptywatchlist": "Media added to your Plex Watchlist 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 Incoming Webhook 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",