From 77a33cb74d744bb747b791785799b632af8c7862 Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Thu, 4 May 2023 14:08:22 -0400 Subject: [PATCH 01/34] fix(ui): corrected mobile menu spacing in collection details (#3432) --- src/components/CollectionDetails/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 34b379e24..ff22ee107 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -348,7 +348,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { /> ))} /> -
+
); }; From 7522aa31743b169c903ebdf9d4d698645d27514c Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Wed, 10 May 2023 20:36:12 -0400 Subject: [PATCH 02/34] fix: availability sync file detection (#3371) * fix: added extra check for unmonitored movies in radarr * feat: created new radarr/sonarr routes to grab existing series data * refactor: updated job routes to check by external service id * fix: season check will now also look at episode file count --- server/api/servarr/sonarr.ts | 19 ++ server/lib/availabilitySync.ts | 530 +++++++++++++++++++-------------- 2 files changed, 320 insertions(+), 229 deletions(-) diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 2e423ef39..6cda2a49c 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -76,6 +76,15 @@ export interface SonarrSeries { ignoreEpisodesWithoutFiles?: boolean; searchForMissingEpisodes?: boolean; }; + statistics: { + seasonCount: number; + episodeFileCount: number; + episodeCount: number; + totalEpisodeCount: number; + sizeOnDisk: number; + releaseGroups: string[]; + percentOfEpisodes: number; + }; } export interface AddSeriesOptions { @@ -116,6 +125,16 @@ class SonarrAPI extends ServarrBase<{ } } + public async getSeriesById(id: number): Promise { + try { + const response = await this.axios.get(`/series/${id}`); + + return response.data; + } catch (e) { + throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`); + } + } + public async getSeriesByTitle(title: string): Promise { try { const response = await this.axios.get('/series/lookup', { diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 93ccfe391..a9f61fff6 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -1,7 +1,8 @@ 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 type { SonarrSeason } from '@server/api/servarr/sonarr'; +import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaStatus } from '@server/constants/media'; import { getRepository } from '@server/datasource'; @@ -47,158 +48,150 @@ class AvailabilitySync { try { for await (const media of this.loadAvailableMediaPaginated(pageSize)) { - try { - if (!this.running) { - throw new Error('Job aborted'); - } + if (!this.running) { + throw new Error('Job aborted'); + } - const mediaExists = await this.mediaExists(media); + const mediaExists = await this.mediaExists(media); + + // 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 } }, + }); - //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 } }, - }); - - logger.info( - `${ - media.mediaType === 'tv' ? media.tvdbId : media.tmdbId - } does not exist in any of your media instances. We will change its status to unknown.`, - { label: 'AvailabilitySync' } - ); + logger.info( + `Media ID ${media.id} does not exist in any of your media instances. Status will be changed to unknown.`, + { 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 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); - } + await requestRepository.remove(request); } + } - 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 } }, + 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 } }, + { + 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 }, { - status4k: MediaStatus.PARTIALLY_AVAILABLE, - media: { id: media.id }, - }, - ], - }); + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + } + ); + } else { + const seasonExists = await this.seasonExists(media, season); - 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, - } + 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' } ); - } else { - const seasonExists = await this.seasonExists(media, season); - if (!seasonExists) { - logger.info( - `Removing season ${season.seasonNumber}, media id: ${media.tvdbId} 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, + } ); + } - 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, - }, + const seasonToBeDeleted = await seasonRequestRepository.findOne( + { + relations: { + request: { + media: true, }, - where: { - request: { - media: { - id: media.id, - }, + }, + where: { + request: { + media: { + id: media.id, }, - seasonNumber: season.seasonNumber, }, - }); - - if (seasonToBeDeleted) { - await seasonRequestRepository.remove(seasonToBeDeleted); + seasonNumber: season.seasonNumber, + }, } + ); - didDeleteSeasons = true; + if (seasonToBeDeleted) { + await seasonRequestRepository.remove(seasonToBeDeleted); } + + didDeleteSeasons = true; } + } - if (didDeleteSeasons) { - if ( - media.status === MediaStatus.AVAILABLE || - media.status4k === MediaStatus.AVAILABLE - ) { - logger.info( - `Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted some of its seasons.`, - { 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.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 (media.status4k === MediaStatus.AVAILABLE) { + await mediaRepository.update(media.id, { + status4k: MediaStatus.PARTIALLY_AVAILABLE, + }); } } } } - } catch (ex) { - logger.error('Failure with media.', { - errorMessage: ex.message, - label: 'AvailabilitySync', - }); } } } catch (ex) { @@ -254,9 +247,9 @@ class AvailabilitySync { }); logger.info( - `${media.tmdbId} does not exist in your ${is4k ? '4k' : 'non-4k'} ${ - isTVType ? 'sonarr' : 'radarr' - } and plex instance. We will change its status to unknown.`, + `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' } ); @@ -306,46 +299,70 @@ class AvailabilitySync { apiKey: server.apiKey, url: RadarrAPI.buildUrl(server, '/api/v3'), }); - const meta = await api.getMovieByTmdbId(media.tmdbId); + try { + // Check if both exist or if a single non-4k or 4k exists + // If both do not exist we will return false - //check if both exist or if a single non-4k or 4k exists - //if both do not exist we will return false - if (!server.is4k && !meta.id) { - existsInRadarr = false; - } + let meta: RadarrMovie | undefined; - if (server.is4k && !meta.id) { - existsInRadarr4k = false; - } - } + if (!server.is4k && media.externalServiceId) { + meta = await api.getMovie({ id: media.externalServiceId }); + } - if (existsInRadarr && existsInRadarr4k) { - return true; - } + if (server.is4k && media.externalServiceId4k) { + meta = await api.getMovie({ id: media.externalServiceId4k }); + } - if (!existsInRadarr && existsInPlex) { - return true; - } + if (!server.is4k && (!meta || !meta.hasFile)) { + existsInRadarr = false; + } - if (!existsInRadarr4k && existsInPlex4k) { - return true; + if (server.is4k && (!meta || !meta.hasFile)) { + existsInRadarr4k = false; + } + } 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 only a single non-4k or 4k exists, then change entity columns accordingly - //related media request will then be deleted - if (!existsInRadarr && existsInRadarr4k && !existsInPlex) { + // 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 && !existsInRadarr4k && !existsInPlex4k) { + if ( + (existsInRadarr || existsInPlex) && + !existsInRadarr4k && + !existsInPlex4k + ) { if (media.status4k !== MediaStatus.UNKNOWN) { this.mediaUpdater(media, true); } } - if (existsInRadarr || existsInRadarr4k) { + if (existsInRadarr || existsInRadarr4k || existsInPlex || existsInPlex4k) { return true; } @@ -357,10 +374,6 @@ class AvailabilitySync { existsInPlex: boolean, existsInPlex4k: boolean ): Promise { - if (!media.tvdbId) { - return false; - } - let existsInSonarr = true; let existsInSonarr4k = true; @@ -369,49 +382,75 @@ class AvailabilitySync { 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 - const meta = await api.getSeriesByTvdbId(media.tvdbId); + let meta: SonarrSeries | undefined; - this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = meta.seasons; + if (!server.is4k && media.externalServiceId) { + meta = await api.getSeriesById(media.externalServiceId); + this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] = + meta.seasons; + } - //check if both exist or if a single non-4k or 4k exists - //if both do not exist we will return false - if (!server.is4k && !meta.id) { - existsInSonarr = false; - } + if (server.is4k && media.externalServiceId4k) { + meta = await api.getSeriesById(media.externalServiceId4k); + this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] = + meta.seasons; + } - if (server.is4k && !meta.id) { - existsInSonarr4k = false; - } - } + if (!server.is4k && (!meta || meta.statistics.episodeFileCount === 0)) { + existsInSonarr = false; + } - if (existsInSonarr && existsInSonarr4k) { - return true; - } + if (server.is4k && (!meta || meta.statistics.episodeFileCount === 0)) { + existsInSonarr4k = 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 (!existsInSonarr && existsInPlex) { - return true; - } + if (!server.is4k) { + existsInSonarr = false; + } - if (!existsInSonarr4k && existsInPlex4k) { - return true; + if (server.is4k) { + existsInSonarr4k = false; + } + } } - //if only a single non-4k or 4k exists, then change entity columns accordingly - //related media request will then be deleted - if (!existsInSonarr && existsInSonarr4k && !existsInPlex) { + // 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); } } - if (existsInSonarr && !existsInSonarr4k && !existsInPlex4k) { + if ( + (existsInSonarr || existsInPlex) && + !existsInSonarr4k && + !existsInPlex4k + ) { if (media.status4k !== MediaStatus.UNKNOWN) { this.mediaUpdater(media, true); } } - if (existsInSonarr || existsInSonarr4k) { + if (existsInSonarr || existsInSonarr4k || existsInPlex || existsInPlex4k) { return true; } @@ -424,10 +463,6 @@ class AvailabilitySync { seasonExistsInPlex: boolean, seasonExistsInPlex4k: boolean ): Promise { - if (!media.tvdbId) { - return false; - } - let seasonExistsInSonarr = true; let seasonExistsInSonarr4k = true; @@ -441,35 +476,67 @@ class AvailabilitySync { url: SonarrAPI.buildUrl(server, '/api/v3'), }); - const seasons = - this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] ?? - (await api.getSeriesByTvdbId(media.tvdbId)).seasons; - this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = seasons; + 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; + } - const hasMonitoredSeason = seasons.find( - ({ monitored, seasonNumber }) => - monitored && season.seasonNumber === seasonNumber - ); + 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; + } - if (!server.is4k && !hasMonitoredSeason) { - seasonExistsInSonarr = false; - } + const seasonIsUnavailable = seasons?.find( + ({ seasonNumber, statistics }) => + season.seasonNumber === seasonNumber && + statistics?.episodeFileCount === 0 + ); - if (server.is4k && !hasMonitoredSeason) { - seasonExistsInSonarr4k = false; - } - } + if (!server.is4k && seasonIsUnavailable) { + seasonExistsInSonarr = false; + } - if (seasonExistsInSonarr && seasonExistsInSonarr4k) { - return true; - } + 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 (!seasonExistsInSonarr && seasonExistsInPlex) { - return true; - } + if (!server.is4k) { + seasonExistsInSonarr = false; + } - if (!seasonExistsInSonarr4k && seasonExistsInPlex4k) { - return true; + if (server.is4k) { + seasonExistsInSonarr4k = false; + } + } } const seasonToBeDeleted = await seasonRequestRepository.findOne({ @@ -489,16 +556,16 @@ class AvailabilitySync { }, }); - //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 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 && + (seasonExistsInSonarr4k || seasonExistsInPlex4k) && !seasonExistsInPlex ) { if (season.status !== MediaStatus.UNKNOWN) { logger.info( - `${media.tvdbId}, season: ${season.seasonNumber} does not exist in your non-4k sonarr and plex instance. We will change its status to unknown.`, + `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, { @@ -511,7 +578,7 @@ class AvailabilitySync { if (media.status === MediaStatus.AVAILABLE) { logger.info( - `Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`, + `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`, { label: 'AvailabilitySync' } ); await mediaRepository.update(media.id, { @@ -522,13 +589,13 @@ class AvailabilitySync { } if ( - seasonExistsInSonarr && + (seasonExistsInSonarr || seasonExistsInPlex) && !seasonExistsInSonarr4k && !seasonExistsInPlex4k ) { if (season.status4k !== MediaStatus.UNKNOWN) { logger.info( - `${media.tvdbId}, season: ${season.seasonNumber} does not exist in your 4k sonarr and plex instance. We will change its status to unknown.`, + `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, { @@ -541,7 +608,7 @@ class AvailabilitySync { if (media.status4k === MediaStatus.AVAILABLE) { logger.info( - `Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`, + `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`, { label: 'AvailabilitySync' } ); await mediaRepository.update(media.id, { @@ -551,7 +618,12 @@ class AvailabilitySync { } } - if (seasonExistsInSonarr || seasonExistsInSonarr4k) { + if ( + seasonExistsInSonarr || + seasonExistsInSonarr4k || + seasonExistsInPlex || + seasonExistsInPlex4k + ) { return true; } @@ -565,7 +637,7 @@ class AvailabilitySync { let existsInPlex = false; let existsInPlex4k = false; - //check each plex instance to see if media exists + // Check each plex instance to see if media exists try { if (ratingKey) { const meta = await this.plexClient?.getMetadata(ratingKey); @@ -573,6 +645,7 @@ class AvailabilitySync { existsInPlex = true; } } + if (ratingKey4k) { const meta4k = await this.plexClient?.getMetadata(ratingKey4k); if (meta4k) { @@ -580,18 +653,17 @@ class AvailabilitySync { } } } catch (ex) { - // TODO: oof, not the nicest way of handling this, but plex-api does not leave us with any other options... if (!ex.message.includes('response code: 404')) { throw ex; } } - //base case for if both media versions exist in plex + // 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 + // 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, @@ -599,10 +671,10 @@ class AvailabilitySync { existsInPlex4k ); - //if true, media exists in at least one radarr or plex instance. + // If true, media exists in at least one radarr or plex instance. if (existsInRadarr) { logger.warn( - `${media.tmdbId} exists in at least one radarr or plex instance. Media will be updated if set to available.`, + `${media.id} exists in at least one Radarr or Plex instance. Media will be updated if set to available.`, { label: 'AvailabilitySync', } @@ -619,10 +691,10 @@ class AvailabilitySync { existsInPlex4k ); - //if true, media exists in at least one sonarr or plex instance. + // If true, media exists in at least one sonarr or plex instance. if (existsInSonarr) { logger.warn( - `${media.tvdbId} exists in at least one sonarr or plex instance. Media will be updated if set to available.`, + `${media.id} exists in at least one Sonarr or Plex instance. Media will be updated if set to available.`, { label: 'AvailabilitySync', } @@ -672,7 +744,7 @@ class AvailabilitySync { } } - //base case for if both season versions exist in plex + // Base case if both season versions exist in plex if (seasonExistsInPlex && seasonExistsInPlex4k) { return true; } @@ -686,7 +758,7 @@ class AvailabilitySync { if (existsInSonarr) { logger.warn( - `${media.tvdbId}, season: ${season.seasonNumber} exists in at least one sonarr or plex instance. Media will be updated if set to available.`, + `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', } From 70b1540ae23e83e01013856a9e06ad39e600922d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 11 May 2023 02:58:16 +0200 Subject: [PATCH 03/34] fix: handle search results with collections (#3393) * feat: handle search collection * Update server/utils/typeHelpers.ts Co-authored-by: Danshil Kokil Mungur * fix: modified title card to show collection instead of movies --------- Co-authored-by: Danshil Kokil Mungur Co-authored-by: Brandon --- server/api/themoviedb/interfaces.ts | 19 ++++++++++- server/models/Search.ts | 40 ++++++++++++++++++++++-- server/routes/discover.ts | 5 ++- server/utils/typeHelpers.ts | 23 ++++++++++++-- src/components/Common/ListView/index.tsx | 15 ++++++++- src/components/TitleCard/index.tsx | 28 ++++++++++++++--- src/i18n/globalMessages.ts | 1 + 7 files changed, 118 insertions(+), 13 deletions(-) diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 955e1b12e..775a89765 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -28,6 +28,18 @@ export interface TmdbTvResult extends TmdbMediaResult { first_air_date: string; } +export interface TmdbCollectionResult { + id: number; + media_type: 'collection'; + title: string; + original_title: string; + adult: boolean; + poster_path?: string; + backdrop_path?: string; + overview: string; + original_language: string; +} + export interface TmdbPersonResult { id: number; name: string; @@ -45,7 +57,12 @@ interface TmdbPaginatedResponse { } export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse { - results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[]; + results: ( + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult + )[]; } export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse { diff --git a/server/models/Search.ts b/server/models/Search.ts index 6ab696fe3..2193bbe15 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -1,4 +1,5 @@ import type { + TmdbCollectionResult, TmdbMovieDetails, TmdbMovieResult, TmdbPersonDetails, @@ -9,7 +10,7 @@ import type { import { MediaType as MainMediaType } from '@server/constants/media'; import type Media from '@server/entity/Media'; -export type MediaType = 'tv' | 'movie' | 'person'; +export type MediaType = 'tv' | 'movie' | 'person' | 'collection'; interface SearchResult { id: number; @@ -43,6 +44,18 @@ export interface TvResult extends SearchResult { firstAirDate: string; } +export interface CollectionResult { + id: number; + mediaType: 'collection'; + title: string; + originalTitle: string; + adult: boolean; + posterPath?: string; + backdropPath?: string; + overview: string; + originalLanguage: string; +} + export interface PersonResult { id: number; name: string; @@ -53,7 +66,7 @@ export interface PersonResult { knownFor: (MovieResult | TvResult)[]; } -export type Results = MovieResult | TvResult | PersonResult; +export type Results = MovieResult | TvResult | PersonResult | CollectionResult; export const mapMovieResult = ( movieResult: TmdbMovieResult, @@ -99,6 +112,20 @@ export const mapTvResult = ( mediaInfo: media, }); +export const mapCollectionResult = ( + collectionResult: TmdbCollectionResult +): CollectionResult => ({ + id: collectionResult.id, + mediaType: collectionResult.media_type || 'collection', + adult: collectionResult.adult, + originalLanguage: collectionResult.original_language, + originalTitle: collectionResult.original_title, + title: collectionResult.title, + overview: collectionResult.overview, + backdropPath: collectionResult.backdrop_path, + posterPath: collectionResult.poster_path, +}); + export const mapPersonResult = ( personResult: TmdbPersonResult ): PersonResult => ({ @@ -118,7 +145,12 @@ export const mapPersonResult = ( }); export const mapSearchResults = ( - results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[], + results: ( + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult + )[], media?: Media[] ): Results[] => results.map((result) => { @@ -139,6 +171,8 @@ export const mapSearchResults = ( req.tmdbId === result.id && req.mediaType === MainMediaType.TV ) ); + case 'collection': + return mapCollectionResult(result); default: return mapPersonResult(result); } diff --git a/server/routes/discover.ts b/server/routes/discover.ts index f032fa66b..47492fc06 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -14,12 +14,13 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { mapProductionCompany } from '@server/models/Movie'; import { + mapCollectionResult, mapMovieResult, mapPersonResult, mapTvResult, } from '@server/models/Search'; import { mapNetwork } from '@server/models/Tv'; -import { isMovie, isPerson } from '@server/utils/typeHelpers'; +import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import { sortBy } from 'lodash'; import { z } from 'zod'; @@ -647,6 +648,8 @@ discoverRoutes.get('/trending', async (req, res, next) => { ) : isPerson(result) ? mapPersonResult(result) + : isCollection(result) + ? mapCollectionResult(result) : mapTvResult( result, media.find( diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index 507ece8cd..548378ff7 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -1,4 +1,5 @@ import type { + TmdbCollectionResult, TmdbMovieDetails, TmdbMovieResult, TmdbPersonDetails, @@ -8,17 +9,35 @@ import type { } from '@server/api/themoviedb/interfaces'; export const isMovie = ( - movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult + movie: + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult ): movie is TmdbMovieResult => { return (movie as TmdbMovieResult).title !== undefined; }; export const isPerson = ( - person: TmdbMovieResult | TmdbTvResult | TmdbPersonResult + person: + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult ): person is TmdbPersonResult => { return (person as TmdbPersonResult).known_for !== undefined; }; +export const isCollection = ( + collection: + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult +): collection is TmdbCollectionResult => { + return (collection as TmdbCollectionResult).media_type === 'collection'; +}; + export const isMovieDetails = ( movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails ): movie is TmdbMovieDetails => { diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 6f09f768b..b46086862 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -5,6 +5,7 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll'; import globalMessages from '@app/i18n/globalMessages'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { + CollectionResult, MovieResult, PersonResult, TvResult, @@ -12,7 +13,7 @@ import type { import { useIntl } from 'react-intl'; type ListViewProps = { - items?: (TvResult | MovieResult | PersonResult)[]; + items?: (TvResult | MovieResult | PersonResult | CollectionResult)[]; plexItems?: WatchlistItem[]; isEmpty?: boolean; isLoading?: boolean; @@ -90,6 +91,18 @@ const ListView = ({ /> ); break; + case 'collection': + titleCard = ( + + ); + break; case 'person': titleCard = (
{mediaType === 'movie' ? intl.formatMessage(globalMessages.movie) + : mediaType === 'collection' + ? intl.formatMessage(globalMessages.collection) : intl.formatMessage(globalMessages.tvshow)}
@@ -177,7 +187,15 @@ const TitleCard = ({ leaveTo="opacity-0" >
- + Date: Thu, 11 May 2023 09:59:15 +0900 Subject: [PATCH 04/34] docs: add Alexays as a contributor for code (#3452) [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 113d0af47..180aea27a 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -809,6 +809,15 @@ "contributions": [ "code" ] + }, + { + "login": "Alexays", + "name": "Alex", + "avatar_url": "https://avatars.githubusercontent.com/u/13947260?v=4", + "profile": "https://arouillard.fr", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index 83fff5ea7..9cf0b0bb4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors

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

💻 Sebastian K
Sebastian K

💻 jariz
jariz

💻 + Alex
Alex

💻 From ac77b037d5fb0c54f5edf4b29d04adb57aef388f Mon Sep 17 00:00:00 2001 From: Zeb Muller Date: Thu, 11 May 2023 11:45:23 +1000 Subject: [PATCH 05/34] fix: error deleting users with over 1000 requests (#3376) Break-up request removal into groups of 1000 requests to be removed at a time. --- server/routes/user/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index ea709caf5..94784df51 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -381,7 +381,14 @@ router.delete<{ id: string }>( * we manually remove all requests from the user here so the parent media's * properly reflect the change. */ - await requestRepository.remove(user.requests); + await requestRepository.remove(user.requests, { + /** + * Break-up into groups of 1000 requests to be removed at a time. + * Necessary for users with >1000 requests, else an SQLite 'Expression tree is too large' error occurs. + * https://typeorm.io/repository-api#additional-options + */ + chunk: user.requests.length / 1000, + }); await userRepository.delete(user.id); return res.status(200).json(user.filter()); From cd1cacad5589e188406466c498d98f2608d40f85 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 11 May 2023 10:45:54 +0900 Subject: [PATCH 06/34] docs: add Zebebles as a contributor for code (#3453) [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 180aea27a..de5ea4913 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -818,6 +818,15 @@ "contributions": [ "code" ] + }, + { + "login": "Zebebles", + "name": "Zeb Muller", + "avatar_url": "https://avatars.githubusercontent.com/u/11425451?v=4", + "profile": "https://github.com/Zebebles", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index 9cf0b0bb4..c50ce854c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors

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

💻 jariz
jariz

💻 Alex
Alex

💻 + Zeb Muller
Zeb Muller

💻 From c1e10338c1a5d1e5d4928428af7edeacfc428916 Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Wed, 10 May 2023 22:59:12 -0400 Subject: [PATCH 07/34] refactor: pull to refresh (#3391) * refactor: decoupled PTR by removing import and creating new touch logic * fix: overscroll behavior on mobile is now prevented on the y axis * feat: added shadow effects to icon * fix: modified cypress test * fix: added better scroll lock functionality * fix: hide icon if scroll value is negative * fix: changed to allow usage on all touch devices --- cypress/e2e/pull-to-refresh.cy.ts | 2 +- package.json | 2 - src/components/Layout/PullToRefresh/index.tsx | 118 ++++++++++++++++++ src/components/Layout/index.tsx | 2 +- src/components/PullToRefresh/index.tsx | 45 ------- src/styles/globals.css | 2 +- yarn.lock | 10 -- 7 files changed, 121 insertions(+), 60 deletions(-) create mode 100644 src/components/Layout/PullToRefresh/index.tsx delete mode 100644 src/components/PullToRefresh/index.tsx diff --git a/cypress/e2e/pull-to-refresh.cy.ts b/cypress/e2e/pull-to-refresh.cy.ts index d56c55897..732ee4137 100644 --- a/cypress/e2e/pull-to-refresh.cy.ts +++ b/cypress/e2e/pull-to-refresh.cy.ts @@ -13,7 +13,7 @@ describe('Pull To Refresh', () => { url: '/api/v1/*', }).as('apiCall'); - cy.get('.searchbar').swipe('bottom', [190, 400]); + cy.get('.searchbar').swipe('bottom', [190, 500]); cy.wait('@apiCall').then((interception) => { assert.isNotNull( diff --git a/package.json b/package.json index 5dff068ee..8b82e45d4 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "openpgp": "5.7.0", "plex-api": "5.3.2", "pug": "3.0.2", - "pulltorefreshjs": "0.1.22", "react": "18.2.0", "react-ace": "10.1.0", "react-animate-height": "2.1.2", @@ -121,7 +120,6 @@ "@types/node": "17.0.36", "@types/node-schedule": "2.1.0", "@types/nodemailer": "6.4.7", - "@types/pulltorefreshjs": "0.1.5", "@types/react": "18.0.28", "@types/react-dom": "18.0.11", "@types/react-transition-group": "4.4.5", diff --git a/src/components/Layout/PullToRefresh/index.tsx b/src/components/Layout/PullToRefresh/index.tsx new file mode 100644 index 000000000..cdedcf43c --- /dev/null +++ b/src/components/Layout/PullToRefresh/index.tsx @@ -0,0 +1,118 @@ +import { ArrowPathIcon } from '@heroicons/react/24/outline'; +import { useRouter } from 'next/router'; +import { useEffect, useRef, useState } from 'react'; + +const PullToRefresh = () => { + const router = useRouter(); + + const [pullStartPoint, setPullStartPoint] = useState(0); + const [pullChange, setPullChange] = useState(0); + const refreshDiv = useRef(null); + + // Various pull down thresholds that determine icon location + const pullDownInitThreshold = pullChange > 20; + const pullDownStopThreshold = 120; + const pullDownReloadThreshold = pullChange > 340; + const pullDownIconLocation = pullChange / 3; + + useEffect(() => { + // Reload function that is called when reload threshold has been hit + // Add loading class to determine when to add spin animation + const forceReload = () => { + refreshDiv.current?.classList.add('loading'); + setTimeout(() => { + router.reload(); + }, 1000); + }; + + const html = document.querySelector('html'); + + // Determines if we are at the top of the page + // Locks or unlocks page when pulling down to refresh + const pullStart = (e: TouchEvent) => { + setPullStartPoint(e.targetTouches[0].screenY); + + if (window.scrollY === 0 && window.scrollX === 0) { + refreshDiv.current?.classList.add('block'); + refreshDiv.current?.classList.remove('hidden'); + document.body.style.touchAction = 'none'; + document.body.style.overscrollBehavior = 'none'; + if (html) { + html.style.overscrollBehaviorY = 'none'; + } + } else { + refreshDiv.current?.classList.remove('block'); + refreshDiv.current?.classList.add('hidden'); + } + }; + + // Tracks how far we have pulled down the refresh icon + const pullDown = async (e: TouchEvent) => { + const screenY = e.targetTouches[0].screenY; + + const pullLength = + pullStartPoint < screenY ? Math.abs(screenY - pullStartPoint) : 0; + + setPullChange(pullLength); + }; + + // Will reload the page if we are past the threshold + // Otherwise, we reset the pull + const pullFinish = () => { + setPullStartPoint(0); + + if (pullDownReloadThreshold) { + forceReload(); + } else { + setPullChange(0); + } + + document.body.style.touchAction = 'auto'; + document.body.style.overscrollBehaviorY = 'auto'; + if (html) { + html.style.overscrollBehaviorY = 'auto'; + } + }; + + window.addEventListener('touchstart', pullStart, { passive: false }); + window.addEventListener('touchmove', pullDown, { passive: false }); + window.addEventListener('touchend', pullFinish, { passive: false }); + + return () => { + window.removeEventListener('touchstart', pullStart); + window.removeEventListener('touchmove', pullDown); + window.removeEventListener('touchend', pullFinish); + }; + }, [pullDownInitThreshold, pullDownReloadThreshold, pullStartPoint, router]); + + return ( +
+
+ +
+
+ ); +}; + +export default PullToRefresh; diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index b30b9712a..878f27b18 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -1,8 +1,8 @@ import MobileMenu from '@app/components/Layout/MobileMenu'; +import PullToRefresh from '@app/components/Layout/PullToRefresh'; import SearchInput from '@app/components/Layout/SearchInput'; import Sidebar from '@app/components/Layout/Sidebar'; import UserDropdown from '@app/components/Layout/UserDropdown'; -import PullToRefresh from '@app/components/PullToRefresh'; import type { AvailableLocale } from '@app/context/LanguageContext'; import useLocale from '@app/hooks/useLocale'; import useSettings from '@app/hooks/useSettings'; diff --git a/src/components/PullToRefresh/index.tsx b/src/components/PullToRefresh/index.tsx deleted file mode 100644 index 68939c486..000000000 --- a/src/components/PullToRefresh/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { ArrowPathIcon } from '@heroicons/react/24/outline'; -import { useRouter } from 'next/router'; -import PR from 'pulltorefreshjs'; -import { useEffect } from 'react'; -import ReactDOMServer from 'react-dom/server'; - -const PullToRefresh = () => { - const router = useRouter(); - - useEffect(() => { - PR.init({ - mainElement: '#pull-to-refresh', - onRefresh() { - router.reload(); - }, - iconArrow: ReactDOMServer.renderToString( -
- -
- ), - iconRefreshing: ReactDOMServer.renderToString( -
- -
- ), - instructionsPullToRefresh: ReactDOMServer.renderToString(
), - instructionsReleaseToRefresh: ReactDOMServer.renderToString(
), - instructionsRefreshing: ReactDOMServer.renderToString(
), - distReload: 60, - distIgnore: 15, - shouldPullToRefresh: () => - !window.scrollY && document.body.style.overflow !== 'hidden', - }); - return () => { - PR.destroyAll(); - }; - }, [router]); - - return
; -}; - -export default PullToRefresh; diff --git a/src/styles/globals.css b/src/styles/globals.css index fac7272d7..8110e87e0 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -17,7 +17,7 @@ body { @apply bg-gray-900; - overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; } code { diff --git a/yarn.lock b/yarn.lock index c95f591d7..886aee53e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3954,11 +3954,6 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== -"@types/pulltorefreshjs@0.1.5": - version "0.1.5" - resolved "https://registry.yarnpkg.com/@types/pulltorefreshjs/-/pulltorefreshjs-0.1.5.tgz#f15c9dbc91b8fdd8135093d81ece9e9d4d2324d7" - integrity sha512-/VRTgBettvBg1KI8mGnA9oeWs359tTXQ7qsxLuXnksL88jvK6ZNMStG5T9x9vUO9O7jLsgREB0cElz/BWFfdew== - "@types/qs@*": version "6.9.7" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" @@ -11434,11 +11429,6 @@ pug@3.0.2, pug@^3.0.2: pug-runtime "^3.0.1" pug-strip-comments "^2.0.0" -pulltorefreshjs@0.1.22: - version "0.1.22" - resolved "https://registry.yarnpkg.com/pulltorefreshjs/-/pulltorefreshjs-0.1.22.tgz#ddb5e3feee0b2a49fd46e1b18e84fffef2c47ac0" - integrity sha512-haxNVEHnS4NCQA7NeG7TSV69z4uqy/N7nfPRuc4dPWe8H6ygUrMjdNeohE+6v0lVVX/ukSjbLYwPUGUYtFKfvQ== - pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" From 4bd87647d0551c20e13589a62690a6f3e5ad8ff7 Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Thu, 11 May 2023 00:16:50 -0400 Subject: [PATCH 08/34] fix: corrected initial fallback data load on details page (#3395) --- src/pages/movie/[movieId]/index.tsx | 36 ++++++++++++++--------------- src/pages/tv/[tvId]/index.tsx | 34 +++++++++++++-------------- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/pages/movie/[movieId]/index.tsx b/src/pages/movie/[movieId]/index.tsx index 2bb971c18..053ee3ff3 100644 --- a/src/pages/movie/[movieId]/index.tsx +++ b/src/pages/movie/[movieId]/index.tsx @@ -1,7 +1,7 @@ import MovieDetails from '@app/components/MovieDetails'; import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; import axios from 'axios'; -import type { NextPage } from 'next'; +import type { GetServerSideProps, NextPage } from 'next'; interface MoviePageProps { movie?: MovieDetailsType; @@ -11,25 +11,25 @@ const MoviePage: NextPage = ({ movie }) => { return ; }; -MoviePage.getInitialProps = async (ctx) => { - if (ctx.req) { - const response = await axios.get( - `http://localhost:${process.env.PORT || 5055}/api/v1/movie/${ - ctx.query.movieId - }`, - { - headers: ctx.req?.headers?.cookie - ? { cookie: ctx.req.headers.cookie } - : undefined, - } - ); +export const getServerSideProps: GetServerSideProps = async ( + ctx +) => { + const response = await axios.get( + `http://localhost:${process.env.PORT || 5055}/api/v1/movie/${ + ctx.query.movieId + }`, + { + headers: ctx.req?.headers?.cookie + ? { cookie: ctx.req.headers.cookie } + : undefined, + } + ); - return { + return { + props: { movie: response.data, - }; - } - - return {}; + }, + }; }; export default MoviePage; diff --git a/src/pages/tv/[tvId]/index.tsx b/src/pages/tv/[tvId]/index.tsx index 69fe216fd..a8a3cbd7d 100644 --- a/src/pages/tv/[tvId]/index.tsx +++ b/src/pages/tv/[tvId]/index.tsx @@ -1,7 +1,7 @@ import TvDetails from '@app/components/TvDetails'; import type { TvDetails as TvDetailsType } from '@server/models/Tv'; import axios from 'axios'; -import type { NextPage } from 'next'; +import type { GetServerSideProps, NextPage } from 'next'; interface TvPageProps { tv?: TvDetailsType; @@ -11,25 +11,23 @@ const TvPage: NextPage = ({ tv }) => { return ; }; -TvPage.getInitialProps = async (ctx) => { - if (ctx.req) { - const response = await axios.get( - `http://localhost:${process.env.PORT || 5055}/api/v1/tv/${ - ctx.query.tvId - }`, - { - headers: ctx.req?.headers?.cookie - ? { cookie: ctx.req.headers.cookie } - : undefined, - } - ); +export const getServerSideProps: GetServerSideProps = async ( + ctx +) => { + const response = await axios.get( + `http://localhost:${process.env.PORT || 5055}/api/v1/tv/${ctx.query.tvId}`, + { + headers: ctx.req?.headers?.cookie + ? { cookie: ctx.req.headers.cookie } + : undefined, + } + ); - return { + return { + props: { tv: response.data, - }; - } - - return {}; + }, + }; }; export default TvPage; From c27f96096ac8cc6c387f9d1dde5b263576ac2132 Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Thu, 11 May 2023 00:27:45 -0400 Subject: [PATCH 09/34] fix: lock body scroll when using webkit (#3399) --- src/hooks/useLockBodyScroll.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/hooks/useLockBodyScroll.ts b/src/hooks/useLockBodyScroll.ts index 08d7e3b6b..e962447cf 100644 --- a/src/hooks/useLockBodyScroll.ts +++ b/src/hooks/useLockBodyScroll.ts @@ -15,13 +15,20 @@ export const useLockBodyScroll = ( disabled?: boolean ): void => { useEffect(() => { - const originalStyle = window.getComputedStyle(document.body).overflow; + const originalOverflowStyle = window.getComputedStyle( + document.body + ).overflow; + const originalTouchActionStyle = window.getComputedStyle( + document.body + ).touchAction; if (isLocked && !disabled) { document.body.style.overflow = 'hidden'; + document.body.style.touchAction = 'none'; } return () => { if (!disabled) { - document.body.style.overflow = originalStyle; + document.body.style.overflow = originalOverflowStyle; + document.body.style.touchAction = originalTouchActionStyle; } }; }, [isLocked, disabled]); From e051b1dfea9c9320cc9dd420c475ae74cff0d901 Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Thu, 11 May 2023 00:43:11 -0400 Subject: [PATCH 10/34] fix: correctly load series fallback modal with sonarr v4 (#3451) --- server/routes/service.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/routes/service.ts b/server/routes/service.ts index b77d58c9d..083e1eb57 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -183,9 +183,7 @@ serviceRoutes.get<{ tmdbId: string }>( const sonarr = new SonarrAPI({ apiKey: sonarrSettings.apiKey, - url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${ - sonarrSettings.hostname - }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`, + url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), }); try { From aa849776809dfe891e67ff4db6861ef44df1a774 Mon Sep 17 00:00:00 2001 From: Shane Friedman Date: Fri, 12 May 2023 20:23:14 -0400 Subject: [PATCH 11/34] feat(discover): support filtering by tmdb user vote count on discover page (#3407) --- overseerr-api.yml | 20 +++++++++ server/api/themoviedb/index.ts | 12 ++++++ server/routes/discover.ts | 6 +++ .../Discover/FilterSlideover/index.tsx | 41 +++++++++++++++++++ src/components/Discover/constants.ts | 16 ++++++++ src/i18n/locale/en.json | 2 + 6 files changed, 97 insertions(+) diff --git a/overseerr-api.yml b/overseerr-api.yml index 542ac59f4..c8b528859 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4186,6 +4186,16 @@ paths: schema: type: number example: 10 + - in: query + name: voteCountGte + schema: + type: number + example: 7 + - in: query + name: voteCountLte + schema: + type: number + example: 10 - in: query name: watchRegion schema: @@ -4465,6 +4475,16 @@ paths: schema: type: number example: 10 + - in: query + name: voteCountGte + schema: + type: number + example: 7 + - in: query + name: voteCountLte + schema: + type: number + example: 10 - in: query name: watchRegion schema: diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 4c931ff97..ef36fcd6d 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -65,6 +65,8 @@ interface DiscoverMovieOptions { withRuntimeLte?: string; voteAverageGte?: string; voteAverageLte?: string; + voteCountGte?: string; + voteCountLte?: string; originalLanguage?: string; genre?: string; studio?: string; @@ -83,6 +85,8 @@ interface DiscoverTvOptions { withRuntimeLte?: string; voteAverageGte?: string; voteAverageLte?: string; + voteCountGte?: string; + voteCountLte?: string; includeEmptyReleaseDate?: boolean; originalLanguage?: string; genre?: string; @@ -460,6 +464,8 @@ class TheMovieDb extends ExternalAPI { withRuntimeLte, voteAverageGte, voteAverageLte, + voteCountGte, + voteCountLte, watchProviders, watchRegion, }: DiscoverMovieOptions = {}): Promise => { @@ -504,6 +510,8 @@ class TheMovieDb extends ExternalAPI { 'with_runtime.lte': withRuntimeLte, 'vote_average.gte': voteAverageGte, 'vote_average.lte': voteAverageLte, + 'vote_count.gte': voteCountGte, + 'vote_count.lte': voteCountLte, watch_region: watchRegion, with_watch_providers: watchProviders, }, @@ -530,6 +538,8 @@ class TheMovieDb extends ExternalAPI { withRuntimeLte, voteAverageGte, voteAverageLte, + voteCountGte, + voteCountLte, watchProviders, watchRegion, }: DiscoverTvOptions = {}): Promise => { @@ -574,6 +584,8 @@ class TheMovieDb extends ExternalAPI { 'with_runtime.lte': withRuntimeLte, 'vote_average.gte': voteAverageGte, 'vote_average.lte': voteAverageLte, + 'vote_count.gte': voteCountGte, + 'vote_count.lte': voteCountLte, with_watch_providers: watchProviders, watch_region: watchRegion, }, diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 47492fc06..487d1a329 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -65,6 +65,8 @@ const QueryFilterOptions = z.object({ withRuntimeLte: z.coerce.string().optional(), voteAverageGte: z.coerce.string().optional(), voteAverageLte: z.coerce.string().optional(), + voteCountGte: z.coerce.string().optional(), + voteCountLte: z.coerce.string().optional(), network: z.coerce.string().optional(), watchProviders: z.coerce.string().optional(), watchRegion: z.coerce.string().optional(), @@ -96,6 +98,8 @@ discoverRoutes.get('/movies', async (req, res, next) => { withRuntimeLte: query.withRuntimeLte, voteAverageGte: query.voteAverageGte, voteAverageLte: query.voteAverageLte, + voteCountGte: query.voteCountGte, + voteCountLte: query.voteCountLte, watchProviders: query.watchProviders, watchRegion: query.watchRegion, }); @@ -371,6 +375,8 @@ discoverRoutes.get('/tv', async (req, res, next) => { withRuntimeLte: query.withRuntimeLte, voteAverageGte: query.voteAverageGte, voteAverageLte: query.voteAverageLte, + voteCountGte: query.voteCountGte, + voteCountLte: query.voteCountLte, watchProviders: query.watchProviders, watchRegion: query.watchRegion, }); diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index 10ee0fea2..83d5a2e49 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -35,8 +35,10 @@ const messages = defineMessages({ ratingText: 'Ratings between {minValue} and {maxValue}', clearfilters: 'Clear Active Filters', tmdbuserscore: 'TMDB User Score', + tmdbuservotecount: 'TMDB User Vote Count', runtime: 'Runtime', streamingservices: 'Streaming Services', + voteCount: 'Number of votes between {minValue} and {maxValue}', }); type FilterSlideoverProps = { @@ -246,6 +248,45 @@ const FilterSlideover = ({ })} />
+ + {intl.formatMessage(messages.tmdbuservotecount)} + +
+ { + updateQueryParams( + 'voteCountGte', + min !== 0 && Number(currentFilters.voteCountLte) !== 1000 + ? min.toString() + : undefined + ); + }} + onUpdateMax={(max) => { + updateQueryParams( + 'voteCountLte', + max !== 1000 && Number(currentFilters.voteCountGte) !== 0 + ? max.toString() + : undefined + ); + }} + subText={intl.formatMessage(messages.voteCount, { + minValue: currentFilters.voteCountGte ?? 0, + maxValue: currentFilters.voteCountLte ?? 1000, + })} + /> +
{intl.formatMessage(messages.streamingservices)} diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 802ba7c6e..0571f1fc7 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -104,6 +104,8 @@ export const QueryFilterOptions = z.object({ withRuntimeLte: z.string().optional(), voteAverageGte: z.string().optional(), voteAverageLte: z.string().optional(), + voteCountLte: z.string().optional(), + voteCountGte: z.string().optional(), watchRegion: z.string().optional(), watchProviders: z.string().optional(), }); @@ -169,6 +171,14 @@ export const prepareFilterValues = ( filterValues.voteAverageLte = values.voteAverageLte; } + if (values.voteCountGte) { + filterValues.voteCountGte = values.voteCountGte; + } + + if (values.voteCountLte) { + filterValues.voteCountLte = values.voteCountLte; + } + if (values.watchProviders) { filterValues.watchProviders = values.watchProviders; } @@ -190,6 +200,12 @@ export const countActiveFilters = (filterValues: FilterOptions): number => { delete clonedFilters.voteAverageLte; } + if (clonedFilters.voteCountGte || filterValues.voteCountLte) { + totalCount += 1; + delete clonedFilters.voteCountGte; + delete clonedFilters.voteCountLte; + } + if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) { totalCount += 1; delete clonedFilters.withRuntimeGte; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 39d44bbc3..0098eb5c6 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -76,7 +76,9 @@ "components.Discover.FilterSlideover.streamingservices": "Streaming Services", "components.Discover.FilterSlideover.studio": "Studio", "components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score", + "components.Discover.FilterSlideover.tmdbuservotecount": "TMDB User Vote Count", "components.Discover.FilterSlideover.to": "To", + "components.Discover.FilterSlideover.voteCount": "Number of votes between {minValue} and {maxValue}", "components.Discover.MovieGenreList.moviegenres": "Movie Genres", "components.Discover.MovieGenreSlider.moviegenres": "Movie Genres", "components.Discover.NetworkSlider.networks": "Networks", From b8e3c07c4760462d42dafb05ebaea8c19a8ec842 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 13 May 2023 09:35:51 +0900 Subject: [PATCH 12/34] docs: add SMores as a contributor for code (#3455) [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 de5ea4913..7cf0f74e8 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -827,6 +827,15 @@ "contributions": [ "code" ] + }, + { + "login": "SMores", + "name": "Shane Friedman", + "avatar_url": "https://avatars.githubusercontent.com/u/5354254?v=4", + "profile": "http://smoores.dev", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index c50ce854c..915c75c8f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors

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

💻 Alex
Alex

💻 Zeb Muller
Zeb Muller

💻 + Shane Friedman
Shane Friedman

💻 From 24f268b6cb67d9a8d8675cd6e09dd83a7f499add Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Izaac=20Br=C3=A5nn?= Date: Mon, 29 May 2023 04:29:36 +0200 Subject: [PATCH 13/34] feat: auto tagging requested media with username (#3338) * feat: auto tagging requested media with username Relating to discussion: https://github.com/sct/overseerr/discussions/3313 Adding an option to the Radarr and Sonarr service to enable automatic tagging with the username requesting the media. Current format, to reduce tag clutter if a user changes displayname: `[user.id] - [user.displayName]` * fix: modified new secondary tip language --------- Co-authored-by: Brandon Cohen --- server/entity/MediaRequest.ts | 64 +++++++++++++++++++ server/lib/settings.ts | 1 + src/components/Settings/RadarrModal/index.tsx | 20 ++++++ src/components/Settings/SonarrModal/index.tsx | 20 ++++++ src/i18n/locale/en.json | 5 ++ 5 files changed, 110 insertions(+) diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 61122afc3..5a8d3988c 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -764,6 +764,38 @@ export class MediaRequest { return; } + if (radarrSettings.tagRequests) { + let userTag = (await radarr.getTags()).find((v) => + v.label.startsWith(this.requestedBy.id + ' - ') + ); + if (!userTag) { + logger.info(`Requester has no active tag. Creating new`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + userId: this.requestedBy.id, + newTag: + this.requestedBy.id + ' - ' + this.requestedBy.displayName, + }); + userTag = await radarr.createTag({ + label: this.requestedBy.id + ' - ' + this.requestedBy.displayName, + }); + } + if (userTag.id) { + if (!tags?.find((v) => v === userTag?.id)) { + tags?.push(userTag.id); + } + } else { + logger.warn(`Requester has no tag and failed to add one`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + userId: this.requestedBy.id, + radarrServer: radarrSettings.hostname + ':' + radarrSettings.port, + }); + } + } + if ( media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ) { @@ -1022,6 +1054,38 @@ export class MediaRequest { }); } + if (sonarrSettings.tagRequests) { + let userTag = (await sonarr.getTags()).find((v) => + v.label.startsWith(this.requestedBy.id + ' - ') + ); + if (!userTag) { + logger.info(`Requester has no active tag. Creating new`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + userId: this.requestedBy.id, + newTag: + this.requestedBy.id + ' - ' + this.requestedBy.displayName, + }); + userTag = await sonarr.createTag({ + label: this.requestedBy.id + ' - ' + this.requestedBy.displayName, + }); + } + if (userTag.id) { + if (!tags?.find((v) => v === userTag?.id)) { + tags?.push(userTag.id); + } + } else { + logger.warn(`Requester has no tag and failed to add one`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + userId: this.requestedBy.id, + sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port, + }); + } + } + const sonarrSeriesOptions: AddSeriesOptions = { profileId: qualityProfile, languageProfileId: languageProfile, diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 8e66ebc5d..c3981fe90 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -61,6 +61,7 @@ export interface DVRSettings { externalUrl?: string; syncEnabled: boolean; preventSearch: boolean; + tagRequests: boolean; } export interface RadarrSettings extends DVRSettings { diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index e74b0465f..4ebc7a8ba 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -57,6 +57,9 @@ const messages = defineMessages({ testFirstTags: 'Test connection to load tags', tags: 'Tags', enableSearch: 'Enable Automatic Search', + tagRequests: 'Tag Requests', + tagRequestsInfo: + "Automatically add an additional tag with the requester's user ID & display name", validationApplicationUrl: 'You must provide a valid URL', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationBaseUrlLeadingSlash: 'URL base must have a leading slash', @@ -238,6 +241,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { externalUrl: radarr?.externalUrl, syncEnabled: radarr?.syncEnabled ?? false, enableSearch: !radarr?.preventSearch, + tagRequests: radarr?.tagRequests ?? false, }} validationSchema={RadarrSettingsSchema} onSubmit={async (values) => { @@ -263,6 +267,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { externalUrl: values.externalUrl, syncEnabled: values.syncEnabled, preventSearch: !values.enableSearch, + tagRequests: values.tagRequests, }; if (!radarr) { await axios.post('/api/v1/settings/radarr', submission); @@ -713,6 +718,21 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { />
+
+ +
+ +
+
); diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index d9ff0c172..6c61d5dbf 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -62,6 +62,9 @@ const messages = defineMessages({ syncEnabled: 'Enable Scan', externalUrl: 'External URL', enableSearch: 'Enable Automatic Search', + tagRequests: 'Tag Requests', + tagRequestsInfo: + "Automatically add an additional tag with the requester's user ID & display name", validationApplicationUrl: 'You must provide a valid URL', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationBaseUrlLeadingSlash: 'Base URL must have a leading slash', @@ -252,6 +255,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { externalUrl: sonarr?.externalUrl, syncEnabled: sonarr?.syncEnabled ?? false, enableSearch: !sonarr?.preventSearch, + tagRequests: sonarr?.tagRequests ?? false, }} validationSchema={SonarrSettingsSchema} onSubmit={async (values) => { @@ -292,6 +296,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { externalUrl: values.externalUrl, syncEnabled: values.syncEnabled, preventSearch: !values.enableSearch, + tagRequests: values.tagRequests, }; if (!sonarr) { await axios.post('/api/v1/settings/sonarr', submission); @@ -960,6 +965,21 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { /> +
+ +
+ +
+
); diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 0098eb5c6..24a537a07 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -684,6 +684,8 @@ "components.Settings.RadarrModal.servername": "Server Name", "components.Settings.RadarrModal.ssl": "Use SSL", "components.Settings.RadarrModal.syncEnabled": "Enable Scan", + "components.Settings.RadarrModal.tagRequests": "Tag Requests", + "components.Settings.RadarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name", "components.Settings.RadarrModal.tags": "Tags", "components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", "components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders", @@ -861,6 +863,8 @@ "components.Settings.SonarrModal.servername": "Server Name", "components.Settings.SonarrModal.ssl": "Use SSL", "components.Settings.SonarrModal.syncEnabled": "Enable Scan", + "components.Settings.SonarrModal.tagRequests": "Tag Requests", + "components.Settings.SonarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name", "components.Settings.SonarrModal.tags": "Tags", "components.Settings.SonarrModal.testFirstLanguageProfiles": "Test connection to load language profiles", "components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", @@ -1178,6 +1182,7 @@ "i18n.cancel": "Cancel", "i18n.canceling": "Canceling…", "i18n.close": "Close", + "i18n.collection": "Collection", "i18n.decline": "Decline", "i18n.declined": "Declined", "i18n.delete": "Delete", From 0a007ca805dff334fc7972590c6f7bc85dce95a7 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 29 May 2023 11:32:08 +0900 Subject: [PATCH 14/34] docs: add IzaacJ as a contributor for code (#3473) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 12 +++++++++++- README.md | 5 ++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 7cf0f74e8..62df8b807 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -836,6 +836,15 @@ "contributions": [ "code" ] + }, + { + "login": "IzaacJ", + "name": "Izaac Brånn", + "avatar_url": "https://avatars.githubusercontent.com/u/711323?v=4", + "profile": "https://izaacj.me", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", @@ -845,5 +854,6 @@ "repoType": "github", "repoHost": "https://github.com", "skipCi": false, - "commitConvention": "angular" + "commitConvention": "angular", + "commitType": "docs" } diff --git a/README.md b/README.md index 915c75c8f..af2e7859d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors

@@ -191,6 +191,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Zeb Muller
Zeb Muller

💻 Shane Friedman
Shane Friedman

💻 + + Izaac Brånn
Izaac Brånn

💻 + From f33eb862fd85ff135828ab25c8d12d84e7e50762 Mon Sep 17 00:00:00 2001 From: Ryan Cohen Date: Mon, 29 May 2023 11:36:26 +0900 Subject: [PATCH 15/34] chore: update codeowners (#3474) [skip ci] --- .github/CODEOWNERS | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6effae045..68a2d018e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,10 +1,10 @@ # Global code ownership -* @sct @TheCatLady @danshilm +* @sct @TheCatLady @danshilm @OwsleyJr # Documentation -/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm -/*.md @TheCatLady @samwiseg0 @danshilm -/docs/ @TheCatLady @samwiseg0 @danshilm +/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm @OwsleyJr +/*.md @TheCatLady @samwiseg0 @danshilm @OwsleyJr +/docs/ @TheCatLady @samwiseg0 @danshilm @OwsleyJr # Snap-related files /.github/workflows/snap.yaml @samwiseg0 @@ -12,4 +12,4 @@ # i18n locale files /src/i18n/locale/ @sct @TheCatLady -/src/i18n/locale/en.json @sct @TheCatLady @danshilm +/src/i18n/locale/en.json @sct @TheCatLady @danshilm @OwsleyJr From d7fa35e066cf371797aaa46ca464aa531ba8fb35 Mon Sep 17 00:00:00 2001 From: Salman Tariq Date: Thu, 1 Jun 2023 02:53:50 +0500 Subject: [PATCH 16/34] fix(genreselector): fix searching in Genre filter (#3468) --- src/components/Selector/index.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index 8a21d3fbe..78ae33ea1 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -169,15 +169,19 @@ export const GenreSelector = ({ loadDefaultGenre(); }, [defaultValue, type]); - const loadGenreOptions = async () => { + const loadGenreOptions = async (inputValue: string) => { const results = await axios.get( `/api/v1/discover/genreslider/${type}` ); - return results.data.map((result) => ({ - label: result.name, - value: result.id, - })); + return results.data + .map((result) => ({ + label: result.name, + value: result.id, + })) + .filter(({ label }) => + label.toLowerCase().includes(inputValue.toLowerCase()) + ); }; return ( From df332cec84f31e7a5152d9ad80f3f7839c0f3329 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 1 Jun 2023 00:24:05 +0000 Subject: [PATCH 17/34] docs: add SalmanTariq as a contributor for code (#3478) * 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 62df8b807..634d9050e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -845,6 +845,15 @@ "contributions": [ "code" ] + }, + { + "login": "SalmanTariq", + "name": "Salman Tariq", + "avatar_url": "https://avatars.githubusercontent.com/u/13284494?v=4", + "profile": "https://github.com/SalmanTariq", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index af2e7859d..5aa90d257 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors

@@ -193,6 +193,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Izaac Brånn
Izaac Brånn

💻 + Salman Tariq
Salman Tariq

💻 From 48f76662d5c08156f1da3f47e216c5f02668f64b Mon Sep 17 00:00:00 2001 From: Andrew Kennedy Date: Fri, 9 Jun 2023 15:46:04 -0700 Subject: [PATCH 18/34] fix: make a (shallow) copy of radarr/sonarr tags into a request before adding user tags (#3485) * Make a (shallow) copy of radarr/sonarr tags into a request before adding user tags * Undo random formatting changes * more undoing formatting changes * Fix undefined case. * Prettier format --- server/entity/MediaRequest.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 5a8d3988c..e980860c6 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -704,7 +704,7 @@ export class MediaRequest { let rootFolder = radarrSettings.activeDirectory; let qualityProfile = radarrSettings.activeProfileId; - let tags = radarrSettings.tags; + let tags = radarrSettings.tags ? [...radarrSettings.tags] : []; if ( this.rootFolder && @@ -1002,7 +1002,11 @@ export class MediaRequest { let tags = seriesType === 'anime' ? sonarrSettings.animeTags - : sonarrSettings.tags; + ? [...sonarrSettings.animeTags] + : [] + : sonarrSettings.tags + ? [...sonarrSettings.tags] + : []; if ( this.rootFolder && From 21231186d17df00073a8977bab9f7605736fd155 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 9 Jun 2023 23:20:02 +0000 Subject: [PATCH 19/34] docs: add andrew-kennedy as a contributor for code (#3489) [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 634d9050e..5ece548ea 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -854,6 +854,15 @@ "contributions": [ "code" ] + }, + { + "login": "andrew-kennedy", + "name": "Andrew Kennedy", + "avatar_url": "https://avatars.githubusercontent.com/u/2387159?v=4", + "profile": "https://github.com/andrew-kennedy", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index 5aa90d257..522988c86 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors

@@ -194,6 +194,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Izaac Brånn
Izaac Brånn

💻 Salman Tariq
Salman Tariq

💻 + Andrew Kennedy
Andrew Kennedy

💻 From 04fbd00d4ac29045592588ef8b664d1916991e37 Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Sun, 11 Jun 2023 06:14:14 +0500 Subject: [PATCH 20/34] fix: fixes RT ratings for tv shows (#3492) fix #3491 --- server/api/rottentomatoes.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts index 7695e3987..99a74eb1b 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rottentomatoes.ts @@ -17,7 +17,7 @@ interface RTAlgoliaHit { title: string; titles: string[]; description: string; - releaseYear: string; + releaseYear: number; rating: string; genres: string[]; updateDate: string; @@ -111,22 +111,19 @@ class RottenTomatoes extends ExternalAPI { // First, attempt to match exact name and year let movie = contentResults.hits.find( - (movie) => movie.releaseYear === year.toString() && movie.title === name + (movie) => movie.releaseYear === year && movie.title === name ); // If we don't find a movie, try to match partial name and year if (!movie) { movie = contentResults.hits.find( - (movie) => - movie.releaseYear === year.toString() && movie.title.includes(name) + (movie) => movie.releaseYear === year && movie.title.includes(name) ); } // If we still dont find a movie, try to match just on year if (!movie) { - movie = contentResults.hits.find( - (movie) => movie.releaseYear === year.toString() - ); + movie = contentResults.hits.find((movie) => movie.releaseYear === year); } // One last try, try exact name match only @@ -181,7 +178,7 @@ class RottenTomatoes extends ExternalAPI { if (year) { tvshow = contentResults.hits.find( - (series) => series.releaseYear === year.toString() + (series) => series.releaseYear === year ); } From 24d94ef6fd79d1fc1cef5464bbff5e6d2ba0789e Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 11 Jun 2023 10:15:55 +0900 Subject: [PATCH 21/34] docs: add Fallenbagel as a contributor for code (#3493) [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 5ece548ea..371872759 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -863,6 +863,15 @@ "contributions": [ "code" ] + }, + { + "login": "Fallenbagel", + "name": "Fallenbagel", + "avatar_url": "https://avatars.githubusercontent.com/u/98979876?v=4", + "profile": "https://github.com/Fallenbagel", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index 522988c86..6fe73c27d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors

@@ -195,6 +195,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Izaac Brånn
Izaac Brånn

💻 Salman Tariq
Salman Tariq

💻 Andrew Kennedy
Andrew Kennedy

💻 + Fallenbagel
Fallenbagel

💻 From c1a47bd9de332cb4925974690f5a33448b5cc2e6 Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Mon, 12 Jun 2023 04:35:01 -0400 Subject: [PATCH 22/34] 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 23/34] 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 24/34] 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 25/34] 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 26/34] 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 27/34] 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 28/34] 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 29/34] 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 30/34] 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 31/34] 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 32/34] 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 33/34] 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 34/34] 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);