Compare commits

..

8 Commits

Author SHA1 Message Date
gauthier-th
f8db770bf3 fix(api): catch error when watchlist item doesn't exist anymore 2025-09-16 11:20:58 +02:00
0xsysr3ll
17172e93f9 feat(webhook): add support for dynamic placeholders in webhook URL (#1491)
* feat(wehbook): add support for dynamic placeholders in webhook URL

* refactor(webhook): rename supportPlaceholders to supportVariables and update related logic

Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>

* feat(i18n): add missing translations

Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>

* refactor(notifications): simplify webhook URL validation logic

Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>

* fix: wrong docs url

Co-authored-by: Gauthier <mail@gauthierth.fr>

* fix: update webhook documentation URL to point to Jellyseerr

Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>

---------

Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
Co-authored-by: Gauthier <mail@gauthierth.fr>
2025-09-10 17:20:58 +08:00
THOMAS B
4878722030 fix(tvdb): respect display language when fetching metadata (#1889)
* fix(tvdb): respect display language when fetching metadata

* refactor(tvdb): use seasons translation

* refactor(tvdb): limit while loop

* fix(tvdb): fix translation with '-'

* refactor(tvdb): remove logs

* style(tvdb): remove useless logs

* refactor(tvdb): simplify wanted translation condition

* refactor(languages): move AvailableLocale  from context to types
2025-09-08 20:20:21 +08:00
Ishan Jain
479be0daeb feat(notifications): make embedded posters optional (#1364)
* feat(notifications): make images optional

* fix(notifications): added en i18n config

* fix: prettify

* fix(notifications): added embedImage support for ntfy

* fix(frontend): update embedImage on form state change and submission

* fix(locale): updated locale for embedImage

* fix: renamed embedImage to embedPoster
2025-09-08 14:11:31 +02:00
THOMAS B
6245dae3b3 fix(tvdb): return specials seasons (#1894) 2025-09-08 10:49:26 +02:00
Gauthier
d82c6f6222 fix(settings): correct port saving of proxy settings (#1890)
The port of the proxy settings was saved as a string instead of a number, causing the API to throw
an error and making it impossible to save the network settings.
2025-09-04 14:45:42 +02:00
0xsysr3ll
13fe4c890b feat(issue): add issue description preview (#1881)
* feat(issue): add issue description preview

This PR adds a description preview to the issues list page, allowing users to quickly view issue details without navigating to individual issue pages.

Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>

* fix(issue): remove unnecessary user join

Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>

---------

Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-09-04 10:24:08 +02:00
THOMAS B
22b2824441 feat: add tvdb indexer (#899)
* feat(tvdb): get tv seasons/episodes with tvdb

* fix: fix rate limiter index tvdb indexer

* fix(usersettings): remove unused column tvdbtoken

* refactor(tvdb): replace tvdb api by skyhook

* fix: error during get episodes

* fix: error if tmdb poster is null

* refactor: clean tvdb indexer code

* fix: wrong language with tmdb indexer

* style: replace avalaible to available

* style: tvdb.login to tvdb.test

* fix(test): fix  discover test

* fix(test): wrong url tv-details

* test(tvdb): add tvdb tests

* style(tvdb): rename pokemon to correct tv show

* refactor(indexer): remove unused getSeasonIdentifier method

* refactor(settings): replace tvdb object to boolean type

* refactor(tmdb): reduce still path condition

* test(tvdb): change 'use' to 'tvdb' condition check

* fix(tmdb): fix build

fix build after rebase

* fix(build): revert package.json

* fix(tvdb): ensure that seasons contain data

* refactor(swagger): fix /tvdb/test response

* fix(scanner): add tvdb indexer for scanner

* refactor(tvdb): remove skyhook api

* refactor(tvdb): use tvdb api

* fix(tvdb): rename tvdb to medatada

* refactor(medata): add tvdb settings

* refactor(metadata): rewrite metadata settings

* refactor(metadata): refactor metadata routes

* refactor(metadata): remove french comments

* refactor(metadata): refactor tvdb api calls

* style(prettier): run prettier

* fix(scanner): fix jellyfin scanner with tvdb provider

* fix(scanner): fix plex scanner tvdb provider

* style(provider): change provider name in info section

* style(provider): full provider name in select

* style(provider): remove french comment

* fix(tests): fix all cypress tests

* refactor(tvdb): fix apikey

* refactor(tmdb): apply prettier

* refactor(tvdb): remove logger info

* feat(metadata): replace fetch with axios for API calls

* feat(provider): replace indexer by provider

* fix(tests): fix cypress test

* chore: add project-wide apikey for tvdb

* chore: add correct application-wide key

* fix(test): fix test with default provider tmdb anime

* style(cypress): fix anime name variable

* chore(i18n): remove french translation + apply i18n:extract

* style(wording): standardize naming to "Metadata Provider" in UI text

* docs(comments): translate from French to English

* refactor(tvdb): remove unnecessary try/catch block

* feat(i18n): add missing translations

* fix(scanner): correct metadata provider ID from Tmdb to Tvdb

* style(settings): clarify navigation label from "Metadata" to "Metadata Providers"

* style(logs): update error log label from "Metadata" to "MetadataProvider"

* refactor(tvdb): replace indexer by metadata providers

* refactor(settings): remove metadata providers logo

* fix(config): restore missing config/db/.gitkeep file

---------

Co-authored-by: TOomaAh <ubuntu@PC>
Co-authored-by: fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
2025-09-02 22:40:47 +02:00
36 changed files with 576 additions and 102 deletions

0
config/db/.gitkeep Normal file
View File

View File

@@ -1451,6 +1451,9 @@ components:
type: string type: string
jsonPayload: jsonPayload:
type: string type: string
supportVariables:
type: boolean
example: false
TelegramSettings: TelegramSettings:
type: object type: object
properties: properties:

View File

@@ -312,12 +312,25 @@ class PlexTvAPI extends ExternalAPI {
const watchlistDetails = await Promise.all( const watchlistDetails = await Promise.all(
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map( (cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
async (watchlistItem) => { async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>( let detailedResponse: MetadataResponse;
`/library/metadata/${watchlistItem.ratingKey}`, try {
{ detailedResponse = await this.getRolling<MetadataResponse>(
baseURL: 'https://discover.provider.plex.tv', `/library/metadata/${watchlistItem.ratingKey}`,
{
baseURL: 'https://discover.provider.plex.tv',
}
);
} catch (e) {
if (e.response?.status === 404) {
logger.warn(
`Item with ratingKey ${watchlistItem.ratingKey} not found, it may have been removed from the server.`,
{ label: 'Plex.TV Metadata API' }
);
return null;
} else {
throw e;
} }
); }
const metadata = detailedResponse.MediaContainer.Metadata[0]; const metadata = detailedResponse.MediaContainer.Metadata[0];
@@ -343,7 +356,9 @@ class PlexTvAPI extends ExternalAPI {
) )
); );
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); const filteredList = watchlistDetails.filter(
(detail) => detail?.tmdbId
) as PlexWatchlistItem[];
return { return {
offset, offset,

View File

@@ -7,12 +7,13 @@ import type {
TmdbTvEpisodeResult, TmdbTvEpisodeResult,
TmdbTvSeasonResult, TmdbTvSeasonResult,
} from '@server/api/themoviedb/interfaces'; } from '@server/api/themoviedb/interfaces';
import type { import {
TvdbBaseResponse, convertTmdbLanguageToTvdbWithFallback,
TvdbEpisode, type TvdbBaseResponse,
TvdbLoginResponse, type TvdbEpisode,
TvdbSeasonDetails, type TvdbLoginResponse,
TvdbTvDetails, type TvdbSeasonDetails,
type TvdbTvDetails,
} from '@server/api/tvdb/interfaces'; } from '@server/api/tvdb/interfaces';
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache'; import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
import logger from '@server/logger'; import logger from '@server/logger';
@@ -203,10 +204,6 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
seasonNumber: number; seasonNumber: number;
language?: string; language?: string;
}): Promise<TmdbSeasonWithEpisodes> { }): Promise<TmdbSeasonWithEpisodes> {
if (seasonNumber === 0) {
return this.createEmptySeasonResponse(tvId);
}
try { try {
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language }); const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
@@ -219,7 +216,12 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language }); return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
} }
return await this.getTvdbSeasonData(tvdbId, seasonNumber, tvId); return await this.getTvdbSeasonData(
tvdbId,
seasonNumber,
tvId,
language
);
} catch (error) { } catch (error) {
this.handleError('Failed to fetch TV season details', error); this.handleError('Failed to fetch TV season details', error);
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language }); return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
@@ -275,12 +277,12 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
} }
const seasons = tvdbData.seasons const seasons = tvdbData.seasons
.filter( .filter((season) => season.type && season.type.type === 'official')
(season) =>
season.number > 0 && season.type && season.type.type === 'official'
)
.sort((a, b) => a.number - b.number) .sort((a, b) => a.number - b.number)
.map((season) => this.createSeasonData(season, tvdbData)); .map((season) => this.createSeasonData(season, tvdbData))
.filter(
(season) => season && season.season_number >= 0
) as TmdbTvSeasonResult[];
return seasons; return seasons;
} }
@@ -289,13 +291,14 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
season: TvdbSeasonDetails, season: TvdbSeasonDetails,
tvdbData: TvdbTvDetails tvdbData: TvdbTvDetails
): TmdbTvSeasonResult { ): TmdbTvSeasonResult {
if (!season.number) { const seasonNumber = season.number ?? -1;
if (seasonNumber < 0) {
return { return {
id: 0, id: 0,
episode_count: 0, episode_count: 0,
name: '', name: '',
overview: '', overview: '',
season_number: 0, season_number: -1,
poster_path: '', poster_path: '',
air_date: '', air_date: '',
}; };
@@ -319,8 +322,8 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
private async getTvdbSeasonData( private async getTvdbSeasonData(
tvdbId: number, tvdbId: number,
seasonNumber: number, seasonNumber: number,
tvId: number tvId: number,
//language: string = Tvdb.DEFAULT_LANGUAGE language: string = Tvdb.DEFAULT_LANGUAGE
): Promise<TmdbSeasonWithEpisodes> { ): Promise<TmdbSeasonWithEpisodes> {
const tvdbData = await this.fetchTvdbShowData(tvdbId); const tvdbData = await this.fetchTvdbShowData(tvdbId);
@@ -344,6 +347,132 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
return this.createEmptySeasonResponse(tvId); return this.createEmptySeasonResponse(tvId);
} }
const wantedTranslation = convertTmdbLanguageToTvdbWithFallback(
language,
Tvdb.DEFAULT_LANGUAGE
);
// check if translation is available for the season
const availableTranslation = season.nameTranslations.filter(
(translation) =>
translation === wantedTranslation ||
translation === Tvdb.DEFAULT_LANGUAGE
);
if (!availableTranslation) {
return this.getSeasonWithOriginalLanguage(
tvdbId,
tvId,
seasonNumber,
season
);
}
return this.getSeasonWithTranslation(
tvdbId,
tvId,
seasonNumber,
season,
wantedTranslation
);
}
private async getSeasonWithTranslation(
tvdbId: number,
tvId: number,
seasonNumber: number,
season: TvdbSeasonDetails,
language: string
): Promise<TmdbSeasonWithEpisodes> {
if (!season) {
logger.error(
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
);
return this.createEmptySeasonResponse(tvId);
}
const allEpisodes = [] as TvdbEpisode[];
let page = 0;
// Limit to max 50 pages to avoid infinite loops.
// 50 pages with 500 items per page = 25_000 episodes in a series which should be more than enough
const maxPages = 50;
while (page < maxPages) {
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
`/series/${tvdbId}/episodes/default/${language}`,
{
headers: {
Authorization: `Bearer ${this.token}`,
},
params: {
page: page,
},
}
);
if (!resp?.data?.episodes) {
logger.warn(
`No episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
);
break;
}
const { episodes } = resp.data;
if (!episodes) {
logger.debug(
`No more episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
);
break;
}
allEpisodes.push(...episodes);
const hasNextPage = resp.links?.next && episodes.length > 0;
if (!hasNextPage) {
break;
}
page++;
}
if (page >= maxPages) {
logger.warn(
`Reached max pages (${maxPages}) for TVDB ID: ${tvdbId} on season ${seasonNumber} with language ${language}. There might be more episodes available.`
);
}
const episodes = this.processEpisodes(
{ ...season, episodes: allEpisodes },
seasonNumber,
tvId
);
return {
episodes,
external_ids: { tvdb_id: tvdbId },
name: '',
overview: '',
id: season.id,
air_date: season.firstAired,
season_number: episodes.length,
};
}
private async getSeasonWithOriginalLanguage(
tvdbId: number,
tvId: number,
seasonNumber: number,
season: TvdbSeasonDetails
): Promise<TmdbSeasonWithEpisodes> {
if (!season) {
logger.error(
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
);
return this.createEmptySeasonResponse(tvId);
}
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>( const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
`/seasons/${season.id}/extended`, `/seasons/${season.id}/extended`,
{ {
@@ -397,7 +526,10 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
season_number: episode.seasonNumber, season_number: episode.seasonNumber,
production_code: '', production_code: '',
show_id: tvId, show_id: tvId,
still_path: episode.image ? episode.image : '', still_path:
episode.image && !episode.image.startsWith('https://')
? 'https://artworks.thetvdb.com' + episode.image
: '',
vote_average: 1, vote_average: 1,
vote_count: 1, vote_count: 1,
}; };

View File

@@ -1,6 +1,17 @@
import { type AvailableLocale } from '@server/types/languages';
export interface TvdbBaseResponse<T> { export interface TvdbBaseResponse<T> {
data: T; data: T;
errors: string; errors: string;
links?: TvdbPagination;
}
export interface TvdbPagination {
prev?: string;
self: string;
next?: string;
totalItems: number;
pageSize: number;
} }
export interface TvdbLoginResponse { export interface TvdbLoginResponse {
@@ -142,3 +153,64 @@ export interface TvdbEpisodeTranslation {
overview: string; overview: string;
language: string; language: string;
} }
const TMDB_TO_TVDB_MAPPING: Record<string, string> & {
[key in AvailableLocale]: string;
} = {
ar: 'ara', // Arabic
bg: 'bul', // Bulgarian
ca: 'cat', // Catalan
cs: 'ces', // Czech
da: 'dan', // Danish
de: 'deu', // German
el: 'ell', // Greek
en: 'eng', // English
es: 'spa', // Spanish
fi: 'fin', // Finnish
fr: 'fra', // French
he: 'heb', // Hebrew
hi: 'hin', // Hindi
hr: 'hrv', // Croatian
hu: 'hun', // Hungarian
it: 'ita', // Italian
ja: 'jpn', // Japanese
ko: 'kor', // Korean
lt: 'lit', // Lithuanian
nl: 'nld', // Dutch
pl: 'pol', // Polish
ro: 'ron', // Romanian
ru: 'rus', // Russian
sq: 'sqi', // Albanian
sr: 'srp', // Serbian
sv: 'swe', // Swedish
tr: 'tur', // Turkish
uk: 'ukr', // Ukrainian
'es-MX': 'spa', // Spanish (Latin America) -> Spanish
'nb-NO': 'nor', // Norwegian Bokmål -> Norwegian
'pt-BR': 'pt', // Portuguese (Brazil) -> Portuguese - Brazil (from TVDB data)
'pt-PT': 'por', // Portuguese (Portugal) -> Portuguese - Portugal (from TVDB data)
'zh-CN': 'zho', // Chinese (Simplified) -> Chinese - China
'zh-TW': 'zhtw', // Chinese (Traditional) -> Chinese - Taiwan
};
export function convertTMDBToTVDB(tmdbCode: string): string | null {
const normalizedCode = tmdbCode.toLowerCase();
return (
TMDB_TO_TVDB_MAPPING[tmdbCode] ||
TMDB_TO_TVDB_MAPPING[normalizedCode] ||
null
);
}
export function convertTmdbLanguageToTvdbWithFallback(
tmdbCode: string,
fallback: string
): string {
// First try exact match
const tvdbCode = convertTMDBToTVDB(tmdbCode);
if (tvdbCode) return tvdbCode;
return tvdbCode || fallback || 'eng'; // Default to English if no match found
}

View File

@@ -109,7 +109,9 @@ class DiscordAgent
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): DiscordRichEmbed { ): DiscordRichEmbed {
const { applicationUrl } = getSettings().main; const settings = getSettings();
const { applicationUrl } = settings.main;
const { embedPoster } = settings.notifications.agents.discord;
const appUrl = const appUrl =
applicationUrl || `http://localhost:${process.env.port || 5055}`; applicationUrl || `http://localhost:${process.env.port || 5055}`;
@@ -223,9 +225,11 @@ class DiscordAgent
} }
: undefined, : undefined,
fields, fields,
thumbnail: { thumbnail: embedPoster
url: payload.image, ? {
}, url: payload.image,
}
: undefined,
}; };
} }

View File

@@ -48,7 +48,9 @@ class EmailAgent
recipientEmail: string, recipientEmail: string,
recipientName?: string recipientName?: string
): EmailOptions | undefined { ): EmailOptions | undefined {
const { applicationUrl, applicationTitle } = getSettings().main; const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.email;
if (type === Notification.TEST_NOTIFICATION) { if (type === Notification.TEST_NOTIFICATION) {
return { return {
@@ -129,7 +131,7 @@ class EmailAgent
body, body,
mediaName: payload.subject, mediaName: payload.subject,
mediaExtra: payload.extra ?? [], mediaExtra: payload.extra ?? [],
imageUrl: payload.image, imageUrl: embedPoster ? payload.image : undefined,
timestamp: new Date().toTimeString(), timestamp: new Date().toTimeString(),
requestedBy: payload.request.requestedBy.displayName, requestedBy: payload.request.requestedBy.displayName,
actionUrl: applicationUrl actionUrl: applicationUrl
@@ -176,7 +178,7 @@ class EmailAgent
issueComment: payload.comment?.message, issueComment: payload.comment?.message,
mediaName: payload.subject, mediaName: payload.subject,
extra: payload.extra ?? [], extra: payload.extra ?? [],
imageUrl: payload.image, imageUrl: embedPoster ? payload.image : undefined,
timestamp: new Date().toTimeString(), timestamp: new Date().toTimeString(),
actionUrl: applicationUrl actionUrl: applicationUrl
? `${applicationUrl}/issues/${payload.issue.id}` ? `${applicationUrl}/issues/${payload.issue.id}`

View File

@@ -22,7 +22,9 @@ class NtfyAgent
} }
private buildPayload(type: Notification, payload: NotificationPayload) { private buildPayload(type: Notification, payload: NotificationPayload) {
const { applicationUrl } = getSettings().main; const settings = getSettings();
const { applicationUrl } = settings.main;
const { embedPoster } = settings.notifications.agents.ntfy;
const topic = this.getSettings().options.topic; const topic = this.getSettings().options.topic;
const priority = 3; const priority = 3;
@@ -72,7 +74,7 @@ class NtfyAgent
message += `\n\n**${extra.name}**\n${extra.value}`; message += `\n\n**${extra.name}**\n${extra.value}`;
} }
const attach = payload.image; const attach = embedPoster ? payload.image : undefined;
let click; let click;
if (applicationUrl && payload.media) { if (applicationUrl && payload.media) {

View File

@@ -78,7 +78,9 @@ class PushoverAgent
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): Promise<Partial<PushoverPayload>> { ): Promise<Partial<PushoverPayload>> {
const { applicationUrl, applicationTitle } = getSettings().main; const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.pushover;
const title = payload.event ?? payload.subject; const title = payload.event ?? payload.subject;
let message = payload.event ? `<b>${payload.subject}</b>` : ''; let message = payload.event ? `<b>${payload.subject}</b>` : '';
@@ -155,7 +157,7 @@ class PushoverAgent
let attachment_base64; let attachment_base64;
let attachment_type; let attachment_type;
if (payload.image) { if (embedPoster && payload.image) {
const imagePayload = await this.getImagePayload(payload.image); const imagePayload = await this.getImagePayload(payload.image);
if (imagePayload.attachment_base64 && imagePayload.attachment_type) { if (imagePayload.attachment_base64 && imagePayload.attachment_type) {
attachment_base64 = imagePayload.attachment_base64; attachment_base64 = imagePayload.attachment_base64;

View File

@@ -63,7 +63,9 @@ class SlackAgent
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): SlackBlockEmbed { ): SlackBlockEmbed {
const { applicationUrl, applicationTitle } = getSettings().main; const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.slack;
const fields: EmbedField[] = []; const fields: EmbedField[] = [];
@@ -159,13 +161,14 @@ class SlackAgent
type: 'mrkdwn', type: 'mrkdwn',
text: payload.message, text: payload.message,
}, },
accessory: payload.image accessory:
? { embedPoster && payload.image
type: 'image', ? {
image_url: payload.image, type: 'image',
alt_text: payload.subject, image_url: payload.image,
} alt_text: payload.subject,
: undefined, }
: undefined,
}); });
} }

View File

@@ -65,7 +65,9 @@ class TelegramAgent
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): Partial<TelegramMessagePayload | TelegramPhotoPayload> { ): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
const { applicationUrl, applicationTitle } = getSettings().main; const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { embedPoster } = settings.notifications.agents.telegram;
/* eslint-disable no-useless-escape */ /* eslint-disable no-useless-escape */
let message = `\*${this.escapeText( let message = `\*${this.escapeText(
@@ -142,7 +144,7 @@ class TelegramAgent
} }
/* eslint-enable */ /* eslint-enable */
return payload.image return embedPoster && payload.image
? { ? {
photo: payload.image, photo: payload.image,
caption: message, caption: message,
@@ -160,7 +162,7 @@ class TelegramAgent
): Promise<boolean> { ): Promise<boolean> {
const settings = this.getSettings(); const settings = this.getSettings();
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${ const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
payload.image ? 'sendPhoto' : 'sendMessage' settings.embedPoster && payload.image ? 'sendPhoto' : 'sendMessage'
}`; }`;
const notificationPayload = this.getNotificationPayload(type, payload); const notificationPayload = this.getNotificationPayload(type, payload);

View File

@@ -177,9 +177,27 @@ class WebhookAgent
subject: payload.subject, subject: payload.subject,
}); });
let webhookUrl = settings.options.webhookUrl;
if (settings.options.supportVariables) {
Object.keys(KeyMap).forEach((keymapKey) => {
const keymapValue = KeyMap[keymapKey as keyof typeof KeyMap];
const variableValue =
type === Notification.TEST_NOTIFICATION
? 'test'
: typeof keymapValue === 'function'
? keymapValue(payload, type)
: get(payload, keymapValue) || 'test';
webhookUrl = webhookUrl.replace(
new RegExp(`{{${keymapKey}}}`, 'g'),
encodeURIComponent(variableValue)
);
});
}
try { try {
await axios.post( await axios.post(
settings.options.webhookUrl, webhookUrl,
this.buildPayload(type, payload), this.buildPayload(type, payload),
settings.options.authHeader settings.options.authHeader
? { ? {

View File

@@ -42,6 +42,8 @@ class WebPushAgent
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): PushNotificationPayload { ): PushNotificationPayload {
const { embedPoster } = getSettings().notifications.agents.webpush;
const mediaType = payload.media const mediaType = payload.media
? payload.media.mediaType === MediaType.MOVIE ? payload.media.mediaType === MediaType.MOVIE
? 'movie' ? 'movie'
@@ -128,7 +130,7 @@ class WebPushAgent
notificationType: Notification[type], notificationType: Notification[type],
subject: payload.subject, subject: payload.subject,
message, message,
image: payload.image, image: embedPoster ? payload.image : undefined,
requestId: payload.request?.id, requestId: payload.request?.id,
actionUrl, actionUrl,
actionUrlTitle, actionUrlTitle,

View File

@@ -207,6 +207,7 @@ interface FullPublicSettings extends PublicSettings {
export interface NotificationAgentConfig { export interface NotificationAgentConfig {
enabled: boolean; enabled: boolean;
embedPoster: boolean;
types?: number; types?: number;
options: Record<string, unknown>; options: Record<string, unknown>;
} }
@@ -274,6 +275,7 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
webhookUrl: string; webhookUrl: string;
jsonPayload: string; jsonPayload: string;
authHeader?: string; authHeader?: string;
supportVariables?: boolean;
}; };
} }
@@ -434,6 +436,7 @@ class Settings {
agents: { agents: {
email: { email: {
enabled: false, enabled: false,
embedPoster: true,
options: { options: {
userEmailRequired: false, userEmailRequired: false,
emailFrom: '', emailFrom: '',
@@ -448,6 +451,7 @@ class Settings {
}, },
discord: { discord: {
enabled: false, enabled: false,
embedPoster: true,
types: 0, types: 0,
options: { options: {
webhookUrl: '', webhookUrl: '',
@@ -457,6 +461,7 @@ class Settings {
}, },
slack: { slack: {
enabled: false, enabled: false,
embedPoster: true,
types: 0, types: 0,
options: { options: {
webhookUrl: '', webhookUrl: '',
@@ -464,6 +469,7 @@ class Settings {
}, },
telegram: { telegram: {
enabled: false, enabled: false,
embedPoster: true,
types: 0, types: 0,
options: { options: {
botAPI: '', botAPI: '',
@@ -474,6 +480,7 @@ class Settings {
}, },
pushbullet: { pushbullet: {
enabled: false, enabled: false,
embedPoster: false,
types: 0, types: 0,
options: { options: {
accessToken: '', accessToken: '',
@@ -481,6 +488,7 @@ class Settings {
}, },
pushover: { pushover: {
enabled: false, enabled: false,
embedPoster: true,
types: 0, types: 0,
options: { options: {
accessToken: '', accessToken: '',
@@ -490,6 +498,7 @@ class Settings {
}, },
webhook: { webhook: {
enabled: false, enabled: false,
embedPoster: true,
types: 0, types: 0,
options: { options: {
webhookUrl: '', webhookUrl: '',
@@ -499,10 +508,12 @@ class Settings {
}, },
webpush: { webpush: {
enabled: false, enabled: false,
embedPoster: true,
options: {}, options: {},
}, },
gotify: { gotify: {
enabled: false, enabled: false,
embedPoster: false,
types: 0, types: 0,
options: { options: {
url: '', url: '',
@@ -512,6 +523,7 @@ class Settings {
}, },
ntfy: { ntfy: {
enabled: false, enabled: false,
embedPoster: true,
types: 0, types: 0,
options: { options: {
url: '', url: '',

View File

@@ -54,6 +54,7 @@ issueRoutes.get<Record<string, string>, IssueResultsResponse>(
.leftJoinAndSelect('issue.createdBy', 'createdBy') .leftJoinAndSelect('issue.createdBy', 'createdBy')
.leftJoinAndSelect('issue.media', 'media') .leftJoinAndSelect('issue.media', 'media')
.leftJoinAndSelect('issue.modifiedBy', 'modifiedBy') .leftJoinAndSelect('issue.modifiedBy', 'modifiedBy')
.leftJoinAndSelect('issue.comments', 'comments')
.where('issue.status IN (:...issueStatus)', { .where('issue.status IN (:...issueStatus)', {
issueStatus: statusFilter, issueStatus: statusFilter,
}); });

View File

@@ -270,6 +270,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
const response: typeof webhookSettings = { const response: typeof webhookSettings = {
enabled: webhookSettings.enabled, enabled: webhookSettings.enabled,
embedPoster: webhookSettings.embedPoster,
types: webhookSettings.types, types: webhookSettings.types,
options: { options: {
...webhookSettings.options, ...webhookSettings.options,
@@ -278,6 +279,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
'utf8' 'utf8'
) )
), ),
supportVariables: webhookSettings.options.supportVariables ?? false,
}, },
}; };
@@ -291,6 +293,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
settings.notifications.agents.webhook = { settings.notifications.agents.webhook = {
enabled: req.body.enabled, enabled: req.body.enabled,
embedPoster: req.body.embedPoster,
types: req.body.types, types: req.body.types,
options: { options: {
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString( jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
@@ -298,6 +301,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
), ),
webhookUrl: req.body.options.webhookUrl, webhookUrl: req.body.options.webhookUrl,
authHeader: req.body.options.authHeader, authHeader: req.body.options.authHeader,
supportVariables: req.body.options.supportVariables ?? false,
}, },
}; };
await settings.save(); await settings.save();
@@ -321,6 +325,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
const testBody = { const testBody = {
enabled: req.body.enabled, enabled: req.body.enabled,
embedPoster: req.body.embedPoster,
types: req.body.types, types: req.body.types,
options: { options: {
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString( jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
@@ -328,6 +333,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
), ),
webhookUrl: req.body.options.webhookUrl, webhookUrl: req.body.options.webhookUrl,
authHeader: req.body.options.authHeader, authHeader: req.body.options.authHeader,
supportVariables: req.body.options.supportVariables ?? false,
}, },
}; };

View File

@@ -80,6 +80,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
const season = await metadataProvider.getTvSeason({ const season = await metadataProvider.getTvSeason({
tvId: Number(req.params.id), tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber), seasonNumber: Number(req.params.seasonNumber),
language: (req.query.language as string) ?? req.locale,
}); });
return res.status(200).json(mapSeasonWithEpisodes(season)); return res.status(200).json(mapSeasonWithEpisodes(season));

View File

@@ -53,10 +53,11 @@ div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
b(style='color: #9ca3af; font-weight: 700;') b(style='color: #9ca3af; font-weight: 700;')
| #{extra.name}&nbsp; | #{extra.name}&nbsp;
| #{extra.value} | #{extra.value}
td(rowspan='2' style='width: 7rem;') if imageUrl
a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl) td(rowspan='2' style='width: 7rem;')
div(style='overflow: hidden; box-sizing: border-box; margin: 0px;') a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl)
img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;') div(style='overflow: hidden; box-sizing: border-box; margin: 0px;')
img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;')
tr tr
td(style='font-size: .85em; color: #9ca3af; line-height: 1em; vertical-align: bottom; margin-right: 1rem') td(style='font-size: .85em; color: #9ca3af; line-height: 1em; vertical-align: bottom; margin-right: 1rem')
span span

35
server/types/languages.d.ts vendored Normal file
View File

@@ -0,0 +1,35 @@
export type AvailableLocale =
| 'ar'
| 'bg'
| 'ca'
| 'cs'
| 'da'
| 'de'
| 'en'
| 'el'
| 'es'
| 'es-MX'
| 'fi'
| 'fr'
| 'hr'
| 'he'
| 'hi'
| 'hu'
| 'it'
| 'ja'
| 'ko'
| 'lt'
| 'nb-NO'
| 'nl'
| 'pl'
| 'pt-BR'
| 'pt-PT'
| 'ro'
| 'ru'
| 'sq'
| 'sr'
| 'sv'
| 'tr'
| 'uk'
| 'zh-CN'
| 'zh-TW';

View File

@@ -1,6 +1,7 @@
import Badge from '@app/components/Common/Badge'; import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage'; import CachedImage from '@app/components/Common/CachedImage';
import Tooltip from '@app/components/Common/Tooltip';
import { issueOptions } from '@app/components/IssueModal/constants'; import { issueOptions } from '@app/components/IssueModal/constants';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
@@ -26,6 +27,7 @@ const messages = defineMessages('components.IssueList.IssueItem', {
opened: 'Opened', opened: 'Opened',
viewissue: 'View Issue', viewissue: 'View Issue',
unknownissuetype: 'Unknown', unknownissuetype: 'Unknown',
descriptionpreview: 'Issue Description',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -107,8 +109,15 @@ const IssueItem = ({ issue }: IssueItemProps) => {
} }
} }
const description = issue.comments?.[0]?.message || '';
const maxDescriptionLength = 120;
const shouldTruncate = description.length > maxDescriptionLength;
const truncatedDescription = shouldTruncate
? description.substring(0, maxDescriptionLength) + '...'
: description;
return ( return (
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row"> <div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:flex-row">
{title.backdropPath && ( {title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3"> <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage <CachedImage
@@ -168,8 +177,38 @@ const IssueItem = ({ issue }: IssueItemProps) => {
> >
{isMovie(title) ? title.title : title.name} {isMovie(title) ? title.title : title.name}
</Link> </Link>
{description && (
<div className="mt-1 max-w-full">
<div className="overflow-hidden text-sm text-gray-300">
{shouldTruncate ? (
<Tooltip
content={
<div className="max-w-sm p-3">
<div className="mb-1 text-sm font-medium text-gray-200">
Issue Description
</div>
<div className="whitespace-pre-wrap text-sm leading-relaxed text-gray-300">
{description}
</div>
</div>
}
tooltipConfig={{
placement: 'top',
offset: [0, 8],
}}
>
<span className="block cursor-help truncate transition-colors hover:text-gray-200">
{truncatedDescription}
</span>
</Tooltip>
) : (
<span className="block break-words">{description}</span>
)}
</div>
</div>
)}
{problemSeasonEpisodeLine.length > 0 && ( {problemSeasonEpisodeLine.length > 0 && (
<div className="card-field"> <div className="card-field mt-1">
{problemSeasonEpisodeLine.map((t, k) => ( {problemSeasonEpisodeLine.map((t, k) => (
<span key={k}>{t}</span> <span key={k}>{t}</span>
))} ))}

View File

@@ -1,10 +1,10 @@
import type { AvailableLocale } from '@app/context/LanguageContext';
import { availableLanguages } from '@app/context/LanguageContext'; import { availableLanguages } from '@app/context/LanguageContext';
import useClickOutside from '@app/hooks/useClickOutside'; import useClickOutside from '@app/hooks/useClickOutside';
import useLocale from '@app/hooks/useLocale'; import useLocale from '@app/hooks/useLocale';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { LanguageIcon } from '@heroicons/react/24/solid'; import { LanguageIcon } from '@heroicons/react/24/solid';
import type { AvailableLocale } from '@server/types/languages';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';

View File

@@ -3,11 +3,11 @@ import PullToRefresh from '@app/components/Layout/PullToRefresh';
import SearchInput from '@app/components/Layout/SearchInput'; import SearchInput from '@app/components/Layout/SearchInput';
import Sidebar from '@app/components/Layout/Sidebar'; import Sidebar from '@app/components/Layout/Sidebar';
import UserDropdown from '@app/components/Layout/UserDropdown'; import UserDropdown from '@app/components/Layout/UserDropdown';
import type { AvailableLocale } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale'; import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid'; import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
import type { AvailableLocale } from '@server/types/languages';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';

View File

@@ -15,6 +15,7 @@ import * as Yup from 'yup';
const messages = defineMessages('components.Settings.Notifications', { const messages = defineMessages('components.Settings.Notifications', {
agentenabled: 'Enable Agent', agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
botUsername: 'Bot Username', botUsername: 'Bot Username',
botAvatarUrl: 'Bot Avatar URL', botAvatarUrl: 'Bot Avatar URL',
webhookUrl: 'Webhook URL', webhookUrl: 'Webhook URL',
@@ -74,6 +75,7 @@ const NotificationsDiscord = () => {
<Formik <Formik
initialValues={{ initialValues={{
enabled: data.enabled, enabled: data.enabled,
embedPoster: data.embedPoster,
types: data.types, types: data.types,
botUsername: data?.options.botUsername, botUsername: data?.options.botUsername,
botAvatarUrl: data?.options.botAvatarUrl, botAvatarUrl: data?.options.botAvatarUrl,
@@ -86,6 +88,7 @@ const NotificationsDiscord = () => {
try { try {
await axios.post('/api/v1/settings/notifications/discord', { await axios.post('/api/v1/settings/notifications/discord', {
enabled: values.enabled, enabled: values.enabled,
embedPoster: values.embedPoster,
types: values.types, types: values.types,
options: { options: {
botUsername: values.botUsername, botUsername: values.botUsername,
@@ -135,6 +138,7 @@ const NotificationsDiscord = () => {
); );
await axios.post('/api/v1/settings/notifications/discord/test', { await axios.post('/api/v1/settings/notifications/discord/test', {
enabled: true, enabled: true,
embedPoster: values.embedPoster,
types: values.types, types: values.types,
options: { options: {
botUsername: values.botUsername, botUsername: values.botUsername,
@@ -176,6 +180,14 @@ const NotificationsDiscord = () => {
<Field type="checkbox" id="enabled" name="enabled" /> <Field type="checkbox" id="enabled" name="enabled" />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="name" className="text-label"> <label htmlFor="name" className="text-label">
{intl.formatMessage(messages.webhookUrl)} {intl.formatMessage(messages.webhookUrl)}

View File

@@ -17,6 +17,7 @@ const messages = defineMessages('components.Settings.Notifications', {
validationSmtpHostRequired: 'You must provide a valid hostname or IP address', validationSmtpHostRequired: 'You must provide a valid hostname or IP address',
validationSmtpPortRequired: 'You must provide a valid port number', validationSmtpPortRequired: 'You must provide a valid port number',
agentenabled: 'Enable Agent', agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
userEmailRequired: 'Require user email', userEmailRequired: 'Require user email',
emailsender: 'Sender Address', emailsender: 'Sender Address',
smtpHost: 'SMTP Host', smtpHost: 'SMTP Host',
@@ -122,6 +123,7 @@ const NotificationsEmail = () => {
<Formik <Formik
initialValues={{ initialValues={{
enabled: data.enabled, enabled: data.enabled,
embedPoster: data.embedPoster,
userEmailRequired: data.options.userEmailRequired, userEmailRequired: data.options.userEmailRequired,
emailFrom: data.options.emailFrom, emailFrom: data.options.emailFrom,
smtpHost: data.options.smtpHost, smtpHost: data.options.smtpHost,
@@ -145,6 +147,7 @@ const NotificationsEmail = () => {
try { try {
await axios.post('/api/v1/settings/notifications/email', { await axios.post('/api/v1/settings/notifications/email', {
enabled: values.enabled, enabled: values.enabled,
embedPoster: values.embedPoster,
options: { options: {
userEmailRequired: values.userEmailRequired, userEmailRequired: values.userEmailRequired,
emailFrom: values.emailFrom, emailFrom: values.emailFrom,
@@ -194,6 +197,7 @@ const NotificationsEmail = () => {
); );
await axios.post('/api/v1/settings/notifications/email/test', { await axios.post('/api/v1/settings/notifications/email/test', {
enabled: true, enabled: true,
embedPoster: values.embedPoster,
options: { options: {
emailFrom: values.emailFrom, emailFrom: values.emailFrom,
smtpHost: values.smtpHost, smtpHost: values.smtpHost,
@@ -241,6 +245,14 @@ const NotificationsEmail = () => {
<Field type="checkbox" id="enabled" name="enabled" /> <Field type="checkbox" id="enabled" name="enabled" />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="userEmailRequired" className="checkbox-label"> <label htmlFor="userEmailRequired" className="checkbox-label">
{intl.formatMessage(messages.userEmailRequired)} {intl.formatMessage(messages.userEmailRequired)}

View File

@@ -19,6 +19,7 @@ const messages = defineMessages(
'components.Settings.Notifications.NotificationsNtfy', 'components.Settings.Notifications.NotificationsNtfy',
{ {
agentenabled: 'Enable Agent', agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
url: 'Server root URL', url: 'Server root URL',
topic: 'Topic', topic: 'Topic',
usernamePasswordAuth: 'Username + Password authentication', usernamePasswordAuth: 'Username + Password authentication',
@@ -80,6 +81,7 @@ const NotificationsNtfy = () => {
<Formik <Formik
initialValues={{ initialValues={{
enabled: data?.enabled, enabled: data?.enabled,
embedPoster: data?.embedPoster,
types: data?.types, types: data?.types,
url: data?.options.url, url: data?.options.url,
topic: data?.options.topic, topic: data?.options.topic,
@@ -94,6 +96,7 @@ const NotificationsNtfy = () => {
try { try {
await axios.post('/api/v1/settings/notifications/ntfy', { await axios.post('/api/v1/settings/notifications/ntfy', {
enabled: values.enabled, enabled: values.enabled,
embedPoster: values.embedPoster,
types: values.types, types: values.types,
options: { options: {
url: values.url, url: values.url,
@@ -188,6 +191,14 @@ const NotificationsNtfy = () => {
<Field type="checkbox" id="enabled" name="enabled" /> <Field type="checkbox" id="enabled" name="enabled" />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="url" className="text-label"> <label htmlFor="url" className="text-label">
{intl.formatMessage(messages.url)} {intl.formatMessage(messages.url)}

View File

@@ -17,6 +17,7 @@ const messages = defineMessages(
'components.Settings.Notifications.NotificationsPushover', 'components.Settings.Notifications.NotificationsPushover',
{ {
agentenabled: 'Enable Agent', agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
accessToken: 'Application API Token', accessToken: 'Application API Token',
accessTokenTip: accessTokenTip:
'<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr', '<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr',
@@ -86,6 +87,7 @@ const NotificationsPushover = () => {
<Formik <Formik
initialValues={{ initialValues={{
enabled: data?.enabled, enabled: data?.enabled,
embedPoster: data?.embedPoster,
types: data?.types, types: data?.types,
accessToken: data?.options.accessToken, accessToken: data?.options.accessToken,
userToken: data?.options.userToken, userToken: data?.options.userToken,
@@ -96,6 +98,7 @@ const NotificationsPushover = () => {
try { try {
await axios.post('/api/v1/settings/notifications/pushover', { await axios.post('/api/v1/settings/notifications/pushover', {
enabled: values.enabled, enabled: values.enabled,
embedPoster: values.embedPoster,
types: values.types, types: values.types,
options: { options: {
accessToken: values.accessToken, accessToken: values.accessToken,
@@ -142,6 +145,7 @@ const NotificationsPushover = () => {
); );
await axios.post('/api/v1/settings/notifications/pushover/test', { await axios.post('/api/v1/settings/notifications/pushover/test', {
enabled: true, enabled: true,
embedPoster: values.embedPoster,
types: values.types, types: values.types,
options: { options: {
accessToken: values.accessToken, accessToken: values.accessToken,
@@ -181,6 +185,14 @@ const NotificationsPushover = () => {
<Field type="checkbox" id="enabled" name="enabled" /> <Field type="checkbox" id="enabled" name="enabled" />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="accessToken" className="text-label"> <label htmlFor="accessToken" className="text-label">
{intl.formatMessage(messages.accessToken)} {intl.formatMessage(messages.accessToken)}

View File

@@ -16,6 +16,7 @@ const messages = defineMessages(
'components.Settings.Notifications.NotificationsSlack', 'components.Settings.Notifications.NotificationsSlack',
{ {
agentenabled: 'Enable Agent', agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
webhookUrl: 'Webhook URL', webhookUrl: 'Webhook URL',
webhookUrlTip: webhookUrlTip:
'Create an <WebhookLink>Incoming Webhook</WebhookLink> integration', 'Create an <WebhookLink>Incoming Webhook</WebhookLink> integration',
@@ -59,6 +60,7 @@ const NotificationsSlack = () => {
<Formik <Formik
initialValues={{ initialValues={{
enabled: data.enabled, enabled: data.enabled,
embedPoster: data.embedPoster,
types: data.types, types: data.types,
webhookUrl: data.options.webhookUrl, webhookUrl: data.options.webhookUrl,
}} }}
@@ -67,6 +69,7 @@ const NotificationsSlack = () => {
try { try {
await axios.post('/api/v1/settings/notifications/slack', { await axios.post('/api/v1/settings/notifications/slack', {
enabled: values.enabled, enabled: values.enabled,
embedPoster: values.embedPoster,
types: values.types, types: values.types,
options: { options: {
webhookUrl: values.webhookUrl, webhookUrl: values.webhookUrl,
@@ -111,6 +114,7 @@ const NotificationsSlack = () => {
); );
await axios.post('/api/v1/settings/notifications/slack/test', { await axios.post('/api/v1/settings/notifications/slack/test', {
enabled: true, enabled: true,
embedPoster: values.embedPoster,
types: values.types, types: values.types,
options: { options: {
webhookUrl: values.webhookUrl, webhookUrl: values.webhookUrl,
@@ -148,6 +152,14 @@ const NotificationsSlack = () => {
<Field type="checkbox" id="enabled" name="enabled" /> <Field type="checkbox" id="enabled" name="enabled" />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="name" className="text-label"> <label htmlFor="name" className="text-label">
{intl.formatMessage(messages.webhookUrl)} {intl.formatMessage(messages.webhookUrl)}

View File

@@ -15,6 +15,7 @@ import * as Yup from 'yup';
const messages = defineMessages('components.Settings.Notifications', { const messages = defineMessages('components.Settings.Notifications', {
agentenabled: 'Enable Agent', agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
botUsername: 'Bot Username', botUsername: 'Bot Username',
botUsernameTip: botUsernameTip:
'Allow users to also start a chat with your bot and configure their own notifications', 'Allow users to also start a chat with your bot and configure their own notifications',
@@ -89,6 +90,7 @@ const NotificationsTelegram = () => {
<Formik <Formik
initialValues={{ initialValues={{
enabled: data?.enabled, enabled: data?.enabled,
embedPoster: data?.embedPoster,
types: data?.types, types: data?.types,
botUsername: data?.options.botUsername, botUsername: data?.options.botUsername,
botAPI: data?.options.botAPI, botAPI: data?.options.botAPI,
@@ -101,6 +103,7 @@ const NotificationsTelegram = () => {
try { try {
await axios.post('/api/v1/settings/notifications/telegram', { await axios.post('/api/v1/settings/notifications/telegram', {
enabled: values.enabled, enabled: values.enabled,
embedPoster: values.embedPoster,
types: values.types, types: values.types,
options: { options: {
botAPI: values.botAPI, botAPI: values.botAPI,
@@ -191,6 +194,14 @@ const NotificationsTelegram = () => {
<Field type="checkbox" id="enabled" name="enabled" /> <Field type="checkbox" id="enabled" name="enabled" />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="botAPI" className="text-label"> <label htmlFor="botAPI" className="text-label">
{intl.formatMessage(messages.botAPI)} {intl.formatMessage(messages.botAPI)}

View File

@@ -15,6 +15,7 @@ const messages = defineMessages(
'components.Settings.Notifications.NotificationsWebPush', 'components.Settings.Notifications.NotificationsWebPush',
{ {
agentenabled: 'Enable Agent', agentenabled: 'Enable Agent',
embedPoster: 'Embed Poster',
webpushsettingssaved: 'Web push notification settings saved successfully!', webpushsettingssaved: 'Web push notification settings saved successfully!',
webpushsettingsfailed: 'Web push notification settings failed to save.', webpushsettingsfailed: 'Web push notification settings failed to save.',
toastWebPushTestSending: 'Sending web push test notification…', toastWebPushTestSending: 'Sending web push test notification…',
@@ -55,11 +56,13 @@ const NotificationsWebPush = () => {
<Formik <Formik
initialValues={{ initialValues={{
enabled: data.enabled, enabled: data.enabled,
embedPoster: data.embedPoster,
}} }}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post('/api/v1/settings/notifications/webpush', { await axios.post('/api/v1/settings/notifications/webpush', {
enabled: values.enabled, enabled: values.enabled,
embedPoster: values.embedPoster,
options: {}, options: {},
}); });
mutate('/api/v1/settings/public'); mutate('/api/v1/settings/public');
@@ -77,7 +80,7 @@ const NotificationsWebPush = () => {
} }
}} }}
> >
{({ isSubmitting }) => { {({ isSubmitting, values }) => {
const testSettings = async () => { const testSettings = async () => {
setIsTesting(true); setIsTesting(true);
let toastId: string | undefined; let toastId: string | undefined;
@@ -94,6 +97,7 @@ const NotificationsWebPush = () => {
); );
await axios.post('/api/v1/settings/notifications/webpush/test', { await axios.post('/api/v1/settings/notifications/webpush/test', {
enabled: true, enabled: true,
embedPoster: values.embedPoster,
options: {}, options: {},
}); });
@@ -128,6 +132,15 @@ const NotificationsWebPush = () => {
<Field type="checkbox" id="enabled" name="enabled" /> <Field type="checkbox" id="enabled" name="enabled" />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="embedPoster" className="checkbox-label">
{intl.formatMessage(messages.embedPoster)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<Field type="checkbox" id="embedPoster" name="embedPoster" />
</div>
</div>
<div className="actions"> <div className="actions">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm"> <span className="ml-3 inline-flex rounded-md shadow-sm">

View File

@@ -1,6 +1,7 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector'; import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import SettingsBadge from '@app/components/Settings/SettingsBadge';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { isValidURL } from '@app/utils/urlValidationHelper'; import { isValidURL } from '@app/utils/urlValidationHelper';
@@ -73,6 +74,11 @@ const messages = defineMessages(
{ {
agentenabled: 'Enable Agent', agentenabled: 'Enable Agent',
webhookUrl: 'Webhook URL', webhookUrl: 'Webhook URL',
webhookUrlTip:
'Test Notification URL is set to {testUrl} instead of the actual webhook URL.',
supportVariables: 'Support URL Variables',
supportVariablesTip:
'Available variables are documented in the webhook template variables section',
authheader: 'Authorization Header', authheader: 'Authorization Header',
validationJsonPayloadRequired: 'You must provide a valid JSON payload', validationJsonPayloadRequired: 'You must provide a valid JSON payload',
webhooksettingssaved: 'Webhook notification settings saved successfully!', webhooksettingssaved: 'Webhook notification settings saved successfully!',
@@ -111,8 +117,14 @@ const NotificationsWebhook = () => {
.test( .test(
'valid-url', 'valid-url',
intl.formatMessage(messages.validationWebhookUrl), intl.formatMessage(messages.validationWebhookUrl),
isValidURL function (value) {
const { supportVariables } = this.parent;
return supportVariables || isValidURL(value);
}
), ),
supportVariables: Yup.boolean(),
jsonPayload: Yup.string() jsonPayload: Yup.string()
.when('enabled', { .when('enabled', {
is: true, is: true,
@@ -147,6 +159,7 @@ const NotificationsWebhook = () => {
webhookUrl: data.options.webhookUrl, webhookUrl: data.options.webhookUrl,
jsonPayload: data.options.jsonPayload, jsonPayload: data.options.jsonPayload,
authHeader: data.options.authHeader, authHeader: data.options.authHeader,
supportVariables: data.options.supportVariables ?? false,
}} }}
validationSchema={NotificationsWebhookSchema} validationSchema={NotificationsWebhookSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
@@ -158,6 +171,7 @@ const NotificationsWebhook = () => {
webhookUrl: values.webhookUrl, webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload), jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader, authHeader: values.authHeader,
supportVariables: values.supportVariables,
}, },
}); });
addToast(intl.formatMessage(messages.webhooksettingssaved), { addToast(intl.formatMessage(messages.webhooksettingssaved), {
@@ -215,6 +229,7 @@ const NotificationsWebhook = () => {
webhookUrl: values.webhookUrl, webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload), jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader, authHeader: values.authHeader,
supportVariables: values.supportVariables ?? false,
}, },
}); });
@@ -249,10 +264,59 @@ const NotificationsWebhook = () => {
<Field type="checkbox" id="enabled" name="enabled" /> <Field type="checkbox" id="enabled" name="enabled" />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="supportVariables" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.supportVariables)}
</span>
<SettingsBadge badgeType="experimental" />
<span className="label-tip">
{intl.formatMessage(messages.supportVariablesTip)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="supportVariables"
name="supportVariables"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setFieldValue('supportVariables', e.target.checked)
}
/>
</div>
</div>
{values.supportVariables && (
<div className="mt-2">
<Link
href="https://docs.jellyseerr.dev/using-jellyseerr/notifications/webhook#template-variables"
passHref
legacyBehavior
>
<Button
as="a"
buttonSize="sm"
target="_blank"
rel="noreferrer"
>
<QuestionMarkCircleIcon />
<span>
{intl.formatMessage(messages.templatevariablehelp)}
</span>
</Button>
</Link>
</div>
)}
<div className="form-row"> <div className="form-row">
<label htmlFor="webhookUrl" className="text-label"> <label htmlFor="webhookUrl" className="text-label">
{intl.formatMessage(messages.webhookUrl)} {intl.formatMessage(messages.webhookUrl)}
<span className="label-required">*</span> <span className="label-required">*</span>
{values.supportVariables && (
<div className="label-tip">
{intl.formatMessage(messages.webhookUrlTip, {
testUrl: '/test',
})}
</div>
)}
</label> </label>
<div className="form-input-area"> <div className="form-input-area">
<div className="form-input-field"> <div className="form-input-field">
@@ -312,7 +376,7 @@ const NotificationsWebhook = () => {
<span>{intl.formatMessage(messages.resetPayload)}</span> <span>{intl.formatMessage(messages.resetPayload)}</span>
</Button> </Button>
<Link <Link
href="https://docs.overseerr.dev/using-overseerr/notifications/webhooks#template-variables" href="https://docs.jellyseerr.dev/using-jellyseerr/notifications/webhook#template-variables"
passHref passHref
legacyBehavior legacyBehavior
> >

View File

@@ -7,7 +7,6 @@ import LanguageSelector from '@app/components/LanguageSelector';
import RegionSelector from '@app/components/RegionSelector'; import RegionSelector from '@app/components/RegionSelector';
import CopyButton from '@app/components/Settings/CopyButton'; import CopyButton from '@app/components/Settings/CopyButton';
import SettingsBadge from '@app/components/Settings/SettingsBadge'; import SettingsBadge from '@app/components/Settings/SettingsBadge';
import type { AvailableLocale } from '@app/context/LanguageContext';
import { availableLanguages } from '@app/context/LanguageContext'; import { availableLanguages } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale'; import useLocale from '@app/hooks/useLocale';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
@@ -18,6 +17,7 @@ import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ArrowPathIcon } from '@heroicons/react/24/solid'; import { ArrowPathIcon } from '@heroicons/react/24/solid';
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces'; import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
import type { MainSettings } from '@server/lib/settings'; import type { MainSettings } from '@server/lib/settings';
import type { AvailableLocale } from '@server/types/languages';
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';

View File

@@ -126,7 +126,7 @@ const SettingsNetwork = () => {
proxy: { proxy: {
enabled: values.proxyEnabled, enabled: values.proxyEnabled,
hostname: values.proxyHostname, hostname: values.proxyHostname,
port: values.proxyPort, port: Number(values.proxyPort),
useSsl: values.proxySsl, useSsl: values.proxySsl,
user: values.proxyUser, user: values.proxyUser,
password: values.proxyPassword, password: values.proxyPassword,

View File

@@ -5,7 +5,6 @@ import PageTitle from '@app/components/Common/PageTitle';
import LanguageSelector from '@app/components/LanguageSelector'; import LanguageSelector from '@app/components/LanguageSelector';
import QuotaSelector from '@app/components/QuotaSelector'; import QuotaSelector from '@app/components/QuotaSelector';
import RegionSelector from '@app/components/RegionSelector'; import RegionSelector from '@app/components/RegionSelector';
import type { AvailableLocale } from '@app/context/LanguageContext';
import { availableLanguages } from '@app/context/LanguageContext'; import { availableLanguages } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale'; import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
@@ -16,6 +15,7 @@ import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ApiErrorCode } from '@server/constants/error'; import { ApiErrorCode } from '@server/constants/error';
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces'; import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
import type { AvailableLocale } from '@server/types/languages';
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';

View File

@@ -1,41 +1,6 @@
import { type AvailableLocale } from '@server/types/languages';
import React from 'react'; import React from 'react';
export type AvailableLocale =
| 'ar'
| 'bg'
| 'ca'
| 'cs'
| 'da'
| 'de'
| 'en'
| 'el'
| 'es'
| 'es-MX'
| 'fi'
| 'fr'
| 'hr'
| 'he'
| 'hi'
| 'hu'
| 'it'
| 'ja'
| 'ko'
| 'lt'
| 'nb-NO'
| 'nl'
| 'pl'
| 'pt-BR'
| 'pt-PT'
| 'ro'
| 'ru'
| 'sq'
| 'sr'
| 'sv'
| 'tr'
| 'uk'
| 'zh-CN'
| 'zh-TW';
type AvailableLanguageObject = Record< type AvailableLanguageObject = Record<
string, string,
{ code: AvailableLocale; display: string } { code: AvailableLocale; display: string }

View File

@@ -180,6 +180,7 @@
"components.IssueDetails.toaststatusupdated": "Issue status updated successfully!", "components.IssueDetails.toaststatusupdated": "Issue status updated successfully!",
"components.IssueDetails.toaststatusupdatefailed": "Something went wrong while updating the issue status.", "components.IssueDetails.toaststatusupdatefailed": "Something went wrong while updating the issue status.",
"components.IssueDetails.unknownissuetype": "Unknown", "components.IssueDetails.unknownissuetype": "Unknown",
"components.IssueList.IssueItem.descriptionpreview": "Issue Description",
"components.IssueList.IssueItem.episodes": "{episodeCount, plural, one {Episode} other {Episodes}}", "components.IssueList.IssueItem.episodes": "{episodeCount, plural, one {Episode} other {Episodes}}",
"components.IssueList.IssueItem.issuestatus": "Status", "components.IssueList.IssueItem.issuestatus": "Status",
"components.IssueList.IssueItem.issuetype": "Type", "components.IssueList.IssueItem.issuetype": "Type",
@@ -623,6 +624,7 @@
"components.Settings.Notifications.NotificationsGotify.validationUrlRequired": "You must provide a valid URL", "components.Settings.Notifications.NotificationsGotify.validationUrlRequired": "You must provide a valid URL",
"components.Settings.Notifications.NotificationsGotify.validationUrlTrailingSlash": "URL must not end in a trailing slash", "components.Settings.Notifications.NotificationsGotify.validationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.Notifications.NotificationsNtfy.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsNtfy.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsNtfy.embedPoster": "Embed Poster",
"components.Settings.Notifications.NotificationsNtfy.ntfysettingsfailed": "Ntfy notification settings failed to save.", "components.Settings.Notifications.NotificationsNtfy.ntfysettingsfailed": "Ntfy notification settings failed to save.",
"components.Settings.Notifications.NotificationsNtfy.ntfysettingssaved": "Ntfy notification settings saved successfully!", "components.Settings.Notifications.NotificationsNtfy.ntfysettingssaved": "Ntfy notification settings saved successfully!",
"components.Settings.Notifications.NotificationsNtfy.password": "Password", "components.Settings.Notifications.NotificationsNtfy.password": "Password",
@@ -653,6 +655,7 @@
"components.Settings.Notifications.NotificationsPushover.accessTokenTip": "<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr", "components.Settings.Notifications.NotificationsPushover.accessTokenTip": "<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr",
"components.Settings.Notifications.NotificationsPushover.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsPushover.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsPushover.deviceDefault": "Device Default", "components.Settings.Notifications.NotificationsPushover.deviceDefault": "Device Default",
"components.Settings.Notifications.NotificationsPushover.embedPoster": "Embed Poster",
"components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Pushover notification settings failed to save.", "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Pushover notification settings failed to save.",
"components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Pushover notification settings saved successfully!", "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Pushover notification settings saved successfully!",
"components.Settings.Notifications.NotificationsPushover.sound": "Notification Sound", "components.Settings.Notifications.NotificationsPushover.sound": "Notification Sound",
@@ -665,6 +668,7 @@
"components.Settings.Notifications.NotificationsPushover.validationTypes": "You must select at least one notification type", "components.Settings.Notifications.NotificationsPushover.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "You must provide a valid user or group key", "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "You must provide a valid user or group key",
"components.Settings.Notifications.NotificationsSlack.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsSlack.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsSlack.embedPoster": "Embed Poster",
"components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Slack notification settings failed to save.", "components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Slack notification settings failed to save.",
"components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Slack notification settings saved successfully!", "components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Slack notification settings saved successfully!",
"components.Settings.Notifications.NotificationsSlack.toastSlackTestFailed": "Slack test notification failed to send.", "components.Settings.Notifications.NotificationsSlack.toastSlackTestFailed": "Slack test notification failed to send.",
@@ -679,6 +683,8 @@
"components.Settings.Notifications.NotificationsWebhook.customJson": "JSON Payload", "components.Settings.Notifications.NotificationsWebhook.customJson": "JSON Payload",
"components.Settings.Notifications.NotificationsWebhook.resetPayload": "Reset to Default", "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Reset to Default",
"components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON payload reset successfully!", "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON payload reset successfully!",
"components.Settings.Notifications.NotificationsWebhook.supportVariables": "Support URL Variables",
"components.Settings.Notifications.NotificationsWebhook.supportVariablesTip": "Available variables are documented in the webhook template variables section",
"components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Template Variable Help", "components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Template Variable Help",
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestFailed": "Webhook test notification failed to send.", "components.Settings.Notifications.NotificationsWebhook.toastWebhookTestFailed": "Webhook test notification failed to send.",
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSending": "Sending webhook test notification…", "components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSending": "Sending webhook test notification…",
@@ -687,9 +693,11 @@
"components.Settings.Notifications.NotificationsWebhook.validationTypes": "You must select at least one notification type", "components.Settings.Notifications.NotificationsWebhook.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.NotificationsWebhook.validationWebhookUrl": "You must provide a valid URL", "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrl": "You must provide a valid URL",
"components.Settings.Notifications.NotificationsWebhook.webhookUrl": "Webhook URL", "components.Settings.Notifications.NotificationsWebhook.webhookUrl": "Webhook URL",
"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.webhooksettingsfailed": "Webhook notification settings failed to save.",
"components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Webhook notification settings saved successfully!", "components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Webhook notification settings saved successfully!",
"components.Settings.Notifications.NotificationsWebPush.agentenabled": "Enable Agent", "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, Jellyseerr must be served over HTTPS.", "components.Settings.Notifications.NotificationsWebPush.httpsRequirement": "In order to receive web push notifications, Jellyseerr must be served over HTTPS.",
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestFailed": "Web push test notification failed to send.", "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.toastWebPushTestSending": "Sending web push test notification…",
@@ -712,6 +720,7 @@
"components.Settings.Notifications.emailsender": "Sender Address", "components.Settings.Notifications.emailsender": "Sender Address",
"components.Settings.Notifications.emailsettingsfailed": "Email notification settings failed to save.", "components.Settings.Notifications.emailsettingsfailed": "Email notification settings failed to save.",
"components.Settings.Notifications.emailsettingssaved": "Email notification settings saved successfully!", "components.Settings.Notifications.emailsettingssaved": "Email notification settings saved successfully!",
"components.Settings.Notifications.embedPoster": "Embed Poster",
"components.Settings.Notifications.enableMentions": "Enable Mentions", "components.Settings.Notifications.enableMentions": "Enable Mentions",
"components.Settings.Notifications.encryption": "Encryption Method", "components.Settings.Notifications.encryption": "Encryption Method",
"components.Settings.Notifications.encryptionDefault": "Use STARTTLS if available", "components.Settings.Notifications.encryptionDefault": "Use STARTTLS if available",
@@ -1157,6 +1166,7 @@
"components.Settings.menuServices": "Services", "components.Settings.menuServices": "Services",
"components.Settings.menuUsers": "Users", "components.Settings.menuUsers": "Users",
"components.Settings.metadataProviderSelection": "Metadata Provider Selection", "components.Settings.metadataProviderSelection": "Metadata Provider Selection",
"components.Settings.metadataProviderSettings": "Metadata Providers",
"components.Settings.metadataSettings": "Settings for metadata provider", "components.Settings.metadataSettings": "Settings for metadata provider",
"components.Settings.metadataSettingsSaved": "Metadata provider settings saved", "components.Settings.metadataSettingsSaved": "Metadata provider settings saved",
"components.Settings.no": "No", "components.Settings.no": "No",

View File

@@ -6,7 +6,6 @@ import StatusChecker from '@app/components/StatusChecker';
import Toast from '@app/components/Toast'; import Toast from '@app/components/Toast';
import ToastContainer from '@app/components/ToastContainer'; import ToastContainer from '@app/components/ToastContainer';
import { InteractionProvider } from '@app/context/InteractionContext'; import { InteractionProvider } from '@app/context/InteractionContext';
import type { AvailableLocale } from '@app/context/LanguageContext';
import { LanguageContext } from '@app/context/LanguageContext'; import { LanguageContext } from '@app/context/LanguageContext';
import { SettingsProvider } from '@app/context/SettingsContext'; import { SettingsProvider } from '@app/context/SettingsContext';
import { UserContext } from '@app/context/UserContext'; import { UserContext } from '@app/context/UserContext';
@@ -16,6 +15,7 @@ import '@app/styles/globals.css';
import { polyfillIntl } from '@app/utils/polyfillIntl'; import { polyfillIntl } from '@app/utils/polyfillIntl';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces'; import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';
import type { AvailableLocale } from '@server/types/languages';
import axios from 'axios'; import axios from 'axios';
import type { AppInitialProps, AppProps } from 'next/app'; import type { AppInitialProps, AppProps } from 'next/app';
import App from 'next/app'; import App from 'next/app';