From c1a47bd9de332cb4925974690f5a33448b5cc2e6 Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Mon, 12 Jun 2023 04:35:01 -0400 Subject: [PATCH 01/18] fix(ui): corrected issues icon color (#3498) --- src/components/Layout/Sidebar/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index d9f8ffd51..81ebb86c7 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -71,9 +71,7 @@ const SidebarLinks: SidebarLinkProps[] = [ { href: '/issues', messagesKey: 'issues', - svgIcon: ( - - ), + svgIcon: , activeRegExp: /^\/issues/, requiredPermission: [ Permission.MANAGE_ISSUES, From 2c3f5330764492e1323afd2d1f25e28ad78a2f2f Mon Sep 17 00:00:00 2001 From: Ryan Cohen Date: Tue, 13 Jun 2023 23:15:54 +0900 Subject: [PATCH 02/18] fix: adjust the plex watchlist sync schedule to have fuzziness (#3502) also fixes the schedule making it uneditable --- server/job/schedule.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 998abf1f4..932d6107f 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -8,6 +8,7 @@ import type { JobId } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import watchlistSync from '@server/lib/watchlistsync'; import logger from '@server/logger'; +import random from 'lodash/random'; import schedule from 'node-schedule'; interface ScheduledJob { @@ -60,21 +61,31 @@ export const startJobs = (): void => { cancelFn: () => plexFullScanner.cancel(), }); - // Run watchlist sync every 5 minutes - scheduledJobs.push({ + // Watchlist Sync + const watchlistSyncJob: ScheduledJob = { id: 'plex-watchlist-sync', name: 'Plex Watchlist Sync', type: 'process', - interval: 'minutes', + interval: 'fixed', cronSchedule: jobs['plex-watchlist-sync'].schedule, - job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => { + job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => { logger.info('Starting scheduled job: Plex Watchlist Sync', { label: 'Jobs', }); watchlistSync.syncWatchlist(); }), + }; + + // To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule + // after each run + watchlistSyncJob.job.on('run', () => { + watchlistSyncJob.job.schedule( + new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true))) + ); }); + scheduledJobs.push(watchlistSyncJob); + // Run full radarr scan every 24 hours scheduledJobs.push({ id: 'radarr-scan', From d0836ce0efd55fccf2546087a0c4f94f7cb2e82a Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Wed, 14 Jun 2023 01:43:08 -0400 Subject: [PATCH 03/18] fix: improved handling of edge case that could cause availability sync to fail (#3497) --- server/lib/availabilitySync.ts | 303 ++++++++++++++++++--------------- 1 file changed, 165 insertions(+), 138 deletions(-) diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index a9f61fff6..231dd9a20 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -30,23 +30,24 @@ class AvailabilitySync { this.sonarrSeasonsCache = {}; this.radarrServers = settings.radarr.filter((server) => server.syncEnabled); this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled); - await this.initPlexClient(); - if (!this.plexClient) { - return; - } + try { + await this.initPlexClient(); - logger.info(`Starting availability sync...`, { - label: 'AvailabilitySync', - }); - const mediaRepository = getRepository(Media); - const requestRepository = getRepository(MediaRequest); - const seasonRepository = getRepository(Season); - const seasonRequestRepository = getRepository(SeasonRequest); + if (!this.plexClient) { + return; + } - const pageSize = 50; + logger.info(`Starting availability sync...`, { + label: 'AvailabilitySync', + }); + const mediaRepository = getRepository(Media); + const requestRepository = getRepository(MediaRequest); + const seasonRepository = getRepository(Season); + const seasonRequestRepository = getRepository(SeasonRequest); + + const pageSize = 50; - try { for await (const media of this.loadAvailableMediaPaginated(pageSize)) { if (!this.running) { throw new Error('Job aborted'); @@ -239,51 +240,60 @@ class AvailabilitySync { const isTVType = media.mediaType === 'tv'; - const request = await requestRepository.findOne({ - relations: { - media: true, - }, - where: { media: { id: media.id }, is4k: is4k ? true : false }, - }); - - logger.info( - `Media ID ${media.id} does not exist in your ${is4k ? '4k' : 'non-4k'} ${ - isTVType ? 'Sonarr' : 'Radarr' - } and Plex instance. Status will be changed to unknown.`, - { label: 'AvailabilitySync' } - ); - - await mediaRepository.update( - media.id, - is4k - ? { - status4k: MediaStatus.UNKNOWN, - serviceId4k: null, - externalServiceId4k: null, - externalServiceSlug4k: null, - ratingKey4k: null, - } - : { - status: MediaStatus.UNKNOWN, - serviceId: null, - externalServiceId: null, - externalServiceSlug: null, - ratingKey: null, - } - ); + try { + const request = await requestRepository.findOne({ + relations: { + media: true, + }, + where: { media: { id: media.id }, is4k: is4k ? true : false }, + }); - if (isTVType) { - const seasonRepository = getRepository(Season); + logger.info( + `Media ID ${media.id} does not exist in your ${ + is4k ? '4k' : 'non-4k' + } ${ + isTVType ? 'Sonarr' : 'Radarr' + } and Plex instance. Status will be changed to unknown.`, + { label: 'AvailabilitySync' } + ); - await seasonRepository?.update( - { media: { id: media.id } }, + await mediaRepository.update( + media.id, is4k - ? { status4k: MediaStatus.UNKNOWN } - : { status: MediaStatus.UNKNOWN } + ? { + status4k: MediaStatus.UNKNOWN, + serviceId4k: null, + externalServiceId4k: null, + externalServiceSlug4k: null, + ratingKey4k: null, + } + : { + status: MediaStatus.UNKNOWN, + serviceId: null, + externalServiceId: null, + externalServiceSlug: null, + ratingKey: null, + } ); - } - await requestRepository.delete({ id: request?.id }); + if (isTVType) { + const seasonRepository = getRepository(Season); + + await seasonRepository?.update( + { media: { id: media.id } }, + is4k + ? { status4k: MediaStatus.UNKNOWN } + : { status: MediaStatus.UNKNOWN } + ); + } + + await requestRepository.delete({ id: request?.id }); + } catch (ex) { + logger.debug(`Failure updating media ID ${media.id}`, { + errorMessage: ex.message, + label: 'AvailabilitySync', + }); + } } private async mediaExistsInRadarr( @@ -539,83 +549,90 @@ class AvailabilitySync { } } - const seasonToBeDeleted = await seasonRequestRepository.findOne({ - relations: { - request: { - media: true, + try { + const seasonToBeDeleted = await seasonRequestRepository.findOne({ + relations: { + request: { + media: true, + }, }, - }, - where: { - request: { - is4k: seasonExistsInSonarr ? true : false, - media: { - id: media.id, + where: { + request: { + is4k: seasonExistsInSonarr ? true : false, + media: { + id: media.id, + }, }, + seasonNumber: season.seasonNumber, }, - seasonNumber: season.seasonNumber, - }, - }); - - // If season does not exist, we will change status to unknown and delete related season request - // If parent media request is empty(all related seasons have been removed), parent is automatically deleted - if ( - !seasonExistsInSonarr && - (seasonExistsInSonarr4k || seasonExistsInPlex4k) && - !seasonExistsInPlex - ) { - if (season.status !== MediaStatus.UNKNOWN) { - logger.info( - `Season ${season.seasonNumber}, media ID ${media.id} does not exist in your non-4k Sonarr and Plex instance. Status will be changed to unknown.`, - { label: 'AvailabilitySync' } - ); - await seasonRepository.update(season.id, { - status: MediaStatus.UNKNOWN, - }); - - if (seasonToBeDeleted) { - await seasonRequestRepository.remove(seasonToBeDeleted); - } + }); - if (media.status === MediaStatus.AVAILABLE) { + // If season does not exist, we will change status to unknown and delete related season request + // If parent media request is empty(all related seasons have been removed), parent is automatically deleted + if ( + !seasonExistsInSonarr && + (seasonExistsInSonarr4k || seasonExistsInPlex4k) && + !seasonExistsInPlex + ) { + if (season.status !== MediaStatus.UNKNOWN) { logger.info( - `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`, + `Season ${season.seasonNumber}, media ID ${media.id} does not exist in your non-4k Sonarr and Plex instance. Status will be changed to unknown.`, { label: 'AvailabilitySync' } ); - await mediaRepository.update(media.id, { - status: MediaStatus.PARTIALLY_AVAILABLE, + await seasonRepository.update(season.id, { + status: MediaStatus.UNKNOWN, }); - } - } - } - if ( - (seasonExistsInSonarr || seasonExistsInPlex) && - !seasonExistsInSonarr4k && - !seasonExistsInPlex4k - ) { - if (season.status4k !== MediaStatus.UNKNOWN) { - logger.info( - `Season ${season.seasonNumber}, media ID ${media.id} does not exist in your 4k Sonarr and Plex instance. Status will be changed to unknown.`, - { label: 'AvailabilitySync' } - ); - await seasonRepository.update(season.id, { - status4k: MediaStatus.UNKNOWN, - }); + if (seasonToBeDeleted) { + await seasonRequestRepository.remove(seasonToBeDeleted); + } - if (seasonToBeDeleted) { - await seasonRequestRepository.remove(seasonToBeDeleted); + if (media.status === MediaStatus.AVAILABLE) { + logger.info( + `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`, + { label: 'AvailabilitySync' } + ); + await mediaRepository.update(media.id, { + status: MediaStatus.PARTIALLY_AVAILABLE, + }); + } } + } - if (media.status4k === MediaStatus.AVAILABLE) { + if ( + (seasonExistsInSonarr || seasonExistsInPlex) && + !seasonExistsInSonarr4k && + !seasonExistsInPlex4k + ) { + if (season.status4k !== MediaStatus.UNKNOWN) { logger.info( - `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`, + `Season ${season.seasonNumber}, media ID ${media.id} does not exist in your 4k Sonarr and Plex instance. Status will be changed to unknown.`, { label: 'AvailabilitySync' } ); - await mediaRepository.update(media.id, { - status4k: MediaStatus.PARTIALLY_AVAILABLE, + await seasonRepository.update(season.id, { + status4k: MediaStatus.UNKNOWN, }); + + if (seasonToBeDeleted) { + await seasonRequestRepository.remove(seasonToBeDeleted); + } + + if (media.status4k === MediaStatus.AVAILABLE) { + logger.info( + `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`, + { label: 'AvailabilitySync' } + ); + await mediaRepository.update(media.id, { + status4k: MediaStatus.PARTIALLY_AVAILABLE, + }); + } } } + } catch (ex) { + logger.debug(`Failure updating media ID ${media.id}`, { + errorMessage: ex.message, + label: 'AvailabilitySync', + }); } if ( @@ -654,7 +671,10 @@ class AvailabilitySync { } } catch (ex) { if (!ex.message.includes('response code: 404')) { - throw ex; + logger.debug(`Failed to retrieve plex metadata`, { + errorMessage: ex.message, + label: 'AvailabilitySync', + }); } } // Base case if both media versions exist in plex @@ -714,36 +734,43 @@ class AvailabilitySync { let seasonExistsInPlex = false; let seasonExistsInPlex4k = false; - if (ratingKey) { - const children = - this.plexSeasonsCache[ratingKey] ?? - (await this.plexClient?.getChildrenMetadata(ratingKey)) ?? - []; - this.plexSeasonsCache[ratingKey] = children; - const seasonMeta = children?.find( - (child) => child.index === season.seasonNumber - ); + try { + if (ratingKey) { + const children = + this.plexSeasonsCache[ratingKey] ?? + (await this.plexClient?.getChildrenMetadata(ratingKey)) ?? + []; + this.plexSeasonsCache[ratingKey] = children; + const seasonMeta = children?.find( + (child) => child.index === season.seasonNumber + ); - if (seasonMeta) { - seasonExistsInPlex = true; + if (seasonMeta) { + seasonExistsInPlex = true; + } } - } - - if (ratingKey4k) { - const children4k = - this.plexSeasonsCache[ratingKey4k] ?? - (await this.plexClient?.getChildrenMetadata(ratingKey4k)) ?? - []; - this.plexSeasonsCache[ratingKey4k] = children4k; - const seasonMeta4k = children4k?.find( - (child) => child.index === season.seasonNumber - ); + if (ratingKey4k) { + const children4k = + this.plexSeasonsCache[ratingKey4k] ?? + (await this.plexClient?.getChildrenMetadata(ratingKey4k)) ?? + []; + this.plexSeasonsCache[ratingKey4k] = children4k; + const seasonMeta4k = children4k?.find( + (child) => child.index === season.seasonNumber + ); - if (seasonMeta4k) { - seasonExistsInPlex4k = true; + if (seasonMeta4k) { + seasonExistsInPlex4k = true; + } + } + } catch (ex) { + if (!ex.message.includes('response code: 404')) { + logger.debug(`Failed to retrieve plex's children metadata`, { + errorMessage: ex.message, + label: 'AvailabilitySync', + }); } } - // Base case if both season versions exist in plex if (seasonExistsInPlex && seasonExistsInPlex4k) { return true; From a761b7dd35a5bd61bb4eb0275b75d1e0977e6a2d Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Wed, 21 Jun 2023 13:18:50 -0400 Subject: [PATCH 04/18] fix: resolved issue with create slider causing incorrect form submission (#3514) --- src/components/Selector/index.tsx | 1 + src/components/Slider/index.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index 78ae33ea1..7b2165872 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -437,6 +437,7 @@ export const WatchProviderSelector = ({ {otherProviders.length > 0 && ( @@ -165,6 +166,7 @@ const Slider = ({ }`} onClick={() => slide(Direction.RIGHT)} disabled={scrollPos.isEnd} + type="button" > From 01de972a8fe2ea3c18d5b2f426d01b5b14d142d4 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Thu, 29 Jun 2023 09:34:10 -0700 Subject: [PATCH 05/18] fix(statusbadge): handle missing season/episode number (#3526) --- src/components/StatusBadge/index.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index 3dbe6e74b..b60b7af04 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -166,11 +166,11 @@ const StatusBadge = ({ {inProgress && ( <> - {mediaType === 'tv' && ( + {mediaType === 'tv' && downloadItem[0].episode && ( {intl.formatMessage(messages.seasonepisodenumber, { - seasonNumber: downloadItem[0].episode?.seasonNumber, - episodeNumber: downloadItem[0].episode?.episodeNumber, + seasonNumber: downloadItem[0].episode.seasonNumber, + episodeNumber: downloadItem[0].episode.episodeNumber, })} )} @@ -219,11 +219,11 @@ const StatusBadge = ({ {inProgress && ( <> - {mediaType === 'tv' && ( + {mediaType === 'tv' && downloadItem[0].episode && ( {intl.formatMessage(messages.seasonepisodenumber, { - seasonNumber: downloadItem[0].episode?.seasonNumber, - episodeNumber: downloadItem[0].episode?.episodeNumber, + seasonNumber: downloadItem[0].episode.seasonNumber, + episodeNumber: downloadItem[0].episode.episodeNumber, })} )} @@ -272,11 +272,11 @@ const StatusBadge = ({ {inProgress && ( <> - {mediaType === 'tv' && ( + {mediaType === 'tv' && downloadItem[0].episode && ( {intl.formatMessage(messages.seasonepisodenumber, { - seasonNumber: downloadItem[0].episode?.seasonNumber, - episodeNumber: downloadItem[0].episode?.episodeNumber, + seasonNumber: downloadItem[0].episode.seasonNumber, + episodeNumber: downloadItem[0].episode.episodeNumber, })} )} From 2816c66300bf870d493c0665b0e984d60f707dfd Mon Sep 17 00:00:00 2001 From: "Anton K. (ai Doge)" Date: Tue, 18 Jul 2023 01:03:52 -0400 Subject: [PATCH 06/18] fix: resolved user access check issue (#3551) * fix: importing friends update checkUserAccess to use getUsers * refactor(server/api/plextv.ts): clean up removed unused getFriends function, and its interface. renamed friends variable. --- server/api/plextv.ts | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 76ee66188..704926895 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -82,21 +82,6 @@ interface ServerResponse { }; } -interface FriendResponse { - MediaContainer: { - User: { - $: { - id: string; - title: string; - username: string; - email: string; - thumb: string; - }; - Server?: ServerResponse[]; - }[]; - }; -} - interface UsersResponse { MediaContainer: { User: { @@ -234,19 +219,6 @@ class PlexTvAPI extends ExternalAPI { } } - public async getFriends(): Promise { - const response = await this.axios.get('/pms/friends/all', { - transformResponse: [], - responseType: 'text', - }); - - const parsedXml = (await xml2js.parseStringPromise( - response.data - )) as FriendResponse; - - return parsedXml; - } - public async checkUserAccess(userId: number): Promise { const settings = getSettings(); @@ -255,9 +227,9 @@ class PlexTvAPI extends ExternalAPI { throw new Error('Plex is not configured!'); } - const friends = await this.getFriends(); + const usersResponse = await this.getUsers(); - const users = friends.MediaContainer.User; + const users = usersResponse.MediaContainer.User; const user = users.find((u) => parseInt(u.$.id) === userId); From 68c7b3650ec82437bdb128f72f734e227ad763cb Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 18 Jul 2023 05:17:07 +0000 Subject: [PATCH 07/18] docs: add scorp200 as a contributor for code (#3555) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 371872759..f29044994 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -872,6 +872,15 @@ "contributions": [ "code" ] + }, + { + "login": "scorp200", + "name": "Anton K. (ai Doge)", + "avatar_url": "https://avatars.githubusercontent.com/u/9427639?v=4", + "profile": "http://aidoge.xyz", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index 6fe73c27d..0f6e32413 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors

@@ -196,6 +196,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Salman Tariq
Salman Tariq

đź’» Andrew Kennedy
Andrew Kennedy

đź’» Fallenbagel
Fallenbagel

đź’» + Anton K. (ai Doge)
Anton K. (ai Doge)

đź’» From 83b008c8391459bd02dc74bcdb0d8caf27207bdf Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Mon, 24 Jul 2023 02:33:12 -0400 Subject: [PATCH 08/18] fix: handle issue causing incorrect media to change to unknown (#3516) * fix: handle issue causing incorrect media to change back to unknown * fix: prevent start if plex client is unavailable * fix: initialize radarr and sonarr clients before the scan starts * fix: compensate for multiple *arr servers * fix: added a more reliable season lookup * refactor: modified tuples to increase code readability --- server/lib/availabilitySync.ts | 1096 +++++++++++++++----------------- 1 file changed, 502 insertions(+), 594 deletions(-) diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 231dd9a20..0a16302cc 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -1,14 +1,13 @@ import type { PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; -import type { RadarrMovie } from '@server/api/servarr/radarr'; -import RadarrAPI from '@server/api/servarr/radarr'; +import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr'; import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; -import { MediaStatus } from '@server/constants/media'; +import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import MediaRequest from '@server/entity/MediaRequest'; -import Season from '@server/entity/Season'; +import type Season from '@server/entity/Season'; import SeasonRequest from '@server/entity/SeasonRequest'; import { User } from '@server/entity/User'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; @@ -18,8 +17,8 @@ import logger from '@server/logger'; class AvailabilitySync { public running = false; private plexClient: PlexAPI; - private plexSeasonsCache: Record = {}; - private sonarrSeasonsCache: Record = {}; + private plexSeasonsCache: Record; + private sonarrSeasonsCache: Record; private radarrServers: RadarrSettings[]; private sonarrServers: SonarrSettings[]; @@ -32,166 +31,176 @@ class AvailabilitySync { this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled); try { - await this.initPlexClient(); - - if (!this.plexClient) { - return; - } - logger.info(`Starting availability sync...`, { label: 'AvailabilitySync', }); - const mediaRepository = getRepository(Media); - const requestRepository = getRepository(MediaRequest); - const seasonRepository = getRepository(Season); - const seasonRequestRepository = getRepository(SeasonRequest); - const pageSize = 50; + const userRepository = getRepository(User); + const admin = await userRepository.findOne({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); + + if (admin) { + this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); + } else { + logger.error('An admin is not configured.'); + } + for await (const media of this.loadAvailableMediaPaginated(pageSize)) { if (!this.running) { throw new Error('Job aborted'); } - const mediaExists = await this.mediaExists(media); + // Check plex, radarr, and sonarr for that specific media and + // if unavailable, then we change the status accordingly. + // If a non-4k or 4k version exists in at least one of the instances, we will only update that specific version + if (media.mediaType === 'movie') { + let movieExists = false; + let movieExists4k = false; + + const { existsInPlex } = await this.mediaExistsInPlex(media, false); + const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex( + media, + true + ); + + const existsInRadarr = await this.mediaExistsInRadarr(media, false); + const existsInRadarr4k = await this.mediaExistsInRadarr(media, true); - // We can not delete media so if both versions do not exist, we will change both columns to unknown or null - if (!mediaExists) { - if ( - media.status !== MediaStatus.UNKNOWN || - media.status4k !== MediaStatus.UNKNOWN - ) { - const request = await requestRepository.find({ - relations: { - media: true, - }, - where: { media: { id: media.id } }, - }); + if (existsInPlex || existsInRadarr) { + movieExists = true; + logger.info( + `The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } + ); + } + if (existsInPlex4k || existsInRadarr4k) { + movieExists4k = true; logger.info( - `Media ID ${media.id} does not exist in any of your media instances. Status will be changed to unknown.`, - { label: 'AvailabilitySync' } + `The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } ); + } - await mediaRepository.update(media.id, { - status: MediaStatus.UNKNOWN, - status4k: MediaStatus.UNKNOWN, - serviceId: null, - serviceId4k: null, - externalServiceId: null, - externalServiceId4k: null, - externalServiceSlug: null, - externalServiceSlug4k: null, - ratingKey: null, - ratingKey4k: null, - }); - - await requestRepository.remove(request); + if (!movieExists && media.status === MediaStatus.AVAILABLE) { + await this.mediaUpdater(media, false); + } + + if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) { + await this.mediaUpdater(media, true); } } + // If both versions still exist in plex, we still need + // to check through sonarr to verify season availability if (media.mediaType === 'tv') { - // ok, the show itself exists, but do all it's seasons? - const seasons = await seasonRepository.find({ - where: [ - { status: MediaStatus.AVAILABLE, media: { id: media.id } }, - { - status: MediaStatus.PARTIALLY_AVAILABLE, - media: { id: media.id }, - }, - { status4k: MediaStatus.AVAILABLE, media: { id: media.id } }, + let showExists = false; + let showExists4k = false; + + const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } = + await this.mediaExistsInPlex(media, false); + const { + existsInPlex: existsInPlex4k, + seasonsMap: plexSeasonsMap4k = new Map(), + } = await this.mediaExistsInPlex(media, true); + + const { existsInSonarr, seasonsMap: sonarrSeasonsMap } = + await this.mediaExistsInSonarr(media, false); + const { + existsInSonarr: existsInSonarr4k, + seasonsMap: sonarrSeasonsMap4k, + } = await this.mediaExistsInSonarr(media, true); + + if (existsInPlex || existsInSonarr) { + showExists = true; + logger.info( + `The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { - status4k: MediaStatus.PARTIALLY_AVAILABLE, - media: { id: media.id }, - }, - ], - }); - - let didDeleteSeasons = false; - for (const season of seasons) { - if ( - !mediaExists && - (season.status !== MediaStatus.UNKNOWN || - season.status4k !== MediaStatus.UNKNOWN) - ) { - await seasonRepository.update( - { id: season.id }, - { - status: MediaStatus.UNKNOWN, - status4k: MediaStatus.UNKNOWN, - } - ); - } else { - const seasonExists = await this.seasonExists(media, season); - - if (!seasonExists) { - logger.info( - `Removing season ${season.seasonNumber}, media ID ${media.id} because it does not exist in any of your media instances.`, - { label: 'AvailabilitySync' } - ); - - if ( - season.status !== MediaStatus.UNKNOWN || - season.status4k !== MediaStatus.UNKNOWN - ) { - await seasonRepository.update( - { id: season.id }, - { - status: MediaStatus.UNKNOWN, - status4k: MediaStatus.UNKNOWN, - } - ); - } - - const seasonToBeDeleted = await seasonRequestRepository.findOne( - { - relations: { - request: { - media: true, - }, - }, - where: { - request: { - media: { - id: media.id, - }, - }, - seasonNumber: season.seasonNumber, - }, - } - ); - - if (seasonToBeDeleted) { - await seasonRequestRepository.remove(seasonToBeDeleted); - } - - didDeleteSeasons = true; + label: 'AvailabilitySync', } - } + ); + } - if (didDeleteSeasons) { - if ( - media.status === MediaStatus.AVAILABLE || - media.status4k === MediaStatus.AVAILABLE - ) { - logger.info( - `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`, - { label: 'AvailabilitySync' } - ); - - if (media.status === MediaStatus.AVAILABLE) { - await mediaRepository.update(media.id, { - status: MediaStatus.PARTIALLY_AVAILABLE, - }); - } - - if (media.status4k === MediaStatus.AVAILABLE) { - await mediaRepository.update(media.id, { - status4k: MediaStatus.PARTIALLY_AVAILABLE, - }); - } + if (existsInPlex4k || existsInSonarr4k) { + showExists4k = true; + logger.info( + `The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', } - } + ); + } + + // Here we will create a final map that will cross compare + // with plex and sonarr. Filtered seasons will go through + // each season and assume the season does not exist. If Plex or + // Sonarr finds that season, we will change the final seasons value + // to true. + const filteredSeasonsMap: Map = new Map(); + + media.seasons + .filter( + (season) => + season.status === MediaStatus.AVAILABLE || + season.status === MediaStatus.PARTIALLY_AVAILABLE + ) + .forEach((season) => + filteredSeasonsMap.set(season.seasonNumber, false) + ); + + const finalSeasons = new Map([ + ...filteredSeasonsMap, + ...plexSeasonsMap, + ...sonarrSeasonsMap, + ]); + + const filteredSeasonsMap4k: Map = new Map(); + + media.seasons + .filter( + (season) => + season.status4k === MediaStatus.AVAILABLE || + season.status4k === MediaStatus.PARTIALLY_AVAILABLE + ) + .forEach((season) => + filteredSeasonsMap4k.set(season.seasonNumber, false) + ); + + const finalSeasons4k = new Map([ + ...filteredSeasonsMap4k, + ...plexSeasonsMap4k, + ...sonarrSeasonsMap4k, + ]); + + if ([...finalSeasons.values()].includes(false)) { + await this.seasonUpdater(media, finalSeasons, false); + } + + if ([...finalSeasons4k.values()].includes(false)) { + await this.seasonUpdater(media, finalSeasons4k, true); + } + + if ( + !showExists && + (media.status === MediaStatus.AVAILABLE || + media.status === MediaStatus.PARTIALLY_AVAILABLE) + ) { + await this.mediaUpdater(media, false); + } + + if ( + !showExists4k && + (media.status4k === MediaStatus.AVAILABLE || + media.status4k === MediaStatus.PARTIALLY_AVAILABLE) + ) { + await this.mediaUpdater(media, true); } } } @@ -234,582 +243,481 @@ class AvailabilitySync { } while (mediaPage.length > 0); } + private findMediaStatus( + requests: MediaRequest[], + is4k: boolean + ): MediaStatus { + const filteredRequests = requests.filter( + (request) => request.is4k === is4k + ); + + let mediaStatus: MediaStatus; + + if ( + filteredRequests.some( + (request) => request.status === MediaRequestStatus.APPROVED + ) + ) { + mediaStatus = MediaStatus.PROCESSING; + } else if ( + filteredRequests.some( + (request) => request.status === MediaRequestStatus.PENDING + ) + ) { + mediaStatus = MediaStatus.PENDING; + } else { + mediaStatus = MediaStatus.UNKNOWN; + } + + return mediaStatus; + } + private async mediaUpdater(media: Media, is4k: boolean): Promise { const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); - const isTVType = media.mediaType === 'tv'; - try { - const request = await requestRepository.findOne({ - relations: { - media: true, - }, - where: { media: { id: media.id }, is4k: is4k ? true : false }, - }); + // Find all related requests only if + // the related media has an available status + const requests = await requestRepository + .createQueryBuilder('request') + .leftJoinAndSelect('request.media', 'media') + .where('(media.id = :id)', { + id: media.id, + }) + .andWhere( + `(request.is4k = :is4k AND media.${ + is4k ? 'status4k' : 'status' + } IN (:...mediaStatus))`, + { + mediaStatus: [ + MediaStatus.AVAILABLE, + MediaStatus.PARTIALLY_AVAILABLE, + ], + is4k: is4k, + } + ) + .getMany(); + + // Check if a season is processing or pending to + // make sure we set the media to the correct status + let mediaStatus = MediaStatus.UNKNOWN; + + if (media.mediaType === 'tv') { + mediaStatus = this.findMediaStatus(requests, is4k); + } + + media[is4k ? 'status4k' : 'status'] = mediaStatus; + media[is4k ? 'serviceId4k' : 'serviceId'] = + mediaStatus === MediaStatus.PROCESSING + ? media[is4k ? 'serviceId4k' : 'serviceId'] + : null; + media[is4k ? 'externalServiceId4k' : 'externalServiceId'] = + mediaStatus === MediaStatus.PROCESSING + ? media[is4k ? 'externalServiceId4k' : 'externalServiceId'] + : null; + media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = + mediaStatus === MediaStatus.PROCESSING + ? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] + : null; + media[is4k ? 'ratingKey4k' : 'ratingKey'] = + mediaStatus === MediaStatus.PROCESSING + ? media[is4k ? 'ratingKey4k' : 'ratingKey'] + : null; logger.info( - `Media ID ${media.id} does not exist in your ${ - is4k ? '4k' : 'non-4k' - } ${ - isTVType ? 'Sonarr' : 'Radarr' + `The ${is4k ? '4K' : 'non-4K'} ${ + media.mediaType === 'movie' ? 'movie' : 'show' + } [TMDB ID ${media.tmdbId}] was not found in any ${ + media.mediaType === 'movie' ? 'Radarr' : 'Sonarr' } and Plex instance. Status will be changed to unknown.`, { label: 'AvailabilitySync' } ); - await mediaRepository.update( - media.id, - is4k - ? { - status4k: MediaStatus.UNKNOWN, - serviceId4k: null, - externalServiceId4k: null, - externalServiceSlug4k: null, - ratingKey4k: null, - } - : { - status: MediaStatus.UNKNOWN, - serviceId: null, - externalServiceId: null, - externalServiceSlug: null, - ratingKey: null, - } + await mediaRepository.save({ media, ...media }); + + // Only delete media request if type is movie. + // Type tv request deletion is handled + // in the season request entity + if (requests.length > 0 && media.mediaType === 'movie') { + await requestRepository.remove(requests); + } + } catch (ex) { + logger.debug( + `Failure updating the ${is4k ? '4K' : 'non-4K'} ${ + media.mediaType === 'tv' ? 'show' : 'movie' + } [TMDB ID ${media.tmdbId}].`, + { + errorMessage: ex.message, + label: 'AvailabilitySync', + } ); + } + } - if (isTVType) { - const seasonRepository = getRepository(Season); + private async seasonUpdater( + media: Media, + seasons: Map, + is4k: boolean + ): Promise { + const mediaRepository = getRepository(Media); + const seasonRequestRepository = getRepository(SeasonRequest); - await seasonRepository?.update( - { media: { id: media.id } }, - is4k - ? { status4k: MediaStatus.UNKNOWN } - : { status: MediaStatus.UNKNOWN } + const seasonsPendingRemoval = new Map( + // Disabled linter as only the value is needed from the filter + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [...seasons].filter(([_, exists]) => !exists) + ); + const seasonKeys = [...seasonsPendingRemoval.keys()]; + + try { + // Need to check and see if there are any related season + // requests. If they are, we will need to delete them. + const seasonRequests = await seasonRequestRepository + .createQueryBuilder('seasonRequest') + .leftJoinAndSelect('seasonRequest.request', 'request') + .leftJoinAndSelect('request.media', 'media') + .where('(media.id = :id)', { id: media.id }) + .andWhere( + '(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))', + { + seasonNumbers: seasonKeys, + is4k: is4k, + } + ) + .getMany(); + + for (const mediaSeason of media.seasons) { + if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) { + mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; + } + } + + if (media.status === MediaStatus.AVAILABLE) { + media.status = MediaStatus.PARTIALLY_AVAILABLE; + logger.info( + `Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`, + { label: 'AvailabilitySync' } ); } - await requestRepository.delete({ id: request?.id }); + if (media.status4k === MediaStatus.AVAILABLE) { + media.status4k = MediaStatus.PARTIALLY_AVAILABLE; + logger.info( + `Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`, + { label: 'AvailabilitySync' } + ); + } + + await mediaRepository.save({ media, ...media }); + + if (seasonRequests.length > 0) { + await seasonRequestRepository.remove(seasonRequests); + } + + logger.info( + `The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${ + media.tmdbId + }] was not found in any ${ + media.mediaType === 'tv' ? 'Sonarr' : 'Radarr' + } and Plex instance. Status will be changed to unknown.`, + { label: 'AvailabilitySync' } + ); } catch (ex) { - logger.debug(`Failure updating media ID ${media.id}`, { - errorMessage: ex.message, - label: 'AvailabilitySync', - }); + logger.debug( + `Failure updating the ${ + is4k ? '4K' : 'non-4K' + } season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`, + { + errorMessage: ex.message, + label: 'AvailabilitySync', + } + ); } } private async mediaExistsInRadarr( media: Media, - existsInPlex: boolean, - existsInPlex4k: boolean + is4k: boolean ): Promise { - let existsInRadarr = true; - let existsInRadarr4k = true; + let existsInRadarr = false; + // Check for availability in all of the available radarr servers + // If any find the media, we will assume the media exists for (const server of this.radarrServers) { - const api = new RadarrAPI({ + const radarrAPI = new RadarrAPI({ apiKey: server.apiKey, url: RadarrAPI.buildUrl(server, '/api/v3'), }); - try { - // Check if both exist or if a single non-4k or 4k exists - // If both do not exist we will return false - let meta: RadarrMovie | undefined; - - if (!server.is4k && media.externalServiceId) { - meta = await api.getMovie({ id: media.externalServiceId }); - } + try { + let radarr: RadarrMovie | undefined; - if (server.is4k && media.externalServiceId4k) { - meta = await api.getMovie({ id: media.externalServiceId4k }); + if (!server.is4k && media.externalServiceId && !is4k) { + radarr = await radarrAPI.getMovie({ + id: media.externalServiceId, + }); } - if (!server.is4k && (!meta || !meta.hasFile)) { - existsInRadarr = false; + if (server.is4k && media.externalServiceId4k && is4k) { + radarr = await radarrAPI.getMovie({ + id: media.externalServiceId4k, + }); } - if (server.is4k && (!meta || !meta.hasFile)) { - existsInRadarr4k = false; + if (radarr && radarr.hasFile) { + existsInRadarr = true; } } catch (ex) { - logger.debug( - `Failure retrieving media ID ${media.id} from your ${ - !server.is4k ? 'non-4K' : '4K' - } Radarr.`, - { - errorMessage: ex.message, - label: 'AvailabilitySync', - } - ); - if (!server.is4k) { - existsInRadarr = false; - } - - if (server.is4k) { - existsInRadarr4k = false; + if (!ex.message.includes('404')) { + existsInRadarr = true; + logger.debug( + `Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${ + media.tmdbId + }] from Radarr.`, + { + errorMessage: ex.message, + label: 'AvailabilitySync', + } + ); } } } - // If only a single non-4k or 4k exists, then change entity columns accordingly - // Related media request will then be deleted - if ( - !existsInRadarr && - (existsInRadarr4k || existsInPlex4k) && - !existsInPlex - ) { - if (media.status !== MediaStatus.UNKNOWN) { - this.mediaUpdater(media, false); - } - } - - if ( - (existsInRadarr || existsInPlex) && - !existsInRadarr4k && - !existsInPlex4k - ) { - if (media.status4k !== MediaStatus.UNKNOWN) { - this.mediaUpdater(media, true); - } - } - - if (existsInRadarr || existsInRadarr4k || existsInPlex || existsInPlex4k) { - return true; - } - - return false; + return existsInRadarr; } private async mediaExistsInSonarr( media: Media, - existsInPlex: boolean, - existsInPlex4k: boolean - ): Promise { - let existsInSonarr = true; - let existsInSonarr4k = true; + is4k: boolean + ): Promise<{ existsInSonarr: boolean; seasonsMap: Map }> { + let existsInSonarr = false; + let preventSeasonSearch = false; + // Check for availability in all of the available sonarr servers + // If any find the media, we will assume the media exists for (const server of this.sonarrServers) { - const api = new SonarrAPI({ + const sonarrAPI = new SonarrAPI({ apiKey: server.apiKey, url: SonarrAPI.buildUrl(server, '/api/v3'), }); - try { - // Check if both exist or if a single non-4k or 4k exists - // If both do not exist we will return false - let meta: SonarrSeries | undefined; + try { + let sonarr: SonarrSeries | undefined; - if (!server.is4k && media.externalServiceId) { - meta = await api.getSeriesById(media.externalServiceId); + if (!server.is4k && media.externalServiceId && !is4k) { + sonarr = await sonarrAPI.getSeriesById(media.externalServiceId); this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] = - meta.seasons; + sonarr.seasons; } - if (server.is4k && media.externalServiceId4k) { - meta = await api.getSeriesById(media.externalServiceId4k); + if (server.is4k && media.externalServiceId4k && is4k) { + sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k); this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] = - meta.seasons; + sonarr.seasons; } - if (!server.is4k && (!meta || meta.statistics.episodeFileCount === 0)) { - existsInSonarr = false; - } - - if (server.is4k && (!meta || meta.statistics.episodeFileCount === 0)) { - existsInSonarr4k = false; + if (sonarr && sonarr.statistics.episodeFileCount > 0) { + existsInSonarr = true; } } catch (ex) { - logger.debug( - `Failure retrieving media ID ${media.id} from your ${ - !server.is4k ? 'non-4K' : '4K' - } Sonarr.`, - { - errorMessage: ex.message, - label: 'AvailabilitySync', - } - ); - - if (!server.is4k) { - existsInSonarr = false; - } - - if (server.is4k) { - existsInSonarr4k = false; + if (!ex.message.includes('404')) { + existsInSonarr = true; + preventSeasonSearch = true; + logger.debug( + `Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${ + media.tmdbId + }] from Sonarr.`, + { + errorMessage: ex.message, + label: 'AvailabilitySync', + } + ); } } } - // If only a single non-4k or 4k exists, then change entity columns accordingly - // Related media request will then be deleted - if ( - !existsInSonarr && - (existsInSonarr4k || existsInPlex4k) && - !existsInPlex - ) { - if (media.status !== MediaStatus.UNKNOWN) { - this.mediaUpdater(media, false); - } - } + // Here we check each season for availability + // If the API returns an error other than a 404, + // we will have to prevent the season check from happening + const seasonsMap: Map = new Map(); + + if (!preventSeasonSearch) { + const filteredSeasons = media.seasons.filter( + (season) => + season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || + season[is4k ? 'status4k' : 'status'] === + MediaStatus.PARTIALLY_AVAILABLE + ); - if ( - (existsInSonarr || existsInPlex) && - !existsInSonarr4k && - !existsInPlex4k - ) { - if (media.status4k !== MediaStatus.UNKNOWN) { - this.mediaUpdater(media, true); - } - } + for (const season of filteredSeasons) { + const seasonExists = await this.seasonExistsInSonarr( + media, + season, + is4k + ); - if (existsInSonarr || existsInSonarr4k || existsInPlex || existsInPlex4k) { - return true; + if (seasonExists) { + seasonsMap.set(season.seasonNumber, true); + } + } } - return false; + return { existsInSonarr, seasonsMap }; } private async seasonExistsInSonarr( media: Media, season: Season, - seasonExistsInPlex: boolean, - seasonExistsInPlex4k: boolean + is4k: boolean ): Promise { - let seasonExistsInSonarr = true; - let seasonExistsInSonarr4k = true; - - const mediaRepository = getRepository(Media); - const seasonRepository = getRepository(Season); - const seasonRequestRepository = getRepository(SeasonRequest); + let seasonExists = false; + // Check each sonarr instance to see if the media still exists + // If found, we will assume the media exists and prevent removal + // We can use the cache we built when we fetched the series with mediaExistsInSonarr for (const server of this.sonarrServers) { - const api = new SonarrAPI({ - apiKey: server.apiKey, - url: SonarrAPI.buildUrl(server, '/api/v3'), - }); - - try { - // Here we can use the cache we built when we fetched the series with mediaExistsInSonarr - // If the cache does not have data, we will fetch with the api route - - let seasons: SonarrSeason[] = - this.sonarrSeasonsCache[ - `${server.id}-${ - !server.is4k ? media.externalServiceId : media.externalServiceId4k - }` - ]; - - if (!server.is4k && media.externalServiceId) { - seasons = - this.sonarrSeasonsCache[ - `${server.id}-${media.externalServiceId}` - ] ?? (await api.getSeriesById(media.externalServiceId)).seasons; - this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] = - seasons; - } + let sonarrSeasons: SonarrSeason[] | undefined; - if (server.is4k && media.externalServiceId4k) { - seasons = - this.sonarrSeasonsCache[ - `${server.id}-${media.externalServiceId4k}` - ] ?? (await api.getSeriesById(media.externalServiceId4k)).seasons; - this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] = - seasons; - } - - const seasonIsUnavailable = seasons?.find( - ({ seasonNumber, statistics }) => - season.seasonNumber === seasonNumber && - statistics?.episodeFileCount === 0 - ); - - if (!server.is4k && seasonIsUnavailable) { - seasonExistsInSonarr = false; - } - - if (server.is4k && seasonIsUnavailable) { - seasonExistsInSonarr4k = false; - } - } catch (ex) { - logger.debug( - `Failure retrieving media ID ${media.id} from your ${ - !server.is4k ? 'non-4K' : '4K' - } Sonarr.`, - { - errorMessage: ex.message, - label: 'AvailabilitySync', - } - ); - - if (!server.is4k) { - seasonExistsInSonarr = false; - } - - if (server.is4k) { - seasonExistsInSonarr4k = false; - } + if (media.externalServiceId && !is4k) { + sonarrSeasons = + this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`]; } - } - - try { - const seasonToBeDeleted = await seasonRequestRepository.findOne({ - relations: { - request: { - media: true, - }, - }, - where: { - request: { - is4k: seasonExistsInSonarr ? true : false, - media: { - id: media.id, - }, - }, - seasonNumber: season.seasonNumber, - }, - }); - // If season does not exist, we will change status to unknown and delete related season request - // If parent media request is empty(all related seasons have been removed), parent is automatically deleted - if ( - !seasonExistsInSonarr && - (seasonExistsInSonarr4k || seasonExistsInPlex4k) && - !seasonExistsInPlex - ) { - if (season.status !== MediaStatus.UNKNOWN) { - logger.info( - `Season ${season.seasonNumber}, media ID ${media.id} does not exist in your non-4k Sonarr and Plex instance. Status will be changed to unknown.`, - { label: 'AvailabilitySync' } - ); - await seasonRepository.update(season.id, { - status: MediaStatus.UNKNOWN, - }); - - if (seasonToBeDeleted) { - await seasonRequestRepository.remove(seasonToBeDeleted); - } - - if (media.status === MediaStatus.AVAILABLE) { - logger.info( - `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`, - { label: 'AvailabilitySync' } - ); - await mediaRepository.update(media.id, { - status: MediaStatus.PARTIALLY_AVAILABLE, - }); - } - } + if (media.externalServiceId4k && is4k) { + sonarrSeasons = + this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`]; } - if ( - (seasonExistsInSonarr || seasonExistsInPlex) && - !seasonExistsInSonarr4k && - !seasonExistsInPlex4k - ) { - if (season.status4k !== MediaStatus.UNKNOWN) { - logger.info( - `Season ${season.seasonNumber}, media ID ${media.id} does not exist in your 4k Sonarr and Plex instance. Status will be changed to unknown.`, - { label: 'AvailabilitySync' } - ); - await seasonRepository.update(season.id, { - status4k: MediaStatus.UNKNOWN, - }); - - if (seasonToBeDeleted) { - await seasonRequestRepository.remove(seasonToBeDeleted); - } + const seasonIsAvailable = sonarrSeasons?.find( + ({ seasonNumber, statistics }) => + season.seasonNumber === seasonNumber && + statistics?.episodeFileCount && + statistics?.episodeFileCount > 0 + ); - if (media.status4k === MediaStatus.AVAILABLE) { - logger.info( - `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`, - { label: 'AvailabilitySync' } - ); - await mediaRepository.update(media.id, { - status4k: MediaStatus.PARTIALLY_AVAILABLE, - }); - } - } + if (seasonIsAvailable && sonarrSeasons) { + seasonExists = true; } - } catch (ex) { - logger.debug(`Failure updating media ID ${media.id}`, { - errorMessage: ex.message, - label: 'AvailabilitySync', - }); - } - - if ( - seasonExistsInSonarr || - seasonExistsInSonarr4k || - seasonExistsInPlex || - seasonExistsInPlex4k - ) { - return true; } - return false; + return seasonExists; } - private async mediaExists(media: Media): Promise { + private async mediaExistsInPlex( + media: Media, + is4k: boolean + ): Promise<{ existsInPlex: boolean; seasonsMap?: Map }> { const ratingKey = media.ratingKey; const ratingKey4k = media.ratingKey4k; - let existsInPlex = false; - let existsInPlex4k = false; + let preventSeasonSearch = false; - // Check each plex instance to see if media exists + // Check each plex instance to see if the media still exists + // If found, we will assume the media exists and prevent removal + // We can use the cache we built when we fetched the series with mediaExistsInPlex try { - if (ratingKey) { - const meta = await this.plexClient?.getMetadata(ratingKey); - if (meta) { - existsInPlex = true; + let plexMedia: PlexMetadata | undefined; + + if (ratingKey && !is4k) { + plexMedia = await this.plexClient?.getMetadata(ratingKey); + + if (media.mediaType === 'tv') { + this.plexSeasonsCache[ratingKey] = + await this.plexClient?.getChildrenMetadata(ratingKey); } } - if (ratingKey4k) { - const meta4k = await this.plexClient?.getMetadata(ratingKey4k); - if (meta4k) { - existsInPlex4k = true; + if (ratingKey4k && is4k) { + plexMedia = await this.plexClient?.getMetadata(ratingKey4k); + + if (media.mediaType === 'tv') { + this.plexSeasonsCache[ratingKey4k] = + await this.plexClient?.getChildrenMetadata(ratingKey4k); } } - } catch (ex) { - if (!ex.message.includes('response code: 404')) { - logger.debug(`Failed to retrieve plex metadata`, { - errorMessage: ex.message, - label: 'AvailabilitySync', - }); - } - } - // Base case if both media versions exist in plex - if (existsInPlex && existsInPlex4k) { - return true; - } - // We then check radarr or sonarr has that specific media. If not, then we will move to delete - // If a non-4k or 4k version exists in at least one of the instances, we will only update that specific version - if (media.mediaType === 'movie') { - const existsInRadarr = await this.mediaExistsInRadarr( - media, - existsInPlex, - existsInPlex4k - ); - - // If true, media exists in at least one radarr or plex instance. - if (existsInRadarr) { - logger.warn( - `${media.id} exists in at least one Radarr or Plex instance. Media will be updated if set to available.`, + if (plexMedia) { + existsInPlex = true; + } + } catch (ex) { + if (!ex.message.includes('404')) { + existsInPlex = true; + preventSeasonSearch = true; + logger.debug( + `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ + media.mediaType === 'tv' ? 'show' : 'movie' + } [TMDB ID ${media.tmdbId}] from Plex.`, { + errorMessage: ex.message, label: 'AvailabilitySync', } ); - - return true; } } + // Here we check each season in plex for availability + // If the API returns an error other than a 404, + // we will have to prevent the season check from happening if (media.mediaType === 'tv') { - const existsInSonarr = await this.mediaExistsInSonarr( - media, - existsInPlex, - existsInPlex4k - ); - - // If true, media exists in at least one sonarr or plex instance. - if (existsInSonarr) { - logger.warn( - `${media.id} exists in at least one Sonarr or Plex instance. Media will be updated if set to available.`, - { - label: 'AvailabilitySync', - } + const seasonsMap: Map = new Map(); + + if (!preventSeasonSearch) { + const filteredSeasons = media.seasons.filter( + (season) => + season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || + season[is4k ? 'status4k' : 'status'] === + MediaStatus.PARTIALLY_AVAILABLE ); - return true; + for (const season of filteredSeasons) { + const seasonExists = await this.seasonExistsInPlex( + media, + season, + is4k + ); + + if (seasonExists) { + seasonsMap.set(season.seasonNumber, true); + } + } } + + return { existsInPlex, seasonsMap }; } - return false; + return { existsInPlex }; } - private async seasonExists(media: Media, season: Season) { + private async seasonExistsInPlex( + media: Media, + season: Season, + is4k: boolean + ): Promise { const ratingKey = media.ratingKey; const ratingKey4k = media.ratingKey4k; - let seasonExistsInPlex = false; - let seasonExistsInPlex4k = false; - try { - if (ratingKey) { - const children = - this.plexSeasonsCache[ratingKey] ?? - (await this.plexClient?.getChildrenMetadata(ratingKey)) ?? - []; - this.plexSeasonsCache[ratingKey] = children; - const seasonMeta = children?.find( - (child) => child.index === season.seasonNumber - ); - - if (seasonMeta) { - seasonExistsInPlex = true; - } - } - if (ratingKey4k) { - const children4k = - this.plexSeasonsCache[ratingKey4k] ?? - (await this.plexClient?.getChildrenMetadata(ratingKey4k)) ?? - []; - this.plexSeasonsCache[ratingKey4k] = children4k; - const seasonMeta4k = children4k?.find( - (child) => child.index === season.seasonNumber - ); + // Check each plex instance to see if the season exists + let plexSeasons: PlexMetadata[] | undefined; - if (seasonMeta4k) { - seasonExistsInPlex4k = true; - } - } - } catch (ex) { - if (!ex.message.includes('response code: 404')) { - logger.debug(`Failed to retrieve plex's children metadata`, { - errorMessage: ex.message, - label: 'AvailabilitySync', - }); - } - } - // Base case if both season versions exist in plex - if (seasonExistsInPlex && seasonExistsInPlex4k) { - return true; + if (ratingKey && !is4k) { + plexSeasons = this.plexSeasonsCache[ratingKey]; } - const existsInSonarr = await this.seasonExistsInSonarr( - media, - season, - seasonExistsInPlex, - seasonExistsInPlex4k - ); - - if (existsInSonarr) { - logger.warn( - `Season ${season.seasonNumber}, media ID ${media.id} exists in at least one Sonarr or Plex instance. Media will be updated if set to available.`, - { - label: 'AvailabilitySync', - } - ); - - return true; + if (ratingKey4k && is4k) { + plexSeasons = this.plexSeasonsCache[ratingKey4k]; } - return false; - } - - private async initPlexClient() { - const userRepository = getRepository(User); - const admin = await userRepository.findOne({ - select: { id: true, plexToken: true }, - where: { id: 1 }, - }); + const seasonIsAvailable = plexSeasons?.find( + (plexSeason) => plexSeason.index === season.seasonNumber + ); - if (!admin) { - logger.warning('No admin configured. Availability sync skipped.'); - return; + if (seasonIsAvailable) { + seasonExistsInPlex = true; } - this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); + return seasonExistsInPlex; } } From b4191f9c65b7ff08764e61d18e7a75bc8d4b3325 Mon Sep 17 00:00:00 2001 From: Marco Faggian Date: Fri, 28 Jul 2023 13:51:19 +0200 Subject: [PATCH 09/18] feat(rating): added IMDB Radarr proxy (#3496) * feat(rating): added imdb radarr proxy Signed-off-by: marcofaggian * refactor(rating/imdb): rm export unused interfaces Signed-off-by: marcofaggian * docs(rating/imdb): rt to imdb Signed-off-by: marcofaggian * refactor(rating/imdb): specified error message Signed-off-by: marcofaggian * refactor(rating/imdb): rm line break Signed-off-by: marcofaggian * refactor(rating): conform to types patter Signed-off-by: marcofaggian * chore(rating/imdb): added line to translation file Signed-off-by: marcofaggian * feat(rating/imdb): ratings to ratingscombined Signed-off-by: marcofaggian * fix(rating/imdb): reinstating ratings route Signed-off-by: marcofaggian * docs(ratings): openapi ratings Signed-off-by: marcofaggian * chore(ratings): undo openapi ratings apex Signed-off-by: marcofaggian --------- Signed-off-by: marcofaggian --- overseerr-api.yml | 57 +++++++ server/api/rating/imdbRadarrProxy.ts | 195 ++++++++++++++++++++++ server/api/{ => rating}/rottentomatoes.ts | 8 +- server/api/ratings.ts | 7 + server/lib/cache.ts | 5 + server/routes/movie.ts | 56 ++++++- server/routes/tv.ts | 2 +- src/components/MovieDetails/index.tsx | 90 ++++++---- src/components/TvDetails/index.tsx | 2 +- src/i18n/locale/en.json | 1 + 10 files changed, 384 insertions(+), 39 deletions(-) create mode 100644 server/api/rating/imdbRadarrProxy.ts rename server/api/{ => rating}/rottentomatoes.ts (93%) create mode 100644 server/api/ratings.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index c8b528859..f3a1cc74b 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -5338,6 +5338,63 @@ paths: audienceRating: type: string enum: ['Spilled', 'Upright'] + /movie/{movieId}/ratingscombined: + get: + summary: Get RT and IMDB movie ratings combined + description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: number + example: 337401 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + rt: + type: object + properties: + title: + type: string + example: Mulan + year: + type: number + example: 2020 + url: + type: string + example: 'http://www.rottentomatoes.com/m/mulan_2020/' + criticsScore: + type: number + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh', 'Certified Fresh'] + audienceScore: + type: number + example: 65 + audienceRating: + type: string + enum: ['Spilled', 'Upright'] + imdb: + type: object + properties: + title: + type: string + example: I am Legend + url: + type: string + example: 'https://www.imdb.com/title/tt0480249' + criticsScore: + type: number + example: 6.5 /tv/{tvId}: get: summary: Get TV details diff --git a/server/api/rating/imdbRadarrProxy.ts b/server/api/rating/imdbRadarrProxy.ts new file mode 100644 index 000000000..0d8ec79fb --- /dev/null +++ b/server/api/rating/imdbRadarrProxy.ts @@ -0,0 +1,195 @@ +import ExternalAPI from '@server/api/externalapi'; +import cacheManager from '@server/lib/cache'; + +type IMDBRadarrProxyResponse = IMDBMovie[]; + +interface IMDBMovie { + ImdbId: string; + Overview: string; + Title: string; + OriginalTitle: string; + TitleSlug: string; + Ratings: Rating[]; + MovieRatings: MovieRatings; + Runtime: number; + Images: Image[]; + Genres: string[]; + Popularity: number; + Premier: string; + InCinema: string; + PhysicalRelease: any; + DigitalRelease: string; + Year: number; + AlternativeTitles: AlternativeTitle[]; + Translations: Translation[]; + Recommendations: Recommendation[]; + Credits: Credits; + Studio: string; + YoutubeTrailerId: string; + Certifications: Certification[]; + Status: any; + Collection: Collection; + OriginalLanguage: string; + Homepage: string; + TmdbId: number; +} + +interface Rating { + Count: number; + Value: number; + Origin: string; + Type: string; +} + +interface MovieRatings { + Tmdb: Tmdb; + Imdb: Imdb; + Metacritic: Metacritic; + RottenTomatoes: RottenTomatoes; +} + +interface Tmdb { + Count: number; + Value: number; + Type: string; +} + +interface Imdb { + Count: number; + Value: number; + Type: string; +} + +interface Metacritic { + Count: number; + Value: number; + Type: string; +} + +interface RottenTomatoes { + Count: number; + Value: number; + Type: string; +} + +interface Image { + CoverType: string; + Url: string; +} + +interface AlternativeTitle { + Title: string; + Type: string; + Language: string; +} + +interface Translation { + Title: string; + Overview: string; + Language: string; +} + +interface Recommendation { + TmdbId: number; + Title: string; +} + +interface Credits { + Cast: Cast[]; + Crew: Crew[]; +} + +interface Cast { + Name: string; + Order: number; + Character: string; + TmdbId: number; + CreditId: string; + Images: Image2[]; +} + +interface Image2 { + CoverType: string; + Url: string; +} + +interface Crew { + Name: string; + Job: string; + Department: string; + TmdbId: number; + CreditId: string; + Images: Image3[]; +} + +interface Image3 { + CoverType: string; + Url: string; +} + +interface Certification { + Country: string; + Certification: string; +} + +interface Collection { + Name: string; + Images: any; + Overview: any; + Translations: any; + Parts: any; + TmdbId: number; +} + +export interface IMDBRating { + title: string; + url: string; + criticsScore: number; +} + +/** + * This is a best-effort API. The IMDB API is technically + * private and getting access costs money/requires approval. + * + * Radarr hosts a public proxy that's in use by all Radarr instances. + */ +class IMDBRadarrProxy extends ExternalAPI { + constructor() { + super('https://api.radarr.video/v1', { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + nodeCache: cacheManager.getCache('imdb').data, + }); + } + + /** + * Ask the Radarr IMDB Proxy for the movie + * + * @param IMDBid Id of IMDB movie + */ + public async getMovieRatings(IMDBid: string): Promise { + try { + const data = await this.get( + `/movie/imdb/${IMDBid}` + ); + + if (!data?.length || data[0].ImdbId !== IMDBid) { + return null; + } + + return { + title: data[0].Title, + url: `https://www.imdb.com/title/${data[0].ImdbId}`, + criticsScore: data[0].MovieRatings.Imdb.Value, + }; + } catch (e) { + throw new Error( + `[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}` + ); + } + } +} + +export default IMDBRadarrProxy; diff --git a/server/api/rottentomatoes.ts b/server/api/rating/rottentomatoes.ts similarity index 93% rename from server/api/rottentomatoes.ts rename to server/api/rating/rottentomatoes.ts index 99a74eb1b..1cf9d6d8e 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rating/rottentomatoes.ts @@ -1,6 +1,6 @@ +import ExternalAPI from '@server/api/externalapi'; import cacheManager from '@server/lib/cache'; import { getSettings } from '@server/lib/settings'; -import ExternalAPI from './externalapi'; interface RTAlgoliaSearchResponse { results: { @@ -144,6 +144,9 @@ class RottenTomatoes extends ExternalAPI { ? 'Fresh' : 'Rotten', criticsScore: movie.rottenTomatoes.criticsScore, + audienceRating: + movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled', + audienceScore: movie.rottenTomatoes.audienceScore, year: Number(movie.releaseYear), }; } catch (e) { @@ -192,6 +195,9 @@ class RottenTomatoes extends ExternalAPI { criticsRating: tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten', criticsScore: tvshow.rottenTomatoes.criticsScore, + audienceRating: + tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled', + audienceScore: tvshow.rottenTomatoes.audienceScore, year: Number(tvshow.releaseYear), }; } catch (e) { diff --git a/server/api/ratings.ts b/server/api/ratings.ts new file mode 100644 index 000000000..1fe1354cf --- /dev/null +++ b/server/api/ratings.ts @@ -0,0 +1,7 @@ +import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy'; +import { type RTRating } from '@server/api/rating/rottentomatoes'; + +export interface RatingResponse { + rt?: RTRating; + imdb?: IMDBRating; +} diff --git a/server/lib/cache.ts b/server/lib/cache.ts index e81466629..011205e7f 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -5,6 +5,7 @@ export type AvailableCacheIds = | 'radarr' | 'sonarr' | 'rt' + | 'imdb' | 'github' | 'plexguid' | 'plextv'; @@ -51,6 +52,10 @@ class CacheManager { stdTtl: 43200, checkPeriod: 60 * 30, }), + imdb: new Cache('imdb', 'IMDB Radarr Proxy', { + stdTtl: 43200, + checkPeriod: 60 * 30, + }), github: new Cache('github', 'GitHub API', { stdTtl: 21600, checkPeriod: 60 * 30, diff --git a/server/routes/movie.ts b/server/routes/movie.ts index f11cead8c..e39e2e86e 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -1,4 +1,6 @@ -import RottenTomatoes from '@server/api/rottentomatoes'; +import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy'; +import RottenTomatoes from '@server/api/rating/rottentomatoes'; +import { type RatingResponse } from '@server/api/ratings'; import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import Media from '@server/entity/Media'; @@ -116,6 +118,9 @@ movieRoutes.get('/:id/similar', async (req, res, next) => { } }); +/** + * Endpoint backed by RottenTomatoes + */ movieRoutes.get('/:id/ratings', async (req, res, next) => { const tmdb = new TheMovieDb(); const rtapi = new RottenTomatoes(); @@ -151,4 +156,53 @@ movieRoutes.get('/:id/ratings', async (req, res, next) => { } }); +/** + * Endpoint combining RottenTomatoes and IMDB + */ +movieRoutes.get('/:id/ratingscombined', async (req, res, next) => { + const tmdb = new TheMovieDb(); + const rtapi = new RottenTomatoes(); + const imdbApi = new IMDBRadarrProxy(); + + try { + const movie = await tmdb.getMovie({ + movieId: Number(req.params.id), + }); + + const rtratings = await rtapi.getMovieRatings( + movie.title, + Number(movie.release_date.slice(0, 4)) + ); + + let imdbRatings; + if (movie.imdb_id) { + imdbRatings = await imdbApi.getMovieRatings(movie.imdb_id); + } + + if (!rtratings && !imdbRatings) { + return next({ + status: 404, + message: 'No ratings found.', + }); + } + + const ratings: RatingResponse = { + ...(rtratings ? { rt: rtratings } : {}), + ...(imdbRatings ? { imdb: imdbRatings } : {}), + }; + + return res.status(200).json(ratings); + } catch (e) { + logger.debug('Something went wrong retrieving movie ratings', { + label: 'API', + errorMessage: e.message, + movieId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve movie ratings.', + }); + } +}); + export default movieRoutes; diff --git a/server/routes/tv.ts b/server/routes/tv.ts index d45e40620..95c8dc11c 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,4 +1,4 @@ -import RottenTomatoes from '@server/api/rottentomatoes'; +import RottenTomatoes from '@server/api/rating/rottentomatoes'; import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import Media from '@server/entity/Media'; diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 1b142d4de..eaaa902e8 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -2,6 +2,7 @@ import RTAudFresh from '@app/assets/rt_aud_fresh.svg'; import RTAudRotten from '@app/assets/rt_aud_rotten.svg'; import RTFresh from '@app/assets/rt_fresh.svg'; import RTRotten from '@app/assets/rt_rotten.svg'; +import ImdbLogo from '@app/assets/services/imdb.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; @@ -40,7 +41,7 @@ import { ChevronDoubleDownIcon, ChevronDoubleUpIcon, } from '@heroicons/react/24/solid'; -import type { RTRating } from '@server/api/rottentomatoes'; +import { type RatingResponse } from '@server/api/ratings'; import { IssueStatus } from '@server/constants/issue'; import { MediaStatus } from '@server/constants/media'; import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; @@ -86,6 +87,7 @@ const messages = defineMessages({ rtcriticsscore: 'Rotten Tomatoes Tomatometer', rtaudiencescore: 'Rotten Tomatoes Audience Score', tmdbuserscore: 'TMDB User Score', + imdbuserscore: 'IMDB User Score', }); interface MovieDetailsProps { @@ -120,8 +122,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { ), }); - const { data: ratingData } = useSWR( - `/api/v1/movie/${router.query.movieId}/ratings` + const { data: ratingData } = useSWR( + `/api/v1/movie/${router.query.movieId}/ratingscombined` ); const sortedCrew = useMemo( @@ -511,44 +513,62 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { )}
{(!!data.voteCount || - (ratingData?.criticsRating && !!ratingData?.criticsScore) || - (ratingData?.audienceRating && !!ratingData?.audienceScore)) && ( + (ratingData?.rt?.criticsRating && + !!ratingData?.rt?.criticsScore) || + (ratingData?.rt?.audienceRating && + !!ratingData?.rt?.audienceScore) || + ratingData?.imdb?.criticsScore) && (
- {ratingData?.criticsRating && !!ratingData?.criticsScore && ( - - - {ratingData.criticsRating === 'Rotten' ? ( - - ) : ( - - )} - {ratingData.criticsScore}% - - - )} - {ratingData?.audienceRating && !!ratingData?.audienceScore && ( - + + {ratingData.rt.criticsRating === 'Rotten' ? ( + + ) : ( + + )} + {ratingData.rt.criticsScore}% + + + )} + {ratingData?.rt?.audienceRating && + !!ratingData?.rt?.audienceScore && ( + + + {ratingData.rt.audienceRating === 'Spilled' ? ( + + ) : ( + + )} + {ratingData.rt.audienceScore}% + + + )} + {ratingData?.imdb?.criticsScore && ( + - {ratingData.audienceRating === 'Spilled' ? ( - - ) : ( - - )} - {ratingData.audienceScore}% + + {ratingData.imdb.criticsScore} )} @@ -797,7 +817,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { tmdbId={data.id} tvdbId={data.externalIds.tvdbId} imdbId={data.externalIds.imdbId} - rtUrl={ratingData?.url} + rtUrl={ratingData?.rt?.url} plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k} />
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index c450ef4ad..2f44a7d8c 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -40,7 +40,7 @@ import { PlayIcon, } from '@heroicons/react/24/outline'; import { ChevronDownIcon } from '@heroicons/react/24/solid'; -import type { RTRating } from '@server/api/rottentomatoes'; +import type { RTRating } from '@server/api/rating/rottentomatoes'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { IssueStatus } from '@server/constants/issue'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 24a537a07..83d3d8421 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -256,6 +256,7 @@ "components.MovieDetails.budget": "Budget", "components.MovieDetails.cast": "Cast", "components.MovieDetails.digitalrelease": "Digital Release", + "components.MovieDetails.imdbuserscore": "IMDB User Score", "components.MovieDetails.managemovie": "Manage Movie", "components.MovieDetails.mark4kavailable": "Mark as Available in 4K", "components.MovieDetails.markavailable": "Mark as Available", From 46e21c4e3e890ebc37ffcbb862148d07c79372d0 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 28 Jul 2023 20:52:03 +0900 Subject: [PATCH 10/18] docs: add marcofaggian as a contributor for code (#3563) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index f29044994..53ca949de 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -881,6 +881,15 @@ "contributions": [ "code" ] + }, + { + "login": "marcofaggian", + "name": "Marco Faggian", + "avatar_url": "https://avatars.githubusercontent.com/u/19221001?v=4", + "profile": "https://marcofaggian.com", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index 0f6e32413..4c7db12e3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors

@@ -197,6 +197,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Andrew Kennedy
Andrew Kennedy

đź’» Fallenbagel
Fallenbagel

đź’» Anton K. (ai Doge)
Anton K. (ai Doge)

đź’» + Marco Faggian
Marco Faggian

đź’» From cb63bf217b9e8810a5210b4bf475b2a96583cc84 Mon Sep 17 00:00:00 2001 From: Eric Nemchik Date: Fri, 28 Jul 2023 09:41:52 -0500 Subject: [PATCH 11/18] fix: Include all defaults in payload (#3538) * fix: Include all defaults in payload * style(src/components/settings/notifications/notificationswebhook/index.tsx): prettier format format changes from previous commit using prettier. line length requirement now met. * fix(server/lib/settings.ts): update default settings for first install --- server/lib/settings.ts | 2 +- .../Notifications/NotificationsWebhook/index.tsx | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/lib/settings.ts b/server/lib/settings.ts index c3981fe90..36dbb1097 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -378,7 +378,7 @@ class Settings { options: { webhookUrl: '', jsonPayload: - 'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i', + 'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i', }, }, webpush: { diff --git a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx index 14f1e672e..fcddfdbdf 100644 --- a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx +++ b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx @@ -39,6 +39,9 @@ const defaultPayload = { requestedBy_email: '{{requestedBy_email}}', requestedBy_username: '{{requestedBy_username}}', requestedBy_avatar: '{{requestedBy_avatar}}', + requestedBy_settings_discordId: '{{requestedBy_settings_discordId}}', + requestedBy_settings_telegramChatId: + '{{requestedBy_settings_telegramChatId}}', }, '{{issue}}': { issue_id: '{{issue_id}}', @@ -47,12 +50,18 @@ const defaultPayload = { reportedBy_email: '{{reportedBy_email}}', reportedBy_username: '{{reportedBy_username}}', reportedBy_avatar: '{{reportedBy_avatar}}', + reportedBy_settings_discordId: '{{reportedBy_settings_discordId}}', + reportedBy_settings_telegramChatId: + '{{reportedBy_settings_telegramChatId}}', }, '{{comment}}': { comment_message: '{{comment_message}}', commentedBy_email: '{{commentedBy_email}}', commentedBy_username: '{{commentedBy_username}}', commentedBy_avatar: '{{commentedBy_avatar}}', + commentedBy_settings_discordId: '{{commentedBy_settings_discordId}}', + commentedBy_settings_telegramChatId: + '{{commentedBy_settings_telegramChatId}}', }, '{{extra}}': [], }; From a686d31e4d7b64812fdb5d2049d7b4d38990f4f5 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 28 Jul 2023 23:42:29 +0900 Subject: [PATCH 12/18] docs: add nemchik as a contributor for code (#3565) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 53ca949de..b5166196f 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -890,6 +890,15 @@ "contributions": [ "code" ] + }, + { + "login": "nemchik", + "name": "Eric Nemchik", + "avatar_url": "https://avatars.githubusercontent.com/u/725456?v=4", + "profile": "http://nemchik.com/", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index 4c7db12e3..2369a0cda 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors

@@ -198,6 +198,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Fallenbagel
Fallenbagel

đź’» Anton K. (ai Doge)
Anton K. (ai Doge)

đź’» Marco Faggian
Marco Faggian

đź’» + Eric Nemchik
Eric Nemchik

💻 From f7b4dfcac472d08c54779a14fc1ad3c90927df26 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Fri, 4 Aug 2023 16:26:03 -0700 Subject: [PATCH 13/18] fix(tautulli): only test connection if hostname is defined (#3573) --- server/routes/settings/index.ts | 34 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 8023ba960..98fe0f776 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -254,25 +254,27 @@ settingsRoutes.post('/tautulli', async (req, res, next) => { Object.assign(settings.tautulli, req.body); - try { - const tautulliClient = new TautulliAPI(settings.tautulli); + if (settings.tautulli.hostname) { + try { + const tautulliClient = new TautulliAPI(settings.tautulli); - const result = await tautulliClient.getInfo(); + const result = await tautulliClient.getInfo(); - if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) { - throw new Error('Tautulli version not supported'); - } + if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) { + throw new Error('Tautulli version not supported'); + } - settings.save(); - } catch (e) { - logger.error('Something went wrong testing Tautulli connection', { - label: 'API', - errorMessage: e.message, - }); - return next({ - status: 500, - message: 'Unable to connect to Tautulli.', - }); + settings.save(); + } catch (e) { + logger.error('Something went wrong testing Tautulli connection', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to connect to Tautulli.', + }); + } } return res.status(200).json(settings.tautulli); From 048fa967f2e5b23831ac9917c703934c50ef75f0 Mon Sep 17 00:00:00 2001 From: David Fernandez Alcoba Date: Sat, 5 Aug 2023 11:56:07 +0200 Subject: [PATCH 14/18] fix: multiple notifications for available media --- server/job/jellyfinsync/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/job/jellyfinsync/index.ts b/server/job/jellyfinsync/index.ts index dff1ea479..2f73b4b03 100644 --- a/server/job/jellyfinsync/index.ts +++ b/server/job/jellyfinsync/index.ts @@ -311,13 +311,15 @@ class JobJellyfinSync { // setting the status to AVAILABLE if all of a type is there, partially if some, // and then not modifying the status if there are 0 items existingSeason.status = - totalStandard >= season.episode_count + totalStandard >= season.episode_count || + existingSeason.status === MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE : totalStandard > 0 ? MediaStatus.PARTIALLY_AVAILABLE : existingSeason.status; existingSeason.status4k = - this.enable4kShow && total4k >= season.episode_count + (this.enable4kShow && total4k >= season.episode_count) || + existingSeason.status === MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE : this.enable4kShow && total4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE From 317110855e6096d6c9af80d1b469565bb8314e9f Mon Sep 17 00:00:00 2001 From: Ryan Cohen Date: Sun, 6 Aug 2023 16:15:03 +0900 Subject: [PATCH 15/18] ci: remove docker caching to save space after build (#3574) --- .github/workflows/ci.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c670f6b9..4914d3375 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,13 +39,6 @@ jobs: uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - - name: Cache Docker layers - uses: actions/cache@v3 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- - name: Log in to Docker Hub uses: docker/login-action@v2 with: @@ -71,15 +64,6 @@ jobs: sctx/overseerr:${{ github.sha }} ghcr.io/sct/overseerr:develop ghcr.io/sct/overseerr:${{ github.sha }} - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max - - # Temporary fix - # https://github.com/docker/build-push-action/issues/252 - # https://github.com/moby/buildkit/issues/1896 - name: Move cache - run: | - rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache discord: name: Send Discord Notification From 30361f2ab751d9a882a9120e0f3df28dc42cc2cd Mon Sep 17 00:00:00 2001 From: jellyfin Date: Wed, 9 Aug 2023 10:57:58 +0200 Subject: [PATCH 16/18] fix: repeat notifications for available 4k media --- server/job/jellyfinsync/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/job/jellyfinsync/index.ts b/server/job/jellyfinsync/index.ts index 2f73b4b03..b263ec6e4 100644 --- a/server/job/jellyfinsync/index.ts +++ b/server/job/jellyfinsync/index.ts @@ -319,7 +319,7 @@ class JobJellyfinSync { : existingSeason.status; existingSeason.status4k = (this.enable4kShow && total4k >= season.episode_count) || - existingSeason.status === MediaStatus.AVAILABLE + existingSeason.status4k === MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE : this.enable4kShow && total4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE From 8685f5796a99d9700146bae9892319db10508d68 Mon Sep 17 00:00:00 2001 From: Ethan Armbrust Date: Thu, 10 Aug 2023 18:10:29 -0400 Subject: [PATCH 17/18] fix(server/api/jellyfin.ts): use /Library/VirtualFolders Jellyfin API call to fetch Jellyfin libs use /Library/VirtualFolders Jellyfin API call to fetch Jellyfin libraries, instead of relying on user's view fix #256 --- server/api/jellyfin.ts | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index b126b55f1..de4e6b7cf 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -171,28 +171,25 @@ class JellyfinAPI { public async getLibraries(): Promise { try { - const account = await this.axios.get( - `/Users/${this.userId ?? 'Me'}/Views` - ); + const libraries = await this.axios.get('/Library/VirtualFolders'); - const response: JellyfinLibrary[] = account.data.Items.filter( - (Item: any) => { + const response: JellyfinLibrary[] = libraries.data + .filter((Item: any) => { return ( - Item.Type === 'CollectionFolder' && Item.CollectionType !== 'music' && Item.CollectionType !== 'books' && Item.CollectionType !== 'musicvideos' && Item.CollectionType !== 'homevideos' ); - } - ).map((Item: any) => { - return { - key: Item.Id, - title: Item.Name, - type: Item.CollectionType === 'movies' ? 'movie' : 'show', - agent: 'jellyfin', - }; - }); + }) + .map((Item: any) => { + return { + key: Item.ItemId, + title: Item.Name, + type: Item.CollectionType === 'movies' ? 'movie' : 'show', + agent: 'jellyfin', + }; + }); return response; } catch (e) { From 3fd016808bf92013537813c281867874ea1b8ae3 Mon Sep 17 00:00:00 2001 From: Uruk <68083474+Uruknara@users.noreply.github.com> Date: Mon, 21 Aug 2023 01:52:09 -0700 Subject: [PATCH 18/18] Fix translation on fr.json (#446) * Fix sidebar name * Update fr.json * Update fr.json * added missing line * Update fr.json * Update fr.json * Update fr.json * Update fr.json * fix watchlist * Update fr.json * Fix plural * Update fr.json * Update fr.json * Update fr.json * Update fr.json * Update fr.json * Update fr.json * Update fr.json * Update fr.json * Update fr.json --- src/i18n/locale/fr.json | 602 ++++++++++++++++++++++++++-------------- 1 file changed, 400 insertions(+), 202 deletions(-) diff --git a/src/i18n/locale/fr.json b/src/i18n/locale/fr.json index c31b77e60..be41e0342 100644 --- a/src/i18n/locale/fr.json +++ b/src/i18n/locale/fr.json @@ -6,42 +6,132 @@ "components.CollectionDetails.overview": "Résumé", "components.CollectionDetails.requestcollection": "Demander la collection", "components.CollectionDetails.requestcollection4k": "Demander la collection en 4K", + "components.Discover.CreateSlider.addSlider": "Ajouter un curseur", + "components.Discover.CreateSlider.addcustomslider": "Créer un curseur personnalisé", + "components.Discover.CreateSlider.addfail": "Impossible de créer un nouveau curseur.", + "components.Discover.CreateSlider.addsuccess": "Création d’un nouveau curseur et sauvegarde des paramètres de personnalisation.", + "components.Discover.CreateSlider.editSlider": "Modifier le curseur", + "components.Discover.CreateSlider.editfail": "Échec de modification du curseur.", + "components.Discover.CreateSlider.editsuccess": "Modification du curseur et sauvegarde des paramètres de personnalisation.", + "components.Discover.CreateSlider.needresults": "Vous devez avoir au moins 1 résultat.", + "components.Discover.CreateSlider.nooptions": "Aucun résultat.", + "components.Discover.CreateSlider.providetmdbgenreid": "Fournir un identifiant de genre TMDB", + "components.Discover.CreateSlider.providetmdbkeywordid": "Fournir un identifiant de mot-clé TMDB", + "components.Discover.CreateSlider.providetmdbnetwork": "Fournir un identifiant de réseau TMDB", + "components.Discover.CreateSlider.providetmdbsearch": "Fournir une requête de recherche", + "components.Discover.CreateSlider.providetmdbstudio": "Fournir un identifiant de Studio TMDB", + "components.Discover.CreateSlider.searchGenres": "Rechercher des genres…", + "components.Discover.CreateSlider.searchKeywords": "Mots-clés de recherche…", + "components.Discover.CreateSlider.searchStudios": "Studios de recherche…", + "components.Discover.CreateSlider.slidernameplaceholder": "Nom du curseur", + "components.Discover.CreateSlider.starttyping": "Commencer à taper pour rechercher.", + "components.Discover.CreateSlider.validationDatarequired": "Vous devez fournir une valeur de données.", + "components.Discover.CreateSlider.validationTitlerequired": "Vous devez fournir un titre.", "components.Discover.DiscoverMovieGenre.genreMovies": "Films {genre}", + "components.Discover.DiscoverMovieKeyword.keywordMovies": "{keywordTitle} Films", "components.Discover.DiscoverMovieLanguage.languageMovies": "Films en {language}", + "components.Discover.DiscoverMovies.activefilters": "{count, plural, one {# Filtre actif} other {# Filtres actifs}}", + "components.Discover.DiscoverMovies.discovermovies": "Films", + "components.Discover.DiscoverMovies.sortPopularityAsc": "Popularité croissante", + "components.Discover.DiscoverMovies.sortPopularityDesc": "Popularité décroissante", + "components.Discover.DiscoverMovies.sortReleaseDateAsc": "Date de diffusion ascendante", + "components.Discover.DiscoverMovies.sortReleaseDateDesc": "Date de diffusion décroissante", + "components.Discover.DiscoverMovies.sortTitleAsc": "Titre (A-Z) ascendant", + "components.Discover.DiscoverMovies.sortTitleDesc": "Titre (Z-A) décroissant", + "components.Discover.DiscoverMovies.sortTmdbRatingAsc": "Évaluation TMDB ascendante", + "components.Discover.DiscoverMovies.sortTmdbRatingDesc": "Évaluation TMDB décroissante", "components.Discover.DiscoverNetwork.networkSeries": "Séries {network}", + "components.Discover.DiscoverSliderEdit.deletefail": "Échec de suppression du curseur.", + "components.Discover.DiscoverSliderEdit.deletesuccess": "Curseur supprimé avec succès.", + "components.Discover.DiscoverSliderEdit.enable": "Activer/désactiver la visibilité", + "components.Discover.DiscoverSliderEdit.remove": "Supprimer", "components.Discover.DiscoverStudio.studioMovies": "Films {studio}", - "components.Discover.DiscoverTvGenre.genreSeries": "Séries {genre}", + "components.Discover.DiscoverTv.activefilters": "{count, plural, one {# Filtre actif} other {# Filtres actifs}}", + "components.Discover.DiscoverTv.discovertv": "Séries TV", + "components.Discover.DiscoverTv.sortFirstAirDateAsc": "Date de diffusion ascendante", + "components.Discover.DiscoverTv.sortFirstAirDateDesc": "Date de diffusion décroissante", + "components.Discover.DiscoverTv.sortPopularityAsc": "Popularité croissante", + "components.Discover.DiscoverTv.sortPopularityDesc": "Popularité décroissante", + "components.Discover.DiscoverTv.sortTitleAsc": "Titre (A-Z) ascendant", + "components.Discover.DiscoverTv.sortTitleDesc": "Titre (Z-A) décroissant", + "components.Discover.DiscoverTv.sortTmdbRatingAsc": "Évaluation TMDB ascendante", + "components.Discover.DiscoverTv.sortTmdbRatingDesc": "Évaluation TMDB décroissante", + "components.Discover.DiscoverTvGenre.genreSeries": "{genre} Séries", + "components.Discover.DiscoverTvKeyword.keywordSeries": "{keywordTitle} Séries", "components.Discover.DiscoverTvLanguage.languageSeries": "Séries en {language}", - "components.Discover.DiscoverWatchlist.discoverwatchlist": "Ta Plex Watchlist", - "components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist", + "components.Discover.DiscoverWatchlist.discoverwatchlist": "Votre liste de visionnage(s)", + "components.Discover.DiscoverWatchlist.watchlist": "Liste de visionnage", + "components.Discover.FilterSlideover.activefilters": "{count, plural, one {# Filtre actif} other {# Filtres actifs}}", + "components.Discover.FilterSlideover.clearfilters": "Effacer les filtres actifs", + "components.Discover.FilterSlideover.filters": "Filtres", + "components.Discover.FilterSlideover.firstAirDate": "Date de la première diffusion", + "components.Discover.FilterSlideover.from": "De", + "components.Discover.FilterSlideover.genres": "Genres", + "components.Discover.FilterSlideover.keywords": "Mots-clés", + "components.Discover.FilterSlideover.originalLanguage": "Langue originale", + "components.Discover.FilterSlideover.ratingText": "Notes entre {minValue} et {maxValue}", + "components.Discover.FilterSlideover.releaseDate": "Date de sortie", + "components.Discover.FilterSlideover.runtime": "Temps d'exécution", + "components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} minute", + "components.Discover.FilterSlideover.streamingservices": "Services de streaming", + "components.Discover.FilterSlideover.studio": "Studio", + "components.Discover.FilterSlideover.tmdbuserscore": "Score d'utilisateur TMDB", + "components.Discover.FilterSlideover.tmdbuservotecount": "Nombre de votes des utilisateurs TMDB", + "components.Discover.FilterSlideover.to": "À", + "components.Discover.FilterSlideover.voteCount": "Nombre de votes entre {minValue} et {maxValue}", "components.Discover.MovieGenreList.moviegenres": "Genres de films", "components.Discover.MovieGenreSlider.moviegenres": "Genres de films", "components.Discover.NetworkSlider.networks": "Diffuseurs", + "components.Discover.PlexWatchlistSlider.emptywatchlist": "Les médias ajoutés à votre liste de visionnage(s) Plex apparaîtront ici.", + "components.Discover.PlexWatchlistSlider.plexwatchlist": "Ma liste de visionnage(s)", + "components.Discover.RecentlyAddedSlider.recentlyAdded": "Récemment ajouté", "components.Discover.StudioSlider.studios": "Studios", "components.Discover.TvGenreList.seriesgenres": "Genres de séries", "components.Discover.TvGenreSlider.tvgenres": "Genres de séries", + "components.Discover.createnewslider": "Créer un nouveau curseur", + "components.Discover.customizediscover": "Personnalisez votre page Découvrir", "components.Discover.discover": "Découvrir", "components.Discover.discovermovies": "Films populaires", "components.Discover.discovertv": "Séries populaires", - "components.Discover.emptywatchlist": "Les médias ajoutés à ta Plex Watchlist apparaîtront ici.", - "components.Discover.noRequests": "Aucun requête", - "components.Discover.plexwatchlist": "Ta Plex Watchlist", + "components.Discover.emptywatchlist": "Les médias ajoutés à ta liste de visionnage(s) Plex apparaîtront ici.", + "components.Discover.moviegenres": "Genre de films", + "components.Discover.networks": "Réseaux", + "components.Discover.noRequests": "Aucune requête", + "components.Discover.plexwatchlist": "Votre liste de visionnage(s)", "components.Discover.popularmovies": "Films populaires", "components.Discover.populartv": "Séries populaires", "components.Discover.recentlyAdded": "Ajouts récents", "components.Discover.recentrequests": "Demandes récentes", + "components.Discover.resetfailed": "Quelque chose, s’est mal passé en réinitialisant les paramètres de personnalisation de découverte.", + "components.Discover.resetsuccess": "Réinitialisation avec succès des paramètres de personnalisation de découverte.", + "components.Discover.resettodefault": "Rétablir par défaut", + "components.Discover.resetwarning": "Réinitialisez tous les curseurs par défaut. Cela supprimera également tous les curseurs personnalisés !", + "components.Discover.stopediting": "Arrêter la modification", + "components.Discover.studios": "Studios", + "components.Discover.tmdbmoviegenre": "Genre de films TMDB", + "components.Discover.tmdbmoviekeyword": "Mots-clés de films TMDB", + "components.Discover.tmdbmoviestreamingservices": "Services de streaming de films TMDB", + "components.Discover.tmdbnetwork": "Réseau TMDB", + "components.Discover.tmdbsearch": "Recherche TMDB", + "components.Discover.tmdbstudio": "Studio TMDB", + "components.Discover.tmdbtvgenre": "Genre de séries TMDB", + "components.Discover.tmdbtvkeyword": "Mots-clés de séries TMDB", + "components.Discover.tmdbtvstreamingservices": "Services de streaming de séries TV TMDB", "components.Discover.trending": "Tendances", + "components.Discover.tvgenres": "Genre de séries", "components.Discover.upcoming": "Films à venir", "components.Discover.upcomingmovies": "Films à venir", "components.Discover.upcomingtv": "Séries à venir", + "components.Discover.updatefailed": "Quelque chose, s’est mal passé en mettant à jour les paramètres de personnalisation de découverte.", + "components.Discover.updatesuccess": "Mise à jour des paramètres de personnalisation de découverte.", "components.DownloadBlock.estimatedtime": "Estimé {time}", - "components.DownloadBlock.formattedTitle": "{title} : Saison {seasonNumber} Épisode {episodeNumber}", + "components.DownloadBlock.formattedTitle": "{title} : Saison {seasonNumber} Épisode {episodeNumber}", "components.IssueDetails.IssueComment.areyousuredelete": "Êtes-vous sûr de vouloir supprimer ce commentaire ?", "components.IssueDetails.IssueComment.delete": "Supprimer le commentaire", "components.IssueDetails.IssueComment.edit": "Éditer le commentaire", "components.IssueDetails.IssueComment.postedby": "Ajouté {relativeTime} par {username}", "components.IssueDetails.IssueComment.postedbyedited": "Ajouté {relativeTime} par {username} (Édité)", - "components.IssueDetails.IssueComment.validationComment": "Vous devez écrire un message", + "components.IssueDetails.IssueComment.validationComment": "Vous devez écrire un message.", "components.IssueDetails.IssueDescription.deleteissue": "Supprimer le problème", "components.IssueDetails.IssueDescription.description": "Description", "components.IssueDetails.IssueDescription.edit": "Éditer la description", @@ -62,8 +152,8 @@ "components.IssueDetails.openedby": "#{issueId} ouvert {relativeTime} par {username}", "components.IssueDetails.openin4karr": "Ouvrir dans {arr} 4K", "components.IssueDetails.openinarr": "Ouvrir dans {arr}", - "components.IssueDetails.play4konplex": "Lire en 4K sur Plex", - "components.IssueDetails.playonplex": "Lire sur Plex", + "components.IssueDetails.play4konplex": "Lire en 4K sur {mediaServerName}", + "components.IssueDetails.playonplex": "Lire sur {mediaServerName}", "components.IssueDetails.problemepisode": "Épisode concerné", "components.IssueDetails.problemseason": "Saison concernée", "components.IssueDetails.reopenissue": "Rouvrir le problème", @@ -102,7 +192,7 @@ "components.IssueModal.CreateIssueModal.toastFailedCreate": "Un problème est survenu lors de la soumission du problème.", "components.IssueModal.CreateIssueModal.toastSuccessCreate": "Le signalement du problème pour {title} a été soumis avec succès !", "components.IssueModal.CreateIssueModal.toastviewissue": "Afficher le problème", - "components.IssueModal.CreateIssueModal.validationMessageRequired": "Vous devez fournir une description", + "components.IssueModal.CreateIssueModal.validationMessageRequired": "Vous devez fournir une description.", "components.IssueModal.CreateIssueModal.whatswrong": "Qu’est-ce qui ne va pas ?", "components.IssueModal.issueAudio": "Audio", "components.IssueModal.issueOther": "Autre", @@ -112,6 +202,8 @@ "components.LanguageSelector.originalLanguageDefault": "Toutes les langues", "components.Layout.LanguagePicker.displaylanguage": "Langue d'affichage", "components.Layout.SearchInput.searchPlaceholder": "Rechercher des films et des séries", + "components.Layout.Sidebar.browsemovies": "Films", + "components.Layout.Sidebar.browsetv": "Séries", "components.Layout.Sidebar.dashboard": "Découvrir", "components.Layout.Sidebar.issues": "Problèmes", "components.Layout.Sidebar.requests": "Demandes", @@ -123,23 +215,43 @@ "components.Layout.UserDropdown.requests": "Demandes", "components.Layout.UserDropdown.settings": "Paramètres", "components.Layout.UserDropdown.signout": "Se déconnecter", + "components.Layout.UserWarnings.emailInvalid": "L'adresse e-mail est invalide.", + "components.Layout.UserWarnings.emailRequired": "Une adresse e-mail est requise.", + "components.Layout.UserWarnings.passwordRequired": "Un mot de passe est requis.", "components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} en retard", "components.Layout.VersionStatus.outofdate": "Obsolète", "components.Layout.VersionStatus.streamdevelop": "Développement de Jellyseerr", "components.Layout.VersionStatus.streamstable": "Jellyseerr stable", + "components.Login.credentialerror": "Le nom d’utilisateur ou le mot de passe est incorrect.", + "components.Login.description": "Comme il s’agit de votre première connexion à {applicationName}, vous devez ajouter une adresse courrielle valide.", "components.Login.email": "Adresse e-mail", "components.Login.forgotpassword": "Mot de passe oublié ?", + "components.Login.host": "{mediaServerName} URL", + "components.Login.initialsignin": "Connexion", + "components.Login.initialsigningin": "Connexion en cours…", "components.Login.loginerror": "Une erreur s'est produite lors de la tentative de connexion.", "components.Login.password": "Mot de passe", + "components.Login.save": "Ajouter", + "components.Login.saving": "Ajout…", "components.Login.signin": "Connexion", "components.Login.signingin": "Connexion en cours…", - "components.Login.signinheader": "Connectez-vous pour continuer", + "components.Login.signinheader": "Connectez-vous pour continuer.", + "components.Login.signinwithjellyfin": "Utilisez votre compte {mediaServerName}", "components.Login.signinwithoverseerr": "Utilisez votre compte {applicationTitle}", "components.Login.signinwithplex": "Utilisez votre compte Plex", - "components.Login.validationemailrequired": "Vous devez fournir un e-mail valide", - "components.Login.validationpasswordrequired": "Vous devez renseigner un mot de passe", + "components.Login.title": "Ajouter un e-mail", + "components.Login.username": "Nom d'utilisateur", + "components.Login.validationEmailFormat": "E-mail invalide", + "components.Login.validationEmailRequired": "Vous devez fournir un e-mail.", + "components.Login.validationemailformat": "Vous devez fournir un e-mail valide.", + "components.Login.validationemailrequired": "Vous devez fournir un e-mail valide.", + "components.Login.validationhostformat": "URL valide requise", + "components.Login.validationhostrequired": "{mediaServerName} URL requise", + "components.Login.validationpasswordrequired": "Vous devez renseigner un mot de passe.", + "components.Login.validationusernamerequired": "Nom d'utilisateur requis", "components.ManageSlideOver.alltime": "Tout le temps", "components.ManageSlideOver.downloadstatus": "Téléchargement(s)", + "components.MovieDetails.imdbuserscore": "Note des utilisateurs IMDB", "components.ManageSlideOver.manageModalAdvanced": "Avancé", "components.ManageSlideOver.manageModalClearMedia": "Effacer les données", "components.ManageSlideOver.manageModalClearMediaWarning": "* Ceci supprimera de manière irréversible toutes les données de ce(tte) {mediaType}, y compris les demandes éventuelles. Si cet élément existe dans votre bibliothèque {mediaServerName}, les informations sur le média seront recréées lors de la prochaine analyse.", @@ -147,6 +259,7 @@ "components.ManageSlideOver.manageModalMedia": "Média(s)", "components.ManageSlideOver.manageModalMedia4k": "Média(s) 4K", "components.ManageSlideOver.manageModalNoRequests": "Aucune demande.", + "components.ManageSlideOver.manageModalRemoveMediaWarning": "* Cela supprimera irréversiblement ce(tte) {mediaType} de {arr}, y compris tous les fichiers.", "components.ManageSlideOver.manageModalRequests": "Demandes", "components.ManageSlideOver.manageModalTitle": "Gérer {mediaType}", "components.ManageSlideOver.mark4kavailable": "Marquer comme disponible en 4K", @@ -160,6 +273,8 @@ "components.ManageSlideOver.pastdays": "{days, number} derniers jours", "components.ManageSlideOver.playedby": "Joué par", "components.ManageSlideOver.plays": "{playCount, number} {playCount, plural, one {lecture} other {lectures}}", + "components.ManageSlideOver.removearr": "Supprimer de {arr}", + "components.ManageSlideOver.removearr4k": "Supprimer de {arr} 4K", "components.ManageSlideOver.tvshow": "série", "components.MediaSlider.ShowMoreCard.seemore": "Voir plus", "components.MovieDetails.MovieCast.fullcast": "Casting complet", @@ -167,22 +282,25 @@ "components.MovieDetails.budget": "Budget", "components.MovieDetails.cast": "Casting", "components.MovieDetails.digitalrelease": "Sortie numérique", + "components.MovieDetails.downloadstatus": "Statut du téléchargement", "components.MovieDetails.managemovie": "Gérer le film", "components.MovieDetails.mark4kavailable": "Marquer comme disponible en 4K", "components.MovieDetails.markavailable": "Marquer comme disponible", + "components.MovieDetails.openradarr": "Ouvrir le film dans Radarr", + "components.MovieDetails.openradarr4k": "Ouvrir le film dans Radarr 4K", "components.MovieDetails.originallanguage": "Langue originale", "components.MovieDetails.originaltitle": "Titre original", "components.MovieDetails.overview": "Résumé", "components.MovieDetails.overviewunavailable": "Résumé indisponible.", "components.MovieDetails.physicalrelease": "Sortie physique", - "components.MovieDetails.play4konplex": "Lire en 4K sur Plex", - "components.MovieDetails.playonplex": "Lire sur Plex", - "components.MovieDetails.productioncountries": "Pays de production", + "components.MovieDetails.play": "Lire sur {mediaServerName}", + "components.MovieDetails.play4k": "Lire en 4k sur {mediaServerName}", + "components.MovieDetails.productioncountries": "{countryCount, plural, one {Pays} other {Pays}} de production", "components.MovieDetails.recommendations": "Recommandations", - "components.MovieDetails.releasedate": "{releaseCount, plural, one {Date} other {Dates}} de sortie", + "components.MovieDetails.releasedate": "{releaseCount, plural, one {Date de sortie} other {Date de sorties}}", "components.MovieDetails.reportissue": "Signaler un problème", - "components.MovieDetails.revenue": "Revenus", - "components.MovieDetails.rtaudiencescore": "Note d'audience de Rotten Tomatoes", + "components.MovieDetails.revenue": "Revenu", + "components.MovieDetails.rtaudiencescore": "Score d’audience de Rotten Tomatoes", "components.MovieDetails.rtcriticsscore": "Rotten Tomatoes Tomatomètre", "components.MovieDetails.runtime": "{minutes} minutes", "components.MovieDetails.showless": "Montrer moins", @@ -210,7 +328,7 @@ "components.NotificationTypeSelector.mediaapproved": "Demande validée", "components.NotificationTypeSelector.mediaapprovedDescription": "Envoyer des notifications lorsqu'une demande de média est validée manuellement.", "components.NotificationTypeSelector.mediaautorequested": "Demande soumise automatiquement", - "components.NotificationTypeSelector.mediaautorequestedDescription": "Recevez une notification lorsque de nouvelles demandes de médias sont automatiquement soumises pour des éléments de votre Plex Watchlist.", + "components.NotificationTypeSelector.mediaautorequestedDescription": "Recevez une notification lorsque de nouvelles demandes de médias sont automatiquement soumises pour des éléments de votre liste de visionnage(s) Plex.", "components.NotificationTypeSelector.mediaavailable": "Demande disponible", "components.NotificationTypeSelector.mediaavailableDescription": "Envoyer des notifications lorsque le média demandé devient disponible.", "components.NotificationTypeSelector.mediadeclined": "Demande refusée", @@ -230,7 +348,7 @@ "components.NotificationTypeSelector.usermediadeclinedDescription": "Être averti(e) lorsque vos demandes de médias sont refusées.", "components.NotificationTypeSelector.usermediafailedDescription": "Être averti(e) lorsqu'une demande de média n'a pas pu être ajoutée à Radarr ou Sonarr.", "components.NotificationTypeSelector.usermediarequestedDescription": "Être averti(e) lorsque d'autres utilisateurs soumettent une demande de média qui nécessite une validation.", - "components.PermissionEdit.admin": "Admin", + "components.PermissionEdit.admin": "Administateur", "components.PermissionEdit.adminDescription": "Accès administrateur complet. Contourne toutes les autres permissions (sélectionnées ou non).", "components.PermissionEdit.advancedrequest": "Demandes avancées", "components.PermissionEdit.advancedrequestDescription": "Permet de modifier les options de demande de média avancées.", @@ -247,11 +365,11 @@ "components.PermissionEdit.autoapproveSeries": "Valider automatiquement les séries", "components.PermissionEdit.autoapproveSeriesDescription": "Valider automatiquement les demandes de séries non-4K.", "components.PermissionEdit.autorequest": "Demande automatique", - "components.PermissionEdit.autorequestDescription": "Autorise l'envoi de demande automatique pour les médias non-4K via la Plex Watchlist.", + "components.PermissionEdit.autorequestDescription": "Autorise l'envoi de demande automatique pour les médias non-4K via la liste de visionnage(s) Plex.", "components.PermissionEdit.autorequestMovies": "Demander automatiquement les Films", - "components.PermissionEdit.autorequestMoviesDescription": "Autorise l'envoi de demande automatique pour les médias non-4K via la Plex Watchlist.", + "components.PermissionEdit.autorequestMoviesDescription": "Autorise l'envoi de demande automatique pour les médias non-4K via la liste de visionnage(s) Plex.", "components.PermissionEdit.autorequestSeries": "Demander automatiquement les Séries", - "components.PermissionEdit.autorequestSeriesDescription": "Autorise l'envoi de demande automatique pour les médias non-4K via la Plex Watchlist.", + "components.PermissionEdit.autorequestSeriesDescription": "Autorise l'envoi de demande automatique pour les médias non-4K via liste de visionnage(s) Plex.", "components.PermissionEdit.createissues": "Signaler des problèmes", "components.PermissionEdit.createissuesDescription": "Autorise à signaler les problèmes liés aux médias.", "components.PermissionEdit.manageissues": "Gérer les problèmes", @@ -280,11 +398,11 @@ "components.PermissionEdit.viewrecentDescription": "Autorise à voir la liste des médias ajoutés récemment.", "components.PermissionEdit.viewrequests": "Voir les demandes", "components.PermissionEdit.viewrequestsDescription": "Autorise à afficher les demandes des autres utilisateurs.", - "components.PermissionEdit.viewwatchlists": "Voir Plex Watchlists", - "components.PermissionEdit.viewwatchlistsDescription": "Autorise à voir la Plex Watchlist des autres utilisateurs.", + "components.PermissionEdit.viewwatchlists": "Voir la liste de visionnage(s) Plex", + "components.PermissionEdit.viewwatchlistsDescription": "Autorise à voir la liste de visionnage(s) Plex des autres utilisateurs.", "components.PersonDetails.alsoknownas": "Aussi connu sous le(s) nom(s) : {names}", "components.PersonDetails.appearsin": "Apparitions", - "components.PersonDetails.ascharacter": "rôle : {character}", + "components.PersonDetails.ascharacter": "Rôle : {character}", "components.PersonDetails.birthdate": "Né(e) le {birthdate}", "components.PersonDetails.crewmember": "Équipe", "components.PersonDetails.lifespan": "{birthdate} – {deathdate}", @@ -311,14 +429,14 @@ "components.RequestBlock.rootfolder": "Dossier racine", "components.RequestBlock.seasons": "{seasonCount, plural, one {Saison} other {Saisons}}", "components.RequestBlock.server": "Serveur de destination", - "components.RequestButton.approve4krequests": "Valider {requestCount, plural, one {demande en 4K} other {{requestCount} demandes en 4K}}", + "components.RequestButton.approve4krequests": "Valider {requestCount, plural, one {Demande en 4k} other {{requestCount} demandes en 4K}}", "components.RequestButton.approverequest": "Valider la demande", "components.RequestButton.approverequest4k": "Valider la demande 4K", - "components.RequestButton.approverequests": "Valider {requestCount, plural, one {demande} other {{requestCount} demandes}}", - "components.RequestButton.decline4krequests": "Refuser {requestCount, plural, one {demande en 4K} other {{requestCount} demandes en 4K}}", + "components.RequestButton.approverequests": "Valider {requestCount, plural, one {Demande} other {{requestCount} demandes}}", + "components.RequestButton.decline4krequests": "Refuser {requestCount, plural, one {Demande en 4K} other {{requestCount} demandes en 4K}}", "components.RequestButton.declinerequest": "Refuser la demande", "components.RequestButton.declinerequest4k": "Refuser la demande 4K", - "components.RequestButton.declinerequests": "Refuser {requestCount, plural, one {demande} other {{requestCount} demandes}}", + "components.RequestButton.declinerequests": "Refuser {requestCount, plural, one {Demande} other {{requestCount} demandes}}", "components.RequestButton.requestmore": "Demander d'autres ajouts", "components.RequestButton.requestmore4k": "Demander d'autres ajouts en 4K", "components.RequestButton.viewrequest": "Voir la demande", @@ -326,16 +444,16 @@ "components.RequestCard.approverequest": "Approuver la demande", "components.RequestCard.cancelrequest": "Annuler la demande", "components.RequestCard.declinerequest": "Refuser la demande", - "components.RequestCard.deleterequest": "Supprimer la Demande", + "components.RequestCard.deleterequest": "Supprimer la demande", "components.RequestCard.editrequest": "Modifier la demande", "components.RequestCard.failedretry": "Une erreur s'est produite lors du renvoi de la demande.", "components.RequestCard.mediaerror": "{mediaType} non trouvé", "components.RequestCard.seasons": "{seasonCount, plural, one {Saison} other {Saisons}}", - "components.RequestCard.tmdbid": "TMDB ID", - "components.RequestCard.tvdbid": "TheTVDB ID", + "components.RequestCard.tmdbid": "Identifiant TMDB", + "components.RequestCard.tvdbid": "Identifiant TheTVDB", "components.RequestCard.unknowntitle": "Titre inconnu", "components.RequestList.RequestItem.cancelRequest": "Annuler la demande", - "components.RequestList.RequestItem.deleterequest": "Supprimer la Demande", + "components.RequestList.RequestItem.deleterequest": "Supprimer la demande", "components.RequestList.RequestItem.editrequest": "Modifier la demande", "components.RequestList.RequestItem.failedretry": "Une erreur s'est produite lors du renvoi de la demande.", "components.RequestList.RequestItem.mediaerror": "{mediaType} non trouvé", @@ -344,8 +462,8 @@ "components.RequestList.RequestItem.requested": "Demandé", "components.RequestList.RequestItem.requesteddate": "Demandé", "components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Saison} other {Saisons}}", - "components.RequestList.RequestItem.tmdbid": "TMDB ID", - "components.RequestList.RequestItem.tvdbid": "TheTVDB ID", + "components.RequestList.RequestItem.tmdbid": "Identifiant TMDB", + "components.RequestList.RequestItem.tvdbid": "Identifiant TheTVDB", "components.RequestList.RequestItem.unknowntitle": "Titre inconnu", "components.RequestList.requests": "Demandes", "components.RequestList.showallrequests": "Afficher toutes les demandes", @@ -384,7 +502,7 @@ "components.RequestModal.edit": "Modifier la demande", "components.RequestModal.errorediting": "Une erreur s'est produite lors de la modification de la demande.", "components.RequestModal.extras": "Extras", - "components.RequestModal.numberofepisodes": "Nbr d'épisodes", + "components.RequestModal.numberofepisodes": "Nombre d'épisodes", "components.RequestModal.pending4krequest": "Demande 4K en attente", "components.RequestModal.pendingapproval": "Votre demande est en attente de validation.", "components.RequestModal.pendingrequest": "Demande en attente", @@ -416,15 +534,22 @@ "components.ResetPassword.gobacklogin": "Retourner à la page de connexion", "components.ResetPassword.password": "Mot de passe", "components.ResetPassword.passwordreset": "Réinitialiser le mot de passe", - "components.ResetPassword.requestresetlinksuccessmessage": "Un lien de réinitialisation du mot de passe sera envoyé à l'e-mail fourni si il est associé à un utilisateur valide.", + "components.ResetPassword.requestresetlinksuccessmessage": "Un lien de réinitialisation du mot de passe sera envoyé à l'e-mail fourni s'il est associé à un utilisateur valide.", "components.ResetPassword.resetpassword": "Réinitialiser votre mot de passe", "components.ResetPassword.resetpasswordsuccessmessage": "Le mot de passe a été réinitialisé avec succès !", - "components.ResetPassword.validationemailrequired": "Vous devez fournir un e-mail valide", - "components.ResetPassword.validationpasswordmatch": "Les mots de passe doivent être les mêmes", - "components.ResetPassword.validationpasswordminchars": "Le mot de passe est trop court ; il doit comporter au moins 8 caractères", - "components.ResetPassword.validationpasswordrequired": "Vous devez renseigner un mot de passe", + "components.ResetPassword.validationemailrequired": "Vous devez fournir un e-mail valide.", + "components.ResetPassword.validationpasswordmatch": "Les mots de passe doivent être les mêmes.", + "components.ResetPassword.validationpasswordminchars": "Le mot de passe est trop court ; il doit comporter au moins 8 caractères.", + "components.ResetPassword.validationpasswordrequired": "Vous devez renseigner un mot de passe.", "components.Search.search": "Rechercher", "components.Search.searchresults": "Résultats de la recherche", + "components.Selector.nooptions": "Aucun résultat.", + "components.Selector.searchGenres": "Sélectionnez les genres…", + "components.Selector.searchKeywords": "Recherche de mots-clés…", + "components.Selector.searchStudios": "Recherche de studios…", + "components.Selector.showless": "Afficher moins", + "components.Selector.showmore": "Afficher plus", + "components.Selector.starttyping": "Commencer à taper pour rechercher.", "components.Settings.Notifications.NotificationsGotify.agentenabled": "Activer l'agent", "components.Settings.Notifications.NotificationsGotify.gotifysettingsfailed": "Les paramètres de notification Gotify n'ont pas pu être enregistrés.", "components.Settings.Notifications.NotificationsGotify.gotifysettingssaved": "Paramètres de notification de Gotify sauvegardés avec succès !", @@ -433,10 +558,10 @@ "components.Settings.Notifications.NotificationsGotify.toastGotifyTestSuccess": "Notification test Gotify envoyée !", "components.Settings.Notifications.NotificationsGotify.token": "Jeton d'application", "components.Settings.Notifications.NotificationsGotify.url": "URL du serveur", - "components.Settings.Notifications.NotificationsGotify.validationTokenRequired": "Vous devez fournir un jeton d'application", - "components.Settings.Notifications.NotificationsGotify.validationTypes": "Vous devez sélectionner au moins un type de notification", - "components.Settings.Notifications.NotificationsGotify.validationUrlRequired": "Vous devez fournir une URL valide", - "components.Settings.Notifications.NotificationsGotify.validationUrlTrailingSlash": "L'URL ne doit pas se terminer par un slash", + "components.Settings.Notifications.NotificationsGotify.validationTokenRequired": "Vous devez fournir un jeton d'application.", + "components.Settings.Notifications.NotificationsGotify.validationTypes": "Vous devez sélectionner au moins, un type de notification.", + "components.Settings.Notifications.NotificationsGotify.validationUrlRequired": "Vous devez fournir une URL valide.", + "components.Settings.Notifications.NotificationsGotify.validationUrlTrailingSlash": "L'URL ne doit pas se terminer par un slash.", "components.Settings.Notifications.NotificationsLunaSea.agentenabled": "Activer l'agent", "components.Settings.Notifications.NotificationsLunaSea.profileName": "Nom du Profil", "components.Settings.Notifications.NotificationsLunaSea.profileNameTip": "Uniquement nécessaire si vous n'utilisez pas le profil default", @@ -445,10 +570,10 @@ "components.Settings.Notifications.NotificationsLunaSea.toastLunaSeaTestFailed": "L'envoi de la notification test LunaSea a échoué.", "components.Settings.Notifications.NotificationsLunaSea.toastLunaSeaTestSending": "Envoi de la notification test LunaSea…", "components.Settings.Notifications.NotificationsLunaSea.toastLunaSeaTestSuccess": "Notification test LunaSea envoyée !", - "components.Settings.Notifications.NotificationsLunaSea.validationTypes": "Vous devez sélectionner au moins un type de notification", - "components.Settings.Notifications.NotificationsLunaSea.validationWebhookUrl": "Vous devez fournir une URL valide", - "components.Settings.Notifications.NotificationsLunaSea.webhookUrl": "URL de webhook", - "components.Settings.Notifications.NotificationsLunaSea.webhookUrlTip": "Votre URL de webhook de notification basée sur l'utilisateur ou l'appareil", + "components.Settings.Notifications.NotificationsLunaSea.validationTypes": "Vous devez sélectionner au moins un type de notification.", + "components.Settings.Notifications.NotificationsLunaSea.validationWebhookUrl": "Vous devez fournir une URL valide.", + "components.Settings.Notifications.NotificationsLunaSea.webhookUrl": "URL de Webhook", + "components.Settings.Notifications.NotificationsLunaSea.webhookUrlTip": "Votre URL de Webhook de notification basée sur l'utilisateur ou l'appareil", "components.Settings.Notifications.NotificationsPushbullet.accessToken": "Jeton d'accès", "components.Settings.Notifications.NotificationsPushbullet.accessTokenTip": "Créer un jeton à partir de vos paramètres de compte", "components.Settings.Notifications.NotificationsPushbullet.agentEnabled": "Activer l'agent", @@ -458,10 +583,10 @@ "components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestFailed": "L'envoi de la notification test Pushbullet a échoué.", "components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSending": "Envoi de la notification test Pushbullet…", "components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSuccess": "Notification test Pushbullet envoyée !", - "components.Settings.Notifications.NotificationsPushbullet.validationAccessTokenRequired": "Vous devez fournir un jeton d'accès", - "components.Settings.Notifications.NotificationsPushbullet.validationTypes": "Vous devez sélectionner au moins un type de notification", + "components.Settings.Notifications.NotificationsPushbullet.validationAccessTokenRequired": "Vous devez fournir un jeton d'accès.", + "components.Settings.Notifications.NotificationsPushbullet.validationTypes": "Vous devez sélectionner au moins, un type de notification.", "components.Settings.Notifications.NotificationsPushover.accessToken": "Jeton API d'application", - "components.Settings.Notifications.NotificationsPushover.accessTokenTip": "Enregistrer une application pour l'utiliser avec Jellyseerr", + "components.Settings.Notifications.NotificationsPushover.accessTokenTip": "Enregistrer une application pour l'utiliser avec Jellyseerr.", "components.Settings.Notifications.NotificationsPushover.agentenabled": "Activer l'agent", "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Les paramètres de notification Pushover n'ont pas pu être enregistrés.", "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Paramètres de notification Pushover enregistrés avec succès !", @@ -470,18 +595,18 @@ "components.Settings.Notifications.NotificationsPushover.toastPushoverTestSuccess": "Notification test Pushover envoyée !", "components.Settings.Notifications.NotificationsPushover.userToken": "Clé d'utilisateur ou de groupe", "components.Settings.Notifications.NotificationsPushover.userTokenTip": "Votre identifiant d'utilisateur ou de groupe de 30 caractères", - "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "Vous devez fournir un jeton d'application valide", - "components.Settings.Notifications.NotificationsPushover.validationTypes": "Vous devez sélectionner au moins un type de notification", - "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "Vous devez fournir une clé d'utilisateur ou de groupe valide", + "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "Vous devez fournir un jeton d'application valide.", + "components.Settings.Notifications.NotificationsPushover.validationTypes": "Vous devez sélectionner au moins un type de notification.", + "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "Vous devez fournir une clé d'utilisateur ou de groupe valide.", "components.Settings.Notifications.NotificationsSlack.agentenabled": "Activer l'agent", "components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Les paramètres de notification Slack n'ont pas pu être enregistrés.", "components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Paramètres de notification de Slack sauvegardés avec succès !", "components.Settings.Notifications.NotificationsSlack.toastSlackTestFailed": "L'envoi de la notification test Slack a échoué.", "components.Settings.Notifications.NotificationsSlack.toastSlackTestSending": "Envoi de la notification test Slack…", "components.Settings.Notifications.NotificationsSlack.toastSlackTestSuccess": "Notification test Slack envoyée !", - "components.Settings.Notifications.NotificationsSlack.validationTypes": "Vous devez sélectionner au moins un type de notification", - "components.Settings.Notifications.NotificationsSlack.validationWebhookUrl": "Vous devez fournir une URL valide", - "components.Settings.Notifications.NotificationsSlack.webhookUrl": "URL de webhook", + "components.Settings.Notifications.NotificationsSlack.validationTypes": "Vous devez sélectionner au moins un type de notification.", + "components.Settings.Notifications.NotificationsSlack.validationWebhookUrl": "Vous devez fournir une URL valide.", + "components.Settings.Notifications.NotificationsSlack.webhookUrl": "URL de Webhook", "components.Settings.Notifications.NotificationsSlack.webhookUrlTip": "Créer une intégration Webhook entrante", "components.Settings.Notifications.NotificationsWebPush.agentenabled": "Activer l'agent", "components.Settings.Notifications.NotificationsWebPush.httpsRequirement": "Afin de recevoir des notifications push web, Jellyseerr doit fonctionner en HTTPS.", @@ -497,16 +622,16 @@ "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "Les données utiles JSON par défaut ont été réinitialisées avec succès !", "components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Aide sur les variables de modèle", "components.Settings.Notifications.NotificationsWebhook.toastWebhookTestFailed": "La notification de test Web Push n’a pas été envoyée.", - "components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSending": "Envoi de notification de test webhook…", + "components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSending": "Envoi de notification de test Webhook…", "components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSuccess": "Notification de test Webhook envoyée !", "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "Vous devez fournir un JSON payload valide", "components.Settings.Notifications.NotificationsWebhook.validationTypes": "Vous devez sélectionner au moins un type de notification", "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrl": "Vous devez fournir une URL valide", - "components.Settings.Notifications.NotificationsWebhook.webhookUrl": "URL de webhook", - "components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Échec de l'enregistrement des paramètres de notification du webhook.", + "components.Settings.Notifications.NotificationsWebhook.webhookUrl": "URL de Webhook", + "components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Échec de l'enregistrement des paramètres de notification du Webhook.", "components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Paramètres de notification Webhook enregistrés avec succès !", "components.Settings.Notifications.agentenabled": "Activer l'agent", - "components.Settings.Notifications.allowselfsigned": "Autoriser les certificats autosignés", + "components.Settings.Notifications.allowselfsigned": "Autoriser les certificats auto-signés", "components.Settings.Notifications.authPass": "Mot de passe SMTP", "components.Settings.Notifications.authUser": "Nom d'utilisateur SMTP", "components.Settings.Notifications.botAPI": "Jeton d'autorisation du bot", @@ -514,8 +639,8 @@ "components.Settings.Notifications.botAvatarUrl": "L'URL de l'avatar de votre Bot", "components.Settings.Notifications.botUsername": "Pseudonyme du Bot", "components.Settings.Notifications.botUsernameTip": "Permet aux utilisateurs de démarrer également une conversation avec votre bot et de configurer leurs propres notifications personnelles", - "components.Settings.Notifications.chatId": "ID discussion", - "components.Settings.Notifications.chatIdTip": "Démarrez une discussion avec votre bot, ajoutez @get_id_bot et exécutez la commande /my_id", + "components.Settings.Notifications.chatId": "Identifiant de discussion", + "components.Settings.Notifications.chatIdTip": "Démarrez une discussion avec votre bot, ajoutez @get_id_bot et exécutez la commande /my_id.", "components.Settings.Notifications.discordsettingsfailed": "Les paramètres de notification Discord n'ont pas pu être enregistrés.", "components.Settings.Notifications.discordsettingssaved": "Paramètres de notification Discord enregistrés avec succès !", "components.Settings.Notifications.emailsender": "Adresse de l'expéditeur", @@ -527,11 +652,11 @@ "components.Settings.Notifications.encryptionImplicitTls": "Utiliser TLS implicite", "components.Settings.Notifications.encryptionNone": "Aucune", "components.Settings.Notifications.encryptionOpportunisticTls": "Toujours utiliser STARTTLS", - "components.Settings.Notifications.encryptionTip": "Dans la majorité des cas, TLS implicite utilise le port 465 et STARTTLS utilise le port 587", + "components.Settings.Notifications.encryptionTip": "Dans la majorité des cas, TLS implicite utilise le port 465 et STARTTLS utilise le port 587.", "components.Settings.Notifications.pgpPassword": "PGP mot de passe", - "components.Settings.Notifications.pgpPasswordTip": "Signer des emails chiffrés en utilisant OpenPGP", + "components.Settings.Notifications.pgpPasswordTip": "Signer des e-mails chiffrés en utilisant OpenPGP", "components.Settings.Notifications.pgpPrivateKey": "PGP Clé privée", - "components.Settings.Notifications.pgpPrivateKeyTip": "Signer des emails chiffrés en utilisant OpenPGP", + "components.Settings.Notifications.pgpPrivateKeyTip": "Signer des e-mails chiffrés en utilisant OpenPGP", "components.Settings.Notifications.sendSilently": "Envoyer silencieusement", "components.Settings.Notifications.sendSilentlyTip": "Envoyer des notifications sans son", "components.Settings.Notifications.senderName": "Nom de l'expéditeur", @@ -548,17 +673,18 @@ "components.Settings.Notifications.toastTelegramTestFailed": "L'envoi de la notification test à Telegram a échoué.", "components.Settings.Notifications.toastTelegramTestSending": "Envoi de la notification de test à Telegram…", "components.Settings.Notifications.toastTelegramTestSuccess": "Notification de test de télégramme envoyée !", - "components.Settings.Notifications.validationBotAPIRequired": "Vous devez fournir la clé d'autorisation du bot", - "components.Settings.Notifications.validationChatIdRequired": "Vous devez fournir un identifiant de discussion valide", - "components.Settings.Notifications.validationEmail": "Vous devez fournir un e-mail valide", - "components.Settings.Notifications.validationPgpPassword": "Vous devez fournir un mot de passe PGP", - "components.Settings.Notifications.validationPgpPrivateKey": "Vous devez fournir une clé privée PGP valide si un mot de passe PGP est entré", - "components.Settings.Notifications.validationSmtpHostRequired": "Vous devez fournir un nom d'hôte ou une adresse IP valide", - "components.Settings.Notifications.validationSmtpPortRequired": "Vous devez fournir un numéro de port valide", - "components.Settings.Notifications.validationTypes": "Vous devez sélectionner au moins un type de notification", - "components.Settings.Notifications.validationUrl": "Vous devez fournir une URL valide", - "components.Settings.Notifications.webhookUrl": "URL de webhook", - "components.Settings.Notifications.webhookUrlTip": "Créez une intégration de webhook dans votre serveur", + "components.Settings.Notifications.userEmailRequired": "E-mail de l’utilisateur requis", + "components.Settings.Notifications.validationBotAPIRequired": "Vous devez fournir la clé d'autorisation du bot.", + "components.Settings.Notifications.validationChatIdRequired": "Vous devez fournir un identifiant de discussion valide.", + "components.Settings.Notifications.validationEmail": "Vous devez fournir un e-mail valide.", + "components.Settings.Notifications.validationPgpPassword": "Vous devez fournir un mot de passe PGP.", + "components.Settings.Notifications.validationPgpPrivateKey": "Vous devez fournir une clé privée PGP valide si un mot de passe PGP est entré.", + "components.Settings.Notifications.validationSmtpHostRequired": "Vous devez fournir un nom d'hôte ou une adresse IP valide.", + "components.Settings.Notifications.validationSmtpPortRequired": "Vous devez fournir un numéro de port valide.", + "components.Settings.Notifications.validationTypes": "Vous devez sélectionner au moins un type de notification.", + "components.Settings.Notifications.validationUrl": "Vous devez fournir une URL valide.", + "components.Settings.Notifications.webhookUrl": "URL de Webhook", + "components.Settings.Notifications.webhookUrlTip": "Créez une intégration de Webhook dans votre serveur.", "components.Settings.RadarrModal.add": "Ajouter un serveur", "components.Settings.RadarrModal.announced": "Annoncé", "components.Settings.RadarrModal.apiKey": "Clé d'API", @@ -590,26 +716,28 @@ "components.Settings.RadarrModal.servername": "Nom du serveur", "components.Settings.RadarrModal.ssl": "Utiliser SSL", "components.Settings.RadarrModal.syncEnabled": "Activer les scans", + "components.Settings.RadarrModal.tagRequests": "Demandes de Tags", + "components.Settings.RadarrModal.tagRequestsInfo": "Ajouter automatiquement un tag supplémentaire avec l’identifiant de l'utilisateur et le nom d’affichage du demandeur.", "components.Settings.RadarrModal.tags": "Tags", - "components.Settings.RadarrModal.testFirstQualityProfiles": "Testez la connexion pour charger les profils qualité", - "components.Settings.RadarrModal.testFirstRootFolders": "Testez la connexion pour charger les dossiers racine", - "components.Settings.RadarrModal.testFirstTags": "Tester la connexion pour charger les tags", + "components.Settings.RadarrModal.testFirstQualityProfiles": "Testez la connexion pour charger les profils qualité.", + "components.Settings.RadarrModal.testFirstRootFolders": "Testez la connexion pour charger les dossiers racine.", + "components.Settings.RadarrModal.testFirstTags": "Tester la connexion pour charger les tags.", "components.Settings.RadarrModal.toastRadarrTestFailure": "Échec de la connexion à Radarr.", "components.Settings.RadarrModal.toastRadarrTestSuccess": "Connexion avec le Serveur Radarr établie avec succès !", - "components.Settings.RadarrModal.validationApiKeyRequired": "Vous devez fournir une clé d'API", - "components.Settings.RadarrModal.validationApplicationUrl": "Vous devez fournir une URL valide", - "components.Settings.RadarrModal.validationApplicationUrlTrailingSlash": "L'URL ne doit pas se terminer par une barre oblique finale", - "components.Settings.RadarrModal.validationBaseUrlLeadingSlash": "L'URL de base doit être précédée d'un slash", - "components.Settings.RadarrModal.validationBaseUrlTrailingSlash": "L'URL de base ne doit pas se terminer par un slash", - "components.Settings.RadarrModal.validationHostnameRequired": "Vous devez fournir un nom d'hôte ou une adresse IP valide", - "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Vous devez sélectionner une disponibilité minimale", - "components.Settings.RadarrModal.validationNameRequired": "Vous devez fournir un nom de serveur", - "components.Settings.RadarrModal.validationPortRequired": "Vous devez fournir un numéro de port valide", - "components.Settings.RadarrModal.validationProfileRequired": "Vous devez sélectionner un profil", - "components.Settings.RadarrModal.validationRootFolderRequired": "Vous devez sélectionner un dossier racine", + "components.Settings.RadarrModal.validationApiKeyRequired": "Vous devez fournir une clé d'API.", + "components.Settings.RadarrModal.validationApplicationUrl": "Vous devez fournir une URL valide.", + "components.Settings.RadarrModal.validationApplicationUrlTrailingSlash": "L'URL ne doit pas se terminer par une barre oblique finale.", + "components.Settings.RadarrModal.validationBaseUrlLeadingSlash": "L'URL de base doit être précédée d'un slash.", + "components.Settings.RadarrModal.validationBaseUrlTrailingSlash": "L'URL de base ne doit pas se terminer par un slash.", + "components.Settings.RadarrModal.validationHostnameRequired": "Vous devez fournir un nom d'hôte ou une adresse IP valide.", + "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Vous devez sélectionner une disponibilité minimale.", + "components.Settings.RadarrModal.validationNameRequired": "Vous devez fournir un nom de serveur.", + "components.Settings.RadarrModal.validationPortRequired": "Vous devez fournir un numéro de port valide.", + "components.Settings.RadarrModal.validationProfileRequired": "Vous devez sélectionner un profil.", + "components.Settings.RadarrModal.validationRootFolderRequired": "Vous devez sélectionner un dossier racine.", "components.Settings.SettingsAbout.Releases.currentversion": "Actuelle", "components.Settings.SettingsAbout.Releases.latestversion": "Dernière version", - "components.Settings.SettingsAbout.Releases.releasedataMissing": "Les données de version sont actuellement indisponible.", + "components.Settings.SettingsAbout.Releases.releasedataMissing": "Les données de version sont actuellement indisponibles.", "components.Settings.SettingsAbout.Releases.releases": "Versions", "components.Settings.SettingsAbout.Releases.versionChangelog": "Journal des modifications de la version {version}", "components.Settings.SettingsAbout.Releases.viewchangelog": "Voir le journal des modifications", @@ -620,7 +748,7 @@ "components.Settings.SettingsAbout.documentation": "Documentation", "components.Settings.SettingsAbout.gettingsupport": "Obtenir de l'aide", "components.Settings.SettingsAbout.githubdiscussions": "Discussions GitHub", - "components.Settings.SettingsAbout.helppaycoffee": "Aidez-nous à payer le café", + "components.Settings.SettingsAbout.helppaycoffee": "Aidez-nous à payer le café.", "components.Settings.SettingsAbout.outofdate": "Obsolète", "components.Settings.SettingsAbout.overseerrinformation": "À propos de Jellyseerr", "components.Settings.SettingsAbout.preferredmethod": "Préféré", @@ -631,6 +759,7 @@ "components.Settings.SettingsAbout.totalrequests": "Total des demandes", "components.Settings.SettingsAbout.uptodate": "À jour", "components.Settings.SettingsAbout.version": "Version", + "components.Settings.SettingsJobsCache.availability-sync": "Synchronisation de la disponibilité des médias", "components.Settings.SettingsJobsCache.cache": "Cache", "components.Settings.SettingsJobsCache.cacheDescription": "Jellyseerr met en cache les demandes aux points de terminaison d'API externes pour optimiser les performances et éviter de faire des appels d'API inutiles.", "components.Settings.SettingsJobsCache.cacheflushed": "Cache de {cachename} vidé.", @@ -643,21 +772,24 @@ "components.Settings.SettingsJobsCache.canceljob": "Annuler la tâche", "components.Settings.SettingsJobsCache.command": "Commande", "components.Settings.SettingsJobsCache.download-sync": "Synchroniser les téléchargements", - "components.Settings.SettingsJobsCache.download-sync-reset": "Reset de la synchronisation des téléchargements", + "components.Settings.SettingsJobsCache.download-sync-reset": "Réinitialisation de la synchronisation des téléchargements", "components.Settings.SettingsJobsCache.editJobSchedule": "Modifier la tâche", "components.Settings.SettingsJobsCache.editJobScheduleCurrent": "Fréquence actuelle", "components.Settings.SettingsJobsCache.editJobSchedulePrompt": "Nouvelle fréquence", "components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Toutes les {jobScheduleHours, plural, one {heure} other {{jobScheduleHours} heures}}", "components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Toutes les {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}", + "components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Toutes les {jobScheduleSeconds, plural, one {seconde} other {{jobScheduleSeconds} secondes}}", "components.Settings.SettingsJobsCache.flushcache": "Vider le cache", - "components.Settings.SettingsJobsCache.jelly-recently-added-scan": "Scanner les récents ajoutés sur Jellyfin", - "components.Settings.SettingsJobsCache.jellyfin-full-scan": "Scanner toute la bibliothèque Jellyfin", + "components.Settings.SettingsJobsCache.jelly-recently-added-scan": "Scanner les récents ajoutés sur Jellyfin.", + "components.Settings.SettingsJobsCache.jellyfin-full-scan": "Scanner toute la bibliothèque Jellyfin.", "components.Settings.SettingsJobsCache.image-cache-cleanup": "Vider le cache des images", "components.Settings.SettingsJobsCache.imagecache": "Cache des images", "components.Settings.SettingsJobsCache.imagecacheDescription": "Une fois activé dans les paramètres, Jellyseerr va mettre en cache les images provenant de sources externes pré-configurées. Les images mises en cache sont sauvegardées dans votre dossier de configuration. Vous pouvez les trouver dans {appDataPath}/cache/images.", "components.Settings.SettingsJobsCache.imagecachecount": "Images mises en cache", "components.Settings.SettingsJobsCache.imagecachesize": "Taille totale du cache", - "components.Settings.SettingsJobsCache.jobScheduleEditFailed": "Un problème est survenu lors de l'enregistrement de la tâche.", + "components.Settings.SettingsJobsCache.jellyfin-full-sync": "Scan complet de la bibliothèque Jellyfin", + "components.Settings.SettingsJobsCache.jellyfin-recently-added-sync": "Scan des ajouts récents Jellyfin", + "components.Settings.SettingsJobsCache.jobScheduleEditFailed": "Un problème est survenu lors de l'enregistrement de la tâche", "components.Settings.SettingsJobsCache.jobScheduleEditSaved": "Tâche modifiée avec succès !", "components.Settings.SettingsJobsCache.jobcancelled": "{jobname} annulé.", "components.Settings.SettingsJobsCache.jobname": "Nom de la tâche", @@ -667,9 +799,9 @@ "components.Settings.SettingsJobsCache.jobstarted": "{jobname} a commencé.", "components.Settings.SettingsJobsCache.jobtype": "Type", "components.Settings.SettingsJobsCache.nextexecution": "Prochaine exécution", - "components.Settings.SettingsJobsCache.plex-full-scan": "Scan complet des bibliothèques Plex", - "components.Settings.SettingsJobsCache.plex-recently-added-scan": "Scan des ajouts récents aux bibliothèques Plex", - "components.Settings.SettingsJobsCache.plex-watchlist-sync": "Synchronisation de la Plex Watchlist", + "components.Settings.SettingsJobsCache.plex-full-scan": "Scan complet des bibliothèques Plex.", + "components.Settings.SettingsJobsCache.plex-recently-added-scan": "Scan des ajouts récents aux bibliothèques Plex.", + "components.Settings.SettingsJobsCache.plex-watchlist-sync": "Synchronisation de la liste de visionnage(s) Plex", "components.Settings.SettingsJobsCache.process": "Processus", "components.Settings.SettingsJobsCache.radarr-scan": "Scan de Radarr", "components.Settings.SettingsJobsCache.runnow": "Exécuter", @@ -693,17 +825,44 @@ "components.Settings.SettingsLogs.showall": "Afficher tous les journaux", "components.Settings.SettingsLogs.time": "Horodatage", "components.Settings.SettingsLogs.viewdetails": "Voir les détails", + "components.Settings.SettingsMain.apikey": "Clé d'API", + "components.Settings.SettingsMain.applicationTitle": "Titre de l'application", + "components.Settings.SettingsMain.applicationurl": "URL de l'application", + "components.Settings.SettingsMain.cacheImages": "Activez la mise en cache des images", + "components.Settings.SettingsMain.cacheImagesTip": "Mettre en cache les images externes (nécessite une quantité importante d’espace disque).", + "components.Settings.SettingsMain.csrfProtection": "Activez la protection CSRF", + "components.Settings.SettingsMain.csrfProtectionHoverTip": "N’ACTIVEZ PAS ce paramètre si vous ne comprenez pas ce que vous faites !", + "components.Settings.SettingsMain.csrfProtectionTip": "Définir l’accès à l’API externe en lecture seule (nécessite HTTPS).", + "components.Settings.SettingsMain.general": "Général", + "components.Settings.SettingsMain.generalsettings": "Paramètres généraux", + "components.Settings.SettingsMain.generalsettingsDescription": "Configurez les paramètres globaux et par défaut pour Jellyseerr.", + "components.Settings.SettingsMain.hideAvailable": "Masquer les médias disponibles", + "components.Settings.SettingsMain.locale": "Langue d'affichage", + "components.Settings.SettingsMain.originallanguage": "Langue de découverte", + "components.Settings.SettingsMain.originallanguageTip": "Filtrer le contenu par langue originale", + "components.Settings.SettingsMain.partialRequestsEnabled": "Autoriser les demandes de séries partielles", + "components.Settings.SettingsMain.region": "Découverte par région", + "components.Settings.SettingsMain.regionTip": "Filtrer le contenu par disponibilité régionale", + "components.Settings.SettingsMain.toastApiKeyFailure": "Quelque chose, a mal tourné lors de la génération d’une nouvelle clé API.", + "components.Settings.SettingsMain.toastApiKeySuccess": "Nouvelle clé API générée avec succès !", + "components.Settings.SettingsMain.toastSettingsFailure": "Quelque chose, a mal tourné lors de l’enregistrement des paramètres.", + "components.Settings.SettingsMain.toastSettingsSuccess": "Paramètres enregistrés avec succès !", + "components.Settings.SettingsMain.trustProxy": "Activer le support de proxy", + "components.Settings.SettingsMain.trustProxyTip": "Autoriser Jellyseerr à enregistrer correctement les adresses IP des clients derrière un proxy.", + "components.Settings.SettingsMain.validationApplicationTitle": "Vous devez fournir un titre d'application.", + "components.Settings.SettingsMain.validationApplicationUrl": "Vous devez fournir une URL valide", + "components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "L’URL ne doit pas se terminer par une barre oblique.", "components.Settings.SettingsUsers.defaultPermissions": "Permissions par défaut", - "components.Settings.SettingsUsers.defaultPermissionsTip": "Autorisations par défaut attribuées aux nouveaux utilisateurs", + "components.Settings.SettingsUsers.defaultPermissionsTip": "Autorisations par défaut attribuées aux nouveaux utilisateurs.", "components.Settings.SettingsUsers.localLogin": "Activer la connexion locale", - "components.Settings.SettingsUsers.localLoginTip": "Autoriser les utilisateurs à se connecter en utilisant leur adresse e-mail et leur mot de passe, au lieu de Plex OAuth", + "components.Settings.SettingsUsers.localLoginTip": "Autoriser les utilisateurs à se connecter en utilisant leur adresse e-mail et leur mot de passe, au lieu de Plex OAuth.", "components.Settings.SettingsUsers.movieRequestLimitLabel": "Limite globale de demandes de films", - "components.Settings.SettingsUsers.newPlexLogin": "Autoriser nouvelle connexion Plex", - "components.Settings.SettingsUsers.newPlexLoginTip": "Autoriser les utilisateurs de Plex à se connecter sans être d'abord importés", + "components.Settings.SettingsUsers.newPlexLogin": "Autoriser les nouvelles connexion {mediaServerName}", + "components.Settings.SettingsUsers.newPlexLoginTip": "Autoriser les utilisateurs de {mediaServerName} à se connecter sans être d'abord importés.", "components.Settings.SettingsUsers.toastSettingsFailure": "Un problème est survenu pendant la sauvegarde des paramètres.", - "components.Settings.SettingsUsers.toastSettingsSuccess": "Les paramètres utilisateur ont été enregistrés avec succès !", - "components.Settings.SettingsUsers.tvRequestLimitLabel": "Limite globale de demandes de séries", - "components.Settings.SettingsUsers.userSettings": "Paramètres utilisateur", + "components.Settings.SettingsUsers.toastSettingsSuccess": "Les paramètres d'utilisateur ont été enregistrés avec succès !", + "components.Settings.SettingsUsers.tvRequestLimitLabel": "Limite globale de demandes de séries.", + "components.Settings.SettingsUsers.userSettings": "Paramètres d'utilisateur", "components.Settings.SettingsUsers.userSettingsDescription": "Configurer les paramètres généraux et par défaut de l'utilisateur.", "components.Settings.SettingsUsers.users": "Utilisateurs", "components.Settings.SonarrModal.add": "Ajouter un serveur", @@ -740,62 +899,76 @@ "components.Settings.SonarrModal.servername": "Nom du serveur", "components.Settings.SonarrModal.ssl": "Utiliser SSL", "components.Settings.SonarrModal.syncEnabled": "Activer les scans", + "components.Settings.SonarrModal.tagRequests": "Demandes de Tags", + "components.Settings.SonarrModal.tagRequestsInfo": "Ajouter automatiquement un tag supplémentaire avec l’identifiant de l'utilisateur et le nom d’affichage du demandeur", "components.Settings.SonarrModal.tags": "Tags", - "components.Settings.SonarrModal.testFirstLanguageProfiles": "Tester la connexion pour charger les profils de langue", - "components.Settings.SonarrModal.testFirstQualityProfiles": "Testez la connexion pour charger les profils qualité", - "components.Settings.SonarrModal.testFirstRootFolders": "Testez la connexion pour charger les dossiers racine", - "components.Settings.SonarrModal.testFirstTags": "Tester la connexion pour charger les tags", + "components.Settings.SonarrModal.testFirstLanguageProfiles": "Tester la connexion pour charger les profils de langue.", + "components.Settings.SonarrModal.testFirstQualityProfiles": "Testez la connexion pour charger les profils qualité.", + "components.Settings.SonarrModal.testFirstRootFolders": "Testez la connexion pour charger les dossiers racine.", + "components.Settings.SonarrModal.testFirstTags": "Tester la connexion pour charger les tags.", "components.Settings.SonarrModal.toastSonarrTestFailure": "Échec de la connexion à Sonarr.", "components.Settings.SonarrModal.toastSonarrTestSuccess": "Connexion à Sonarr établie avec succès !", - "components.Settings.SonarrModal.validationApiKeyRequired": "Vous devez fournir une clé d'API", - "components.Settings.SonarrModal.validationApplicationUrl": "Vous devez fournir une URL valide", - "components.Settings.SonarrModal.validationApplicationUrlTrailingSlash": "L'URL ne doit pas se terminer par une barre oblique finale", - "components.Settings.SonarrModal.validationBaseUrlLeadingSlash": "L'URL de base doit être précédée d'une barre oblique", - "components.Settings.SonarrModal.validationBaseUrlTrailingSlash": "L'URL de base ne doit pas se terminer par une barre oblique finale", - "components.Settings.SonarrModal.validationHostnameRequired": "Vous devez fournir un nom d'hôte ou une adresse IP valide", - "components.Settings.SonarrModal.validationLanguageProfileRequired": "Vous devez sélectionner un profil de langue", - "components.Settings.SonarrModal.validationNameRequired": "Vous devez fournir un nom de serveur", - "components.Settings.SonarrModal.validationPortRequired": "Vous devez fournir un numéro de port valide", - "components.Settings.SonarrModal.validationProfileRequired": "Vous devez sélectionner un profil qualité", - "components.Settings.SonarrModal.validationRootFolderRequired": "Vous devez sélectionner un dossier racine", + "components.Settings.SonarrModal.validationApiKeyRequired": "Vous devez fournir une clé d'API.", + "components.Settings.SonarrModal.validationApplicationUrl": "Vous devez fournir une URL valide.", + "components.Settings.SonarrModal.validationApplicationUrlTrailingSlash": "L'URL ne doit pas se terminer par une barre oblique finale.", + "components.Settings.SonarrModal.validationBaseUrlLeadingSlash": "L'URL de base doit être précédée d'une barre oblique.", + "components.Settings.SonarrModal.validationBaseUrlTrailingSlash": "L'URL de base ne doit pas se terminer par une barre oblique finale.", + "components.Settings.SonarrModal.validationHostnameRequired": "Vous devez fournir un nom d'hôte ou une adresse IP valide.", + "components.Settings.SonarrModal.validationLanguageProfileRequired": "Vous devez sélectionner un profil de langue.", + "components.Settings.SonarrModal.validationNameRequired": "Vous devez fournir un nom de serveur.", + "components.Settings.SonarrModal.validationPortRequired": "Vous devez fournir un numéro de port valide.", + "components.Settings.SonarrModal.validationProfileRequired": "Vous devez sélectionner un profil qualité.", + "components.Settings.SonarrModal.validationRootFolderRequired": "Vous devez sélectionner un dossier racine.", "components.Settings.activeProfile": "Profil actif", "components.Settings.addradarr": "Ajouter un serveur Radarr", "components.Settings.address": "Adresse", "components.Settings.addsonarr": "Ajouter un serveur Sonarr", - "components.Settings.advancedTooltip": "Une configuration incorrecte de ce paramètre peut entraîner un dysfonctionnement", + "components.Settings.advancedTooltip": "Une configuration incorrecte de ce paramètre peut entraîner un dysfonctionnement.", "components.Settings.apikey": "Clé d'API", "components.Settings.applicationTitle": "Titre de l'application", "components.Settings.applicationurl": "URL de l'application", "components.Settings.cacheImages": "Activer la mise en cache d'image", - "components.Settings.cacheImagesTip": "Met en cache localement et utilise des images optimisées (nécessite une quantité considérable d'espace disque)", + "components.Settings.cacheImagesTip": "Met en cache localement et utilise des images optimisées (nécessite une quantité considérable d'espace disque).", "components.Settings.cancelscan": "Annuler le scan", "components.Settings.copied": "Clé d'API copiée dans le presse-papiers.", "components.Settings.csrfProtection": "Activer la protection CSRF", "components.Settings.csrfProtectionHoverTip": "N'activez PAS ce paramètre à moins que vous ne compreniez ce que vous faites !", - "components.Settings.csrfProtectionTip": "Définir l'accès à l'API externe en lecture seule (nécessite HTTPS)", + "components.Settings.csrfProtectionTip": "Définir l'accès à l'API externe en lecture seule (nécessite HTTPS).", "components.Settings.currentlibrary": "Bibliothèque actuelle : {name}", "components.Settings.default": "Par défaut", "components.Settings.default4k": "4K par défaut", - "components.Settings.deleteServer": "Supprimer {serverType} serveur", - "components.Settings.deleteserverconfirm": "Êtes-vous sûr(e) de vouloir supprimer ce serveur ?", + "components.Settings.deleteServer": "Supprimer le serveur {serverType}", + "components.Settings.deleteserverconfirm": "Êtes-vous sûr de vouloir supprimer ce serveur ?", "components.Settings.email": "E-mail", "components.Settings.enablessl": "Utiliser SSL", - "components.Settings.experimentalTooltip": "L'activation de ce paramètre peut entraîner un comportement inattendu de l'application", + "components.Settings.experimentalTooltip": "L'activation de ce paramètre peut entraîner un comportement inattendu de l'application.", "components.Settings.externalUrl": "URL externe", + "components.Settings.hostname": "Nom d'hôte ou adresse IP", + "components.Settings.internalUrl": "URL interne", "components.Settings.general": "Général", "components.Settings.generalsettings": "Paramètres généraux", "components.Settings.generalsettingsDescription": "Configurer les paramètres généraux et par défaut pour Jellyseerr.", "components.Settings.hideAvailable": "Masquer les médias disponibles", - "components.Settings.hostname": "Nom d'hôte ou adresse IP", "components.Settings.is4k": "4K", + "components.Settings.jellyfinSettings": "Paramètres {mediaServerName}", + "components.Settings.jellyfinSettingsDescription": "Configurez éventuellement les points de terminaison internes et externes pour votre serveur {mediaServerName}. Dans la plupart des cas, l’URL externe est différente de l’URL interne.", + "components.Settings.jellyfinSettingsFailure": "Quelque chose, s’est mal passé lors de la sauvegarde des paramètres {mediaServerName}.", + "components.Settings.jellyfinSettingsSuccess": "{mediaServerName} paramètres enregistrés avec succès !", + "components.Settings.jellyfinlibraries": "Bibliothèques {mediaServerName}", + "components.Settings.jellyfinlibrariesDescription": "Les bibliothèques {mediaServerName} recherchent des titres. Cliquez sur le bouton ci-dessous si aucune bibliothèque n’est répertoriée.", + "components.Settings.jellyfinsettings": "Paramètres {mediaServerName}", + "components.Settings.jellyfinsettingsDescription": "Configurez les paramètres pour votre serveur {mediaServerName}. {mediaServerName} analyse vos bibliothèques {mediaServerName} pour voir quel contenu est disponible.", "components.Settings.librariesRemaining": "Bibliothèques restantes : {count}", - "components.Settings.locale": "Langue d'affichage", "components.Settings.manualscan": "Scan manuel des bibliothèques", "components.Settings.manualscanDescription": "Normalement, le scan sera effectué une fois toutes les 24 heures seulement. Jellyseerr vérifiera les ajouts récents de votre serveur Plex de façon proactive. Si c'est la première fois que vous configurez Plex, un scan complet de la bibliothèque est recommandé !", + "components.Settings.manualscanDescriptionJellyfin": "Normalement, cela ne sera exécuté qu’une fois toutes les 24 heures. Jellyseerr vérifiera votre serveur {mediaServerName} récemment ajouté plus agressivement. Si c’est la première fois que vous configurez Jellyseerr, une analyse manuelle unique de la bibliothèque est recommandée !", + "components.Settings.manualscanJellyfin": "Scan manuel de la bibliothèque", + "components.Settings.locale": "Langue d'affichage", "components.Settings.mediaTypeMovie": "film", "components.Settings.mediaTypeSeries": "série", "components.Settings.menuAbout": "À propos", "components.Settings.menuGeneralSettings": "Général", + "components.Settings.menuJellyfinSettings": "{mediaServerName}", "components.Settings.menuJobs": "Tâches et cache", "components.Settings.menuLogs": "Journaux", "components.Settings.menuNotifications": "Notifications", @@ -819,16 +992,18 @@ "components.Settings.plexsettingsDescription": "Configurer les paramètres de votre serveur Plex. Jellyseerr scanne vos librairies Plex pour déterminer les contenus disponibles.", "components.Settings.port": "Port", "components.Settings.radarrsettings": "Paramètres Radarr", + "components.Settings.restartrequiredTooltip": "Jellyseerr doit être redémarré pour que les modifications de ce paramètre prennent effet.", + "components.Settings.save": "Sauvegarder les modifications", + "components.Settings.saving": "Sauvegarde", "components.Settings.region": "Région à découvrir", "components.Settings.regionTip": "Filtrer le contenu par disponibilité régionale", - "components.Settings.restartrequiredTooltip": "Jellyseerr doit être redémarré pour que les modifications de ce paramètre prennent effet", "components.Settings.scan": "Synchroniser les bibliothèques", "components.Settings.scanning": "Synchronisation en cours…", "components.Settings.serverLocal": "local", "components.Settings.serverRemote": "distant", "components.Settings.serverSecure": "sécurisée", "components.Settings.serverpreset": "Serveur", - "components.Settings.serverpresetLoad": "Appuyez sur le bouton pour charger les serveurs disponibles", + "components.Settings.serverpresetLoad": "Appuyez sur le bouton pour charger les serveurs disponibles.", "components.Settings.serverpresetManualMessage": "Configuration manuelle", "components.Settings.serverpresetRefreshing": "Récupération des serveurs…", "components.Settings.serviceSettingsDescription": "Configurez votre serveur {serverType} ci-dessous. Vous pouvez connecter plusieurs serveurs {serverType}, mais seulement deux d’entre eux peuvent être marqués par défaut (un non-4K et un 4K). Les administrateurs peuvent modifier le serveur utilisé pour traiter les nouvelles demandes avant la validation.", @@ -837,12 +1012,15 @@ "components.Settings.sonarrsettings": "Paramètres Sonarr", "components.Settings.ssl": "SSL", "components.Settings.startscan": "Commencer le scan", + "components.Settings.syncJellyfin": "Synchronisation des bibliothèques", + "components.Settings.syncing": "Synchronisation", "components.Settings.tautulliApiKey": "Clé API", "components.Settings.tautulliSettings": "Paramètres Tautulli", - "components.Settings.tautulliSettingsDescription": "Configuration optionnelle pour votre serveur Tautulli. Jellyseerr va récupérer l'historique de visionnage de votre Plex depuis Tautulli.", + "components.Settings.tautulliSettingsDescription": "Configuration optionnelle pour votre serveur Tautulli. Jellyseerr va récupérer l'historique de visionnage(s) de votre Plex depuis Tautulli.", + "components.Settings.timeout": "Délai d'expiration", + "components.Settings.toastPlexConnecting": "Tentative de connexion à Plex…", "components.Settings.toastApiKeyFailure": "Une erreur s'est produite lors de la génération de la nouvelle clé API.", "components.Settings.toastApiKeySuccess": "Nouvelle clé API générée avec succès !", - "components.Settings.toastPlexConnecting": "Tentative de connexion à Plex…", "components.Settings.toastPlexConnectingFailure": "Échec de connexion à Plex.", "components.Settings.toastPlexConnectingSuccess": "Connexion Plex établie avec succès !", "components.Settings.toastPlexRefresh": "Récupération de la liste des serveurs depuis Plex…", @@ -850,25 +1028,26 @@ "components.Settings.toastPlexRefreshSuccess": "Liste des serveurs Plex récupérée avec succès !", "components.Settings.toastSettingsFailure": "Une erreur s'est produite durant l'enregistrement des paramètres.", "components.Settings.toastSettingsSuccess": "Les paramètres ont été enregistrés avec succès !", - "components.Settings.toastTautulliSettingsFailure": "Quelque chose c'est mal passé quand les paramètres Tautulli on été enregistrés.", + "components.Settings.toastTautulliSettingsFailure": "Quelque chose, c'est mal passé quand les paramètres Tautulli, on été enregistrés.", "components.Settings.toastTautulliSettingsSuccess": "Les paramètres pour Tautulli on bien été sauvegardés !", "components.Settings.trustProxy": "Activer la prise en charge proxy", - "components.Settings.trustProxyTip": "Permettre Jellyseerr à enregistrer correctement les adresses IP des clients derrière un proxy", + "components.Settings.trustProxyTip": "Permettre Jellyseerr à enregistrer correctement les adresses IP des clients derrière un proxy.", "components.Settings.urlBase": "URL de base", - "components.Settings.validationApiKey": "Vous devez fournir une clef API", - "components.Settings.validationApplicationTitle": "Vous devez fournir un titre d'application", - "components.Settings.validationApplicationUrl": "Vous devez fournir une URL valide", - "components.Settings.validationApplicationUrlTrailingSlash": "L'URL ne doit pas se terminer par une barre oblique finale", - "components.Settings.validationHostnameRequired": "Vous devez fournir un nom d'hôte valide ou une adresse IP", - "components.Settings.validationPortRequired": "Vous devez fournir un numéro de port valide", - "components.Settings.validationUrl": "Vous devez fournir une URL valide", - "components.Settings.validationUrlBaseLeadingSlash": "L'URL de base doit avoir un slash", - "components.Settings.validationUrlBaseTrailingSlash": "L'URL de base ne doit pas ce terminer par un slash", - "components.Settings.validationUrlTrailingSlash": "L'URL ne doit pas ce terminer par un slash", + "components.Settings.validationApiKey": "Vous devez fournir une clef API.", + "components.Settings.validationApplicationTitle": "Vous devez fournir un titre d'application.", + "components.Settings.validationApplicationUrl": "Vous devez fournir une URL valide.", + "components.Settings.validationApplicationUrlTrailingSlash": "L'URL ne doit pas se terminer par une barre oblique finale.", + "components.Settings.validationHostnameRequired": "Vous devez fournir un nom d'hôte valide ou une adresse IP.", + "components.Settings.validationPortRequired": "Vous devez fournir un numéro de port valide.", + "components.Settings.validationUrl": "Vous devez fournir une URL valide.", + "components.Settings.validationUrlBaseLeadingSlash": "L'URL de base doit avoir un slash.", + "components.Settings.validationUrlBaseTrailingSlash": "L'URL de base ne doit pas ce terminer par un slash.", + "components.Settings.validationUrlTrailingSlash": "L'URL ne doit pas ce terminer par un slash.", "components.Settings.webAppUrl": "URL Application Web", - "components.Settings.webAppUrlTip": "Dirigez éventuellement les utilisateurs vers l'application Web sur votre serveur au lieu de l'application Web « hébergée »", + "components.Settings.webAppUrlTip": "Dirigez éventuellement les utilisateurs vers l'application Web sur votre serveur au lieu de l'application Web « hébergée ».", "components.Settings.webhook": "Webhook", "components.Settings.webpush": "Web Push", + "components.Setup.configuremediaserver": "Configurer le serveur multimédia", "components.Setup.configureplex": "Configurer Plex", "components.Setup.configureservices": "Configurer les Services", "components.Setup.continue": "Continuer", @@ -877,7 +1056,10 @@ "components.Setup.loginwithplex": "Se connecter avec Plex", "components.Setup.scanbackground": "Le scan s'effectue en arrière-plan. Vous pouvez donc continuer le processus de configuration pendant ce temps.", "components.Setup.setup": "Configuration", - "components.Setup.signinMessage": "Commencez en vous connectant avec votre compte Plex", + "components.Setup.signin": "S'inscrire", + "components.Setup.signinMessage": "Commencez en vous connectant avec votre compte Plex.", + "components.Setup.signinWithJellyfin": "Utilisez votre compte {mediaServerName}", + "components.Setup.signinWithPlex": "Utilisez votre compte Plex", "components.Setup.tip": "Astuce", "components.Setup.welcome": "Bienvenue sur Jellyseerr", "components.StatusBadge.managemedia": "Gérer {mediaType}", @@ -893,15 +1075,15 @@ "components.StatusChecker.restartRequiredDescription": "Veuillez redémarrer le serveur pour appliquer les paramètres mis à jour.", "components.TitleCard.cleardata": "Effacer les données", "components.TitleCard.mediaerror": "{mediaType} non trouvé", - "components.TitleCard.tmdbid": "TMDB ID", - "components.TitleCard.tvdbid": "TheTVDB ID", - "components.TvDetails.Season.noepisodes": "Liste des épisodes non disponible..", + "components.TitleCard.tmdbid": "Identifiant TMDB", + "components.TitleCard.tvdbid": "Identifiant TheTVDB", + "components.TvDetails.Season.noepisodes": "Liste des épisodes non disponible.", "components.TvDetails.Season.somethingwentwrong": "Une erreur s'est produite lors de la récupération des données de la saison.", "components.TvDetails.TvCast.fullseriescast": "Casting complet de la série", "components.TvDetails.TvCrew.fullseriescrew": "Équipe complète de la série", "components.TvDetails.anime": "Animé", "components.TvDetails.cast": "Casting", - "components.TvDetails.episodeCount": "{episodeCount, plural, one {# épisode} sur {# épisodes}}", + "components.TvDetails.episodeCount": "{episodeCount, plural, one {# épisode} other {# épisodes}}", "components.TvDetails.episodeRuntime": "Durée d'un épisode", "components.TvDetails.episodeRuntimeMinutes": "{runtime} minutes", "components.TvDetails.firstAirDate": "Date de première diffusion", @@ -912,8 +1094,8 @@ "components.TvDetails.originaltitle": "Titre original", "components.TvDetails.overview": "Résumé", "components.TvDetails.overviewunavailable": "Résumé indisponible.", - "components.TvDetails.play4konplex": "Lire en 4K sur Plex", - "components.TvDetails.playonplex": "Lire sur Plex", + "components.TvDetails.play": "Lire sur {mediaServerName}", + "components.TvDetails.play4K": "Lire en 4k sur {mediaServerName}", "components.TvDetails.productioncountries": "Pays de production", "components.TvDetails.recommendations": "Recommandations", "components.TvDetails.reportissue": "Signaler un problème", @@ -924,15 +1106,15 @@ "components.TvDetails.seasonstitle": "Saisons", "components.TvDetails.showtype": "Type de séries", "components.TvDetails.similar": "Séries similaires", - "components.TvDetails.status4k": "4K {status}", + "components.TvDetails.status4k": "{status} en 4K", "components.TvDetails.streamingproviders": "Disponible en streaming sur", "components.TvDetails.tmdbuserscore": "Note des utilisateurs TMDB", "components.TvDetails.viewfullcrew": "Voir l'équipe complète", "components.TvDetails.watchtrailer": "Regarder la bande-annonce", "components.UserList.accounttype": "Type de compte", - "components.UserList.admin": "Admin", + "components.UserList.admin": "Administrateur", "components.UserList.autogeneratepassword": "Générer automatiquement le mot de passe", - "components.UserList.autogeneratepasswordTip": "Envoyer par email un mot de passe généré par le serveur à l’utilisateur", + "components.UserList.autogeneratepasswordTip": "Envoyer par e-mail un mot de passe généré par le serveur à l’utilisateur", "components.UserList.bulkedit": "Modification en masse", "components.UserList.create": "Créer", "components.UserList.created": "A rejoint", @@ -943,21 +1125,27 @@ "components.UserList.displayName": "Nom affiché", "components.UserList.edituser": "Modifier les permissions de l'utilisateur", "components.UserList.email": "Adresse e-mail", + "components.UserList.importedfromJellyfin": "{userCount} {mediaServerName} {userCount, plural, one {utilisateur} other {utilisateurs}} importé(s) avec succès !", "components.UserList.importedfromplex": "{userCount} {userCount, plural, one {utilisateur} other {utilisateurs}} importé(s) depuis Plex avec succès !", + "components.UserList.importfromJellyfin": "Importer les utilisateurs {mediaServerName}", + "components.UserList.importfromJellyfinerror": "Quelque chose, s’est mal passé lors de l’importation d’utilisateurs {mediaServerName}.", "components.UserList.importfrommediaserver": "Importer les utilisateurs de {mediaServerName}", "components.UserList.importfromplex": "Importer les utilisateurs de Plex", "components.UserList.importfromplexerror": "Une erreur s'est produite durant l'importation des utilisateurs de Plex.", "components.UserList.localLoginDisabled": "Le paramètre Activer la connexion locale est actuellement désactivé.", "components.UserList.localuser": "Utilisateur local", - "components.UserList.newplexsigninenabled": "L'option Autoriser nouvelle connexion Plex est actuellement activée. Les utilisateurs Plex disposant d'un accès à la librairie n'ont pas besoin d'être importés pour pouvoir ce connecter.", - "components.UserList.nouserstoimport": "Aucun nouvel utilisateur de Plex à importer.", + "components.UserList.mediaServerUser": "Utilisateur {mediaServerName} ", + "components.UserList.newJellyfinsigninenabled": "L'option Autoriser les nouvelles connexion {mediaServerName} est actuellement activée. Les utilisateurs {mediaServerName} ayant accès à la bibliothèque n’ont pas besoin d’être importés pour se connecter.", + "components.UserList.newplexsigninenabled": "L'option Autoriser les nouvelles connexion Plex est actuellement activée. Les utilisateurs Plex disposant d'un accès à la librairie n'ont pas besoin d'être importés pour pouvoir se connecter.", + "components.UserList.noJellyfinuserstoimport": "Il n’y a pas d’utilisateurs {mediaServerName} à importer.", + "components.UserList.nouserstoimport": "Aucun nouvel utilisateur de Plex à importé.", "components.UserList.owner": "Propriétaire", "components.UserList.password": "Mot de passe", "components.UserList.passwordinfodescription": "Configurez l'URL de l'application ainsi que les notifications par e-mail pour permettre la génération automatique de mots de passe.", "components.UserList.plexuser": "Utilisateur Plex", "components.UserList.role": "Rôle", "components.UserList.sortCreated": "Date d'inscription", - "components.UserList.sortDisplayName": "Nom d'Utilisateur affiché", + "components.UserList.sortDisplayName": "Nom d'utilisateur affiché", "components.UserList.sortRequests": "Nombre de demandes", "components.UserList.totalrequests": "Demandes", "components.UserList.user": "Utilisateur", @@ -970,51 +1158,55 @@ "components.UserList.userlist": "Liste des utilisateurs", "components.UserList.users": "Utilisateurs", "components.UserList.userssaved": "Les permissions d'utilisateur ont été enregistrées avec succès !", - "components.UserList.validationEmail": "Vous devez fournir un e-mail valide", - "components.UserList.validationpasswordminchars": "Le mot de passe est trop court ; il doit contenir au moins 8 caractères", - "components.UserProfile.ProfileHeader.joindate": "Membre depuis le {joindate}", - "components.UserProfile.ProfileHeader.profile": "Afficher le profil", + "components.UserList.validationEmail": "Vous devez fournir un e-mail valide.", + "components.UserList.validationpasswordminchars": "Le mot de passe est trop court ; il doit contenir au moins 8 caractères.", + "components.UserProfile.ProfileHeader.joindate": "Rejoin le {joindate}", + "components.UserProfile.ProfileHeader.profile": "Voir le profile", "components.UserProfile.ProfileHeader.settings": "Modifier les paramètres", - "components.UserProfile.ProfileHeader.userid": "ID utilisateur : {userid}", + "components.UserProfile.ProfileHeader.userid": "L'identifiant de l'utilisateur: {userid}", "components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Type de compte", - "components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin", + "components.UserProfile.UserSettings.UserGeneralSettings.admin": "Administateur", "components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Langue d'affichage", - "components.UserProfile.UserSettings.UserGeneralSettings.discordId": "ID utilisateur Discord", - "components.UserProfile.UserSettings.UserGeneralSettings.discordIdTip": "Le numéro d'identification à plusieurs chiffres est associé avec votre compte Discord", + "components.UserProfile.UserSettings.UserGeneralSettings.discordId": "Identifiant de l'utilisateur Discord", + "components.UserProfile.UserSettings.UserGeneralSettings.discordIdTip": "Le numéro d'identification à plusieurs chiffres est associé avec votre compte Discord.", "components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Nom affiché", + "components.UserProfile.UserSettings.UserGeneralSettings.email": "E-mail", "components.UserProfile.UserSettings.UserGeneralSettings.enableOverride": "Contourner la limite globale", "components.UserProfile.UserSettings.UserGeneralSettings.general": "Général", "components.UserProfile.UserSettings.UserGeneralSettings.generalsettings": "Paramètres généraux", "components.UserProfile.UserSettings.UserGeneralSettings.languageDefault": "Langage par défaut ({language})", "components.UserProfile.UserSettings.UserGeneralSettings.localuser": "Utilisateur local", + "components.UserProfile.UserSettings.UserGeneralSettings.mediaServerUser": "Utilisateur {mediaServerName}", "components.UserProfile.UserSettings.UserGeneralSettings.movierequestlimit": "Limite de demandes de films", "components.UserProfile.UserSettings.UserGeneralSettings.originallanguage": "Langue à découvrir", "components.UserProfile.UserSettings.UserGeneralSettings.originallanguageTip": "Filtrer le contenu par langue d’origine", "components.UserProfile.UserSettings.UserGeneralSettings.owner": "Propriétaire", "components.UserProfile.UserSettings.UserGeneralSettings.plexuser": "Utilisateur Plex", "components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmovies": "Demander automatiquement les films", - "components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmoviestip": "Demande automatiquement les films sur ta Plex Watchlist", - "components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseries": "Demander automatiquement les Séries", - "components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseriestip": "Demande automatiquement les séries de ta Plex Watchlist", + "components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmoviestip": "Demande automatiquement les films sur ta liste de visionnage(s) Plex.", + "components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseries": "Demander automatiquement les séries", + "components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseriestip": "Demande automatiquement les séries de ta liste de visionnage(s) Plex.", "components.UserProfile.UserSettings.UserGeneralSettings.region": "Région à découvrir", "components.UserProfile.UserSettings.UserGeneralSettings.regionTip": "Filtrer le contenu par disponibilité régionale", "components.UserProfile.UserSettings.UserGeneralSettings.role": "Rôle", + "components.UserProfile.UserSettings.UserGeneralSettings.save": "Sauvegarder les modifications", + "components.UserProfile.UserSettings.UserGeneralSettings.saving": "Sauvergade…", "components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Limite de demandes de séries", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Un problème est survenu pendant l'enregistrement des paramètres.", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Les paramètres ont été enregistrés avec succès !", "components.UserProfile.UserSettings.UserGeneralSettings.user": "Utilisateur", - "components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "Vous devez fournir un ID utilisateur Discord valide", - "components.UserProfile.UserSettings.UserNotificationSettings.discordId": "Identifiant", - "components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "L' IDassocié à votre compte utilisateur", + "components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "Vous devez fournir un identifiant d'utilisateur Discord valide.", + "components.UserProfile.UserSettings.UserNotificationSettings.discordId": "L'identifiant utilisateur", + "components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "Le numéro d'identification associé à votre compte utilisateur.", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "Les paramètres de notification Discord n’ont pas pu être enregistrés.", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "Paramètres de notification Discord enregistrés avec succès !", - "components.UserProfile.UserSettings.UserNotificationSettings.email": "Email", + "components.UserProfile.UserSettings.UserNotificationSettings.email": "E-mail", "components.UserProfile.UserSettings.UserNotificationSettings.emailsettingsfailed": "Impossible d’enregistrer les paramètres de notification par E-mail.", "components.UserProfile.UserSettings.UserNotificationSettings.emailsettingssaved": "Paramètres de notification par E-mail enregistrés avec succès !", "components.UserProfile.UserSettings.UserNotificationSettings.notifications": "Notifications", "components.UserProfile.UserSettings.UserNotificationSettings.notificationsettings": "Paramètres de notification", "components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKey": "Clé Publique PGP", - "components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKeyTip": "Chiffrer les emails en utilisant OpenPGP", + "components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKeyTip": "Chiffrer les e-mails en utilisant OpenPGP", "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletAccessToken": "Jeton d'accès", "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletAccessTokenTip": "Créer un jeton depuis les paramètres de votre compte", "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletsettingsfailed": "Les paramètres de notification Pushbullet n'ont pas été sauvegardés correctement.", @@ -1025,36 +1217,36 @@ "components.UserProfile.UserSettings.UserNotificationSettings.pushoverUserKeyTip": "Votre identifiant d'utilisateur ou de groupe à 30 caractères", "components.UserProfile.UserSettings.UserNotificationSettings.pushoversettingsfailed": "Les paramètres de notification Pushover n'ont pas pu être enregistrés.", "components.UserProfile.UserSettings.UserNotificationSettings.pushoversettingssaved": "Paramètres de notification Pushover enregistrés avec succès !", - "components.UserProfile.UserSettings.UserNotificationSettings.sendSilently": "Envoie les messages silencieusement", - "components.UserProfile.UserSettings.UserNotificationSettings.sendSilentlyDescription": "Envoyer des notifications sans son", - "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatId": "ID de discussion", - "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatIdTipLong": "Démarre une discussion, ajoute @get_id_bot, et utilise la commande /my_id", + "components.UserProfile.UserSettings.UserNotificationSettings.sendSilently": "Envoie les messages silencieusement.", + "components.UserProfile.UserSettings.UserNotificationSettings.sendSilentlyDescription": "Envoyer des notifications sans son.", + "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatId": "Identifiant de discussion", + "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatIdTipLong": "Démarre une discussion, ajoute @get_id_bot, et utilise la commande /my_id.", "components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingsfailed": "Impossible d’enregistrer les paramètres de notification de Telegram.", "components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingssaved": "Paramètres de notification Telegram enregistrés avec succès !", - "components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "Vous devez fournir un identifiant valide", - "components.UserProfile.UserSettings.UserNotificationSettings.validationPgpPublicKey": "Vous devez fournir une clé publique PGP valide", - "components.UserProfile.UserSettings.UserNotificationSettings.validationPushbulletAccessToken": "Vous devez fournir un jeton d'accès", - "components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverApplicationToken": "Vous devez fournir un jeton d'application valide", - "components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverUserKey": "Vous devez fournir une clé d'utilisateur ou de groupe valide", - "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "Vous devez fournir un identifiant de chat valide", + "components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "Vous devez fournir un identifiant valide.", + "components.UserProfile.UserSettings.UserNotificationSettings.validationPgpPublicKey": "Vous devez fournir une clé publique PGP valide.", + "components.UserProfile.UserSettings.UserNotificationSettings.validationPushbulletAccessToken": "Vous devez fournir un jeton d'accès.", + "components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverApplicationToken": "Vous devez fournir un jeton d'application valide.", + "components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverUserKey": "Vous devez fournir une clé d'utilisateur ou de groupe valide.", + "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "Vous devez fournir un identifiant de chat valide.", "components.UserProfile.UserSettings.UserNotificationSettings.webpush": "Web Push", "components.UserProfile.UserSettings.UserNotificationSettings.webpushsettingsfailed": "Échec de l'enregistrement des paramètres de notification Web push.", "components.UserProfile.UserSettings.UserNotificationSettings.webpushsettingssaved": "Paramètres de notification Web Push enregistrés avec succès !", "components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirmez le mot de passe", "components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Mot de passe actuel", "components.UserProfile.UserSettings.UserPasswordChange.newpassword": "Nouveau mot de passe", - "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSet": "Ce compte utilisateur n’a actuellement pas de mot de passe. Configurez un mot de passe ci-dessous pour permettre à ce compte de se connecter en tant \"qu’utilisateur local.\"", - "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSetOwnAccount": "Votre compte n’a actuellement pas de mot de passe. Configurez un mot de passe ci-dessous pour activer la connexion en tant qu’ \"utilisateur local\" en utilisant votre adresse e-mail.", + "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSet": "Ce compte utilisateur n’a actuellement pas de mot de passe. Configurez un mot de passe ci-dessous pour permettre à ce compte de se connecter en tant qu'\"utilisateur local.\"", + "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSetOwnAccount": "Votre compte n’a actuellement pas de mot de passe. Configurez un mot de passe ci-dessous pour activer la connexion en tant qu’\"utilisateur local\" en utilisant votre adresse e-mail.", "components.UserProfile.UserSettings.UserPasswordChange.nopermissionDescription": "Vous n'avez l'autorisation de modifier le mot de passe de cet utilisateur.", "components.UserProfile.UserSettings.UserPasswordChange.password": "Mot de passe", "components.UserProfile.UserSettings.UserPasswordChange.toastSettingsFailure": "Un problème est survenu lors de l'enregistrement du mot de passe.", "components.UserProfile.UserSettings.UserPasswordChange.toastSettingsFailureVerifyCurrent": "Un problème est survenu lors de l'enregistrement du mot de passe. Votre mot de passe actuel a-t-il été saisi correctement ?", "components.UserProfile.UserSettings.UserPasswordChange.toastSettingsSuccess": "Mot de passe enregistré avec succès !", - "components.UserProfile.UserSettings.UserPasswordChange.validationConfirmPassword": "Vous devez confirmer le nouveau mot de passe", - "components.UserProfile.UserSettings.UserPasswordChange.validationConfirmPasswordSame": "Les mots de passe doivent correspondre", - "components.UserProfile.UserSettings.UserPasswordChange.validationCurrentPassword": "Vous devez fournir votre mot de passe actuel", - "components.UserProfile.UserSettings.UserPasswordChange.validationNewPassword": "Vous devez fournir un nouveau mot de passe", - "components.UserProfile.UserSettings.UserPasswordChange.validationNewPasswordLength": "Le mot de passe est trop court, il doit contenir un minimum de 8 caractères", + "components.UserProfile.UserSettings.UserPasswordChange.validationConfirmPassword": "Vous devez confirmer le nouveau mot de passe.", + "components.UserProfile.UserSettings.UserPasswordChange.validationConfirmPasswordSame": "Les mots de passe doivent correspondre.", + "components.UserProfile.UserSettings.UserPasswordChange.validationCurrentPassword": "Vous devez fournir votre mot de passe actuel.", + "components.UserProfile.UserSettings.UserPasswordChange.validationNewPassword": "Vous devez fournir un nouveau mot de passe.", + "components.UserProfile.UserSettings.UserPasswordChange.validationNewPasswordLength": "Le mot de passe est trop court, il doit contenir un minimum de 8 caractères.", "components.UserProfile.UserSettings.UserPermissions.permissions": "Permissions", "components.UserProfile.UserSettings.UserPermissions.toastSettingsFailure": "Une erreur s'est produite lors de l'enregistrement des paramètres.", "components.UserProfile.UserSettings.UserPermissions.toastSettingsSuccess": "Paramètres enregistrés avec succès !", @@ -1064,17 +1256,22 @@ "components.UserProfile.UserSettings.menuNotifications": "Notifications", "components.UserProfile.UserSettings.menuPermissions": "Permissions", "components.UserProfile.UserSettings.unauthorizedDescription": "Vous n'avez pas l'autorisation de modifier les paramètres de cet utilisateur.", - "components.UserProfile.emptywatchlist": "Les médias ajoutés à ta Plex Watchlist apparaîtront ici.", + "components.UserProfile.emptywatchlist": "Les médias ajoutés à ta liste de visionnage(s) Plex apparaîtront ici.", "components.UserProfile.limit": "{remaining} sur {limit}", "components.UserProfile.movierequests": "Demandes de films", "components.UserProfile.pastdays": "{type} (derniers {days} jours)", - "components.UserProfile.plexwatchlist": "Plex Watchlist", + "components.UserProfile.plexwatchlist": "Liste de visionnage(s) Plex", "components.UserProfile.recentlywatched": "Vu récemment", "components.UserProfile.recentrequests": "Demandes récentes", "components.UserProfile.requestsperdays": "{limit} restantes", "components.UserProfile.seriesrequest": "Demandes de séries", "components.UserProfile.totalrequests": "Total des demandes", "components.UserProfile.unlimited": "Illimité", + "components.TitleCard.addToWatchList": "Ajouter à la liste de visionnage(s)", + "components.TitleCard.watchlistCancel": "Liste de visionnage(s) pour {title} annulé.", + "components.TitleCard.watchlistDeleted": "{title} Supprimé de la liste de visionnage(s) avec succès !", + "components.TitleCard.watchlistError": "Quelque chose a mal tourné, réessayer.", + "components.TitleCard.watchlistSuccess": "{title} ajouté à la liste de visionnage(s) avec succès !", "i18n.advanced": "Avancés", "i18n.all": "Toutes", "i18n.approve": "Valider", @@ -1085,8 +1282,9 @@ "i18n.cancel": "Annuler", "i18n.canceling": "Annulation…", "i18n.close": "Fermer", - "i18n.decline": "Refuser", - "i18n.declined": "Refusé", + "i18n.collection": "Collection", + "i18n.decline": "Décline", + "i18n.declined": "Décliné", "i18n.delete": "Supprimer", "i18n.deleting": "Suppression…", "i18n.delimitedlist": "{a}, {b}", @@ -1118,7 +1316,7 @@ "i18n.save": "Sauvegarder les changements", "i18n.saving": "Sauvegarde en cours…", "i18n.settings": "Paramètres", - "i18n.showingresults": "Affichage de {from} à {to} pour {total} résultats", + "i18n.showingresults": "Affichage de {from} à {to} pour {total} résultats.", "i18n.status": "Statut", "i18n.test": "Tester", "i18n.testing": "Test en cours…",