From e98f31e66cd2c9836a24169be0b3446d0923d9f9 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 24 Jul 2025 10:33:53 +0200 Subject: [PATCH 1/7] fix(proxy): initialize image proxies after the proxy is set up (#1794) The ImageProxy for TMDB and TheTVDB were initialized before the proxy settings were set up, so they were ignoring the proxy settings. fix #1787 --- server/routes/imageproxy.ts | 41 ++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/server/routes/imageproxy.ts b/server/routes/imageproxy.ts index 484a2598a..ac2fbe082 100644 --- a/server/routes/imageproxy.ts +++ b/server/routes/imageproxy.ts @@ -4,27 +4,40 @@ import { Router } from 'express'; const router = Router(); -const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', { - rateLimitOptions: { - maxRequests: 20, - maxRPS: 50, - }, -}); -const tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', { - rateLimitOptions: { - maxRequests: 20, - maxRPS: 50, - }, -}); +// Delay the initialization of ImageProxy instances until the proxy (if any) is properly configured +let _tmdbImageProxy: ImageProxy; +function initTmdbImageProxy() { + if (!_tmdbImageProxy) { + _tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', { + rateLimitOptions: { + maxRequests: 20, + maxRPS: 50, + }, + }); + } + return _tmdbImageProxy; +} +let _tvdbImageProxy: ImageProxy; +function initTvdbImageProxy() { + if (!_tvdbImageProxy) { + _tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', { + rateLimitOptions: { + maxRequests: 20, + maxRPS: 50, + }, + }); + } + return _tvdbImageProxy; +} router.get('/:type/*', async (req, res) => { const imagePath = req.path.replace(/^\/\w+/, ''); try { let imageData; if (req.params.type === 'tmdb') { - imageData = await tmdbImageProxy.getImage(imagePath); + imageData = await initTmdbImageProxy().getImage(imagePath); } else if (req.params.type === 'tvdb') { - imageData = await tvdbImageProxy.getImage(imagePath); + imageData = await initTvdbImageProxy().getImage(imagePath); } else { logger.error('Unsupported image type', { imagePath, From e52c63164fcf0fa1d35b61e4a9dedfae92764bdd Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <31414959+0xSysR3ll@users.noreply.github.com> Date: Wed, 30 Jul 2025 23:44:49 +0200 Subject: [PATCH 2/7] fix(api): add missing user settings' api docs (#1820) This PR adds new fields to the UserSettings schema, including username, email, discordId, and various quota limits for movies and TV shows. It also updates API paths to reference the new UserSettings schema. --- jellyseerr-api.yml | 90 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 17 deletions(-) diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index ac5aaa261..b3dc5c466 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -141,14 +141,82 @@ components: UserSettings: type: object properties: + username: + type: string + example: 'Mr User' + email: + type: string + example: 'user@example.com' + discordId: + type: string + nullable: true + example: '123456789' locale: type: string + nullable: true + example: 'en' discoverRegion: type: string - originalLanguage: - type: string + nullable: true + example: 'US' streamingRegion: type: string + nullable: true + example: 'US' + originalLanguage: + type: string + nullable: true + example: 'en' + movieQuotaLimit: + type: number + nullable: true + description: 'Maximum number of movie requests allowed' + example: 10 + movieQuotaDays: + type: number + nullable: true + description: 'Time period in days for movie quota' + example: 30 + tvQuotaLimit: + type: number + nullable: true + description: 'Maximum number of TV requests allowed' + example: 5 + tvQuotaDays: + type: number + nullable: true + description: 'Time period in days for TV quota' + example: 14 + globalMovieQuotaDays: + type: number + nullable: true + description: 'Global movie quota days setting' + example: 30 + globalMovieQuotaLimit: + type: number + nullable: true + description: 'Global movie quota limit setting' + example: 10 + globalTvQuotaLimit: + type: number + nullable: true + description: 'Global TV quota limit setting' + example: 5 + globalTvQuotaDays: + type: number + nullable: true + description: 'Global TV quota days setting' + example: 14 + watchlistSyncMovies: + type: boolean + nullable: true + description: 'Enable watchlist sync for movies' + example: true + watchlistSyncTv: + type: boolean + nullable: true + description: 'Enable watchlist sync for TV' + example: false MainSettings: type: object properties: @@ -4469,11 +4537,7 @@ paths: content: application/json: schema: - type: object - properties: - username: - type: string - example: 'Mr User' + $ref: '#/components/schemas/UserSettings' post: summary: Update general settings for a user description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. @@ -4490,22 +4554,14 @@ paths: content: application/json: schema: - type: object - properties: - username: - type: string - nullable: true + $ref: '#/components/schemas/UserSettings' responses: '200': description: Updated user general settings returned content: application/json: schema: - type: object - properties: - username: - type: string - example: 'Mr User' + $ref: '#/components/schemas/UserSettings' /user/{userId}/settings/password: get: summary: Get password page informatiom From ca1686425bcd34b05ebd3aa0b52ae939d2becc9d Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <31414959+0xSysR3ll@users.noreply.github.com> Date: Fri, 1 Aug 2025 11:03:22 +0200 Subject: [PATCH 3/7] fix(blacklist): handle invalid keywords gracefully (#1815) * fix(blacklist): handle invalid keywords gracefully * fix(blacklist): only remove keywords on 404 errors * fix(blacklist): remove non-null assertion and add proper type annotation * refactor(blacklist): return null instead of 404 for missing keywords * fix(blacklist): add type annotation for validKeywords * fix(selector): update type annotation for validKeywords --- jellyseerr-api.yml | 13 +++- server/api/themoviedb/index.ts | 5 +- server/job/blacklistedTagsProcessor.ts | 67 +++++++++++++++---- server/routes/discover.ts | 12 +++- src/components/BlacklistedTagsBadge/index.tsx | 12 ++-- .../BlacklistedTagsSelector/index.tsx | 13 +++- .../Discover/CreateSlider/index.tsx | 9 ++- src/components/Selector/index.tsx | 9 ++- .../OverrideRule/OverrideRuleTiles.tsx | 12 ++-- 9 files changed, 114 insertions(+), 38 deletions(-) diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index b3dc5c466..600bd81b0 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -7324,11 +7324,22 @@ paths: example: 1 responses: '200': - description: Keyword returned + description: Keyword returned (null if not found) content: application/json: schema: + nullable: true $ref: '#/components/schemas/Keyword' + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Unable to retrieve keyword data.' /watchproviders/regions: get: summary: Get watch provider regions diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index d55e32788..9d6f5089b 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -1054,7 +1054,7 @@ class TheMovieDb extends ExternalAPI { keywordId, }: { keywordId: number; - }): Promise { + }): Promise { try { const data = await this.get( `/keyword/${keywordId}`, @@ -1064,6 +1064,9 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { + if (e.response?.status === 404) { + return null; + } throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`); } } diff --git a/server/job/blacklistedTagsProcessor.ts b/server/job/blacklistedTagsProcessor.ts index eab46a1ee..f7ca4f0f2 100644 --- a/server/job/blacklistedTagsProcessor.ts +++ b/server/job/blacklistedTagsProcessor.ts @@ -72,6 +72,7 @@ class BlacklistedTagProcessor implements RunnableScanner { const blacklistedTagsArr = blacklistedTags.split(','); const pageLimit = settings.main.blacklistedTagsLimit; + const invalidKeywords = new Set(); if (blacklistedTags.length === 0) { return; @@ -87,6 +88,19 @@ class BlacklistedTagProcessor implements RunnableScanner { // Iterate for each tag for (const tag of blacklistedTagsArr) { + const keywordDetails = await tmdb.getKeywordDetails({ + keywordId: Number(tag), + }); + + if (keywordDetails === null) { + logger.warn('Skipping invalid keyword in blacklisted tags', { + label: 'Blacklisted Tags Processor', + keywordId: tag, + }); + invalidKeywords.add(tag); + continue; + } + let queryMax = pageLimit * SortOptionsIterable.length; let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag @@ -102,24 +116,51 @@ class BlacklistedTagProcessor implements RunnableScanner { throw new AbortTransaction(); } - const response = await getDiscover({ - page, - sortBy, - keywords: tag, - }); - await this.processResults(response, tag, type, em); - await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS)); + try { + const response = await getDiscover({ + page, + sortBy, + keywords: tag, + }); - this.progress++; - if (page === 1 && response.total_pages <= queryMax) { - // We will finish the tag with less queries than expected, move progress accordingly - this.progress += queryMax - response.total_pages; - fixedSortMode = true; - queryMax = response.total_pages; + await this.processResults(response, tag, type, em); + await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS)); + + this.progress++; + if (page === 1 && response.total_pages <= queryMax) { + // We will finish the tag with less queries than expected, move progress accordingly + this.progress += queryMax - response.total_pages; + fixedSortMode = true; + queryMax = response.total_pages; + } + } catch (error) { + logger.error('Error processing keyword in blacklisted tags', { + label: 'Blacklisted Tags Processor', + keywordId: tag, + errorMessage: error.message, + }); } } } } + + if (invalidKeywords.size > 0) { + const currentTags = blacklistedTagsArr.filter( + (tag) => !invalidKeywords.has(tag) + ); + const cleanedTags = currentTags.join(','); + + if (cleanedTags !== blacklistedTags) { + settings.main.blacklistedTags = cleanedTags; + await settings.save(); + + logger.info('Cleaned up invalid keywords from settings', { + label: 'Blacklisted Tags Processor', + removedKeywords: Array.from(invalidKeywords), + newBlacklistedTags: cleanedTags, + }); + } + } } private async processResults( diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 72688b2f3..4fdd11678 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -128,11 +128,15 @@ discoverRoutes.get('/movies', async (req, res, next) => { if (keywords) { const splitKeywords = keywords.split(','); - keywordData = await Promise.all( + const keywordResults = await Promise.all( splitKeywords.map(async (keywordId) => { return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) }); }) ); + + keywordData = keywordResults.filter( + (keyword): keyword is TmdbKeyword => keyword !== null + ); } return res.status(200).json({ @@ -415,11 +419,15 @@ discoverRoutes.get('/tv', async (req, res, next) => { if (keywords) { const splitKeywords = keywords.split(','); - keywordData = await Promise.all( + const keywordResults = await Promise.all( splitKeywords.map(async (keywordId) => { return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) }); }) ); + + keywordData = keywordResults.filter( + (keyword): keyword is TmdbKeyword => keyword !== null + ); } return res.status(200).json({ diff --git a/src/components/BlacklistedTagsBadge/index.tsx b/src/components/BlacklistedTagsBadge/index.tsx index eb1c9a475..a96272a4b 100644 --- a/src/components/BlacklistedTagsBadge/index.tsx +++ b/src/components/BlacklistedTagsBadge/index.tsx @@ -29,14 +29,10 @@ const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => { const keywordIds = data.blacklistedTags.slice(1, -1).split(','); Promise.all( keywordIds.map(async (keywordId) => { - try { - const { data } = await axios.get( - `/api/v1/keyword/${keywordId}` - ); - return data.name; - } catch (err) { - return ''; - } + const { data } = await axios.get( + `/api/v1/keyword/${keywordId}` + ); + return data?.name || `[Invalid: ${keywordId}]`; }) ).then((keywords) => { setTagNamesBlacklistedFor(keywords.join(', ')); diff --git a/src/components/BlacklistedTagsSelector/index.tsx b/src/components/BlacklistedTagsSelector/index.tsx index b83691efc..42139b59a 100644 --- a/src/components/BlacklistedTagsSelector/index.tsx +++ b/src/components/BlacklistedTagsSelector/index.tsx @@ -5,7 +5,10 @@ import { encodeURIExtraParams } from '@app/hooks/useDiscover'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import { ArrowDownIcon } from '@heroicons/react/24/solid'; -import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces'; +import type { + TmdbKeyword, + TmdbKeywordSearchResponse, +} from '@server/api/themoviedb/interfaces'; import type { Keyword } from '@server/models/common'; import axios from 'axios'; import { useFormikContext } from 'formik'; @@ -124,15 +127,19 @@ const ControlledKeywordSelector = ({ const keywords = await Promise.all( defaultValue.split(',').map(async (keywordId) => { - const { data } = await axios.get( + const { data } = await axios.get( `/api/v1/keyword/${keywordId}` ); return data; }) ); + const validKeywords: TmdbKeyword[] = keywords.filter( + (keyword): keyword is TmdbKeyword => keyword !== null + ); + onChange( - keywords.map((keyword) => ({ + validKeywords.map((keyword) => ({ label: keyword.name, value: keyword.id, })) diff --git a/src/components/Discover/CreateSlider/index.tsx b/src/components/Discover/CreateSlider/index.tsx index 32cca0794..9c7493d2a 100644 --- a/src/components/Discover/CreateSlider/index.tsx +++ b/src/components/Discover/CreateSlider/index.tsx @@ -77,16 +77,19 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { const keywords = await Promise.all( slider.data.split(',').map(async (keywordId) => { - const keyword = await axios.get( + const keyword = await axios.get( `/api/v1/keyword/${keywordId}` ); - return keyword.data; }) ); + const validKeywords: Keyword[] = keywords.filter( + (keyword): keyword is Keyword => keyword !== null + ); + setDefaultDataValue( - keywords.map((keyword) => ({ + validKeywords.map((keyword) => ({ label: keyword.name, value: keyword.id, })) diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index e6eb15ff4..b8d078876 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -309,16 +309,19 @@ export const KeywordSelector = ({ const keywords = await Promise.all( defaultValue.split(',').map(async (keywordId) => { - const keyword = await axios.get( + const keyword = await axios.get( `/api/v1/keyword/${keywordId}` ); - return keyword.data; }) ); + const validKeywords: Keyword[] = keywords.filter( + (keyword): keyword is Keyword => keyword !== null + ); + setDefaultDataValue( - keywords.map((keyword) => ({ + validKeywords.map((keyword) => ({ label: keyword.name, value: keyword.id, })) diff --git a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx index 4208836f8..3b08a1b27 100644 --- a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx +++ b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx @@ -113,12 +113,16 @@ const OverrideRuleTiles = ({ .flat() .filter((keywordId) => keywordId) .map(async (keywordId) => { - const response = await axios.get(`/api/v1/keyword/${keywordId}`); - const keyword: Keyword = response.data; - return keyword; + const response = await axios.get( + `/api/v1/keyword/${keywordId}` + ); + return response.data; }) ); - setKeywords(keywords); + const validKeywords: Keyword[] = keywords.filter( + (keyword): keyword is Keyword => keyword !== null + ); + setKeywords(validKeywords); const allUsersFromRules = rules .map((rule) => rule.users) .filter((users) => users) From e02ee24f70bae47731ddf445057703ce273b42ef Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <31414959+0xSysR3ll@users.noreply.github.com> Date: Tue, 5 Aug 2025 11:42:11 +0200 Subject: [PATCH 4/7] fix(media): update delete media file logic to include is4k parameter (#1832) * fix(media): update delete media file logic to include is4k parameter * fix(media): revert to MANAGE_REQUESTS permission --- jellyseerr-api.yml | 9 ++++++++- server/routes/media.ts | 6 +++++- src/components/ManageSlideOver/index.tsx | 10 ++++++---- src/components/RequestList/RequestItem/index.tsx | 4 +++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index 600bd81b0..7ec1076c6 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -6655,9 +6655,16 @@ paths: example: '1' schema: type: string + - in: query + name: is4k + description: Whether to remove from 4K service instance (true) or regular service instance (false) + required: false + example: false + schema: + type: boolean responses: '204': - description: Succesfully removed media item + description: Successfully removed media item /media/{mediaId}/{status}: post: summary: Update media status diff --git a/server/routes/media.ts b/server/routes/media.ts index 5e90f4ba7..b9983d8bf 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -197,8 +197,10 @@ mediaRoutes.delete( const media = await mediaRepository.findOneOrFail({ where: { id: Number(req.params.id) }, }); - const is4k = media.serviceUrl4k !== undefined; + + const is4k = req.query.is4k === 'true'; const isMovie = media.mediaType === MediaType.MOVIE; + let serviceSettings; if (isMovie) { serviceSettings = settings.radarr.find( @@ -225,6 +227,7 @@ mediaRoutes.delete( ); } } + if (!serviceSettings) { logger.warn( `There is no default ${ @@ -239,6 +242,7 @@ mediaRoutes.delete( ); return; } + let service; if (isMovie) { service = new RadarrAPI({ diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index ee32590ee..c1b115d41 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -118,9 +118,11 @@ const ManageSlideOver = ({ } }; - const deleteMediaFile = async () => { + const deleteMediaFile = async (is4k = false) => { if (data.mediaInfo) { - await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`); + await axios.delete( + `/api/v1/media/${data.mediaInfo.id}/file?is4k=${is4k}` + ); await axios.delete(`/api/v1/media/${data.mediaInfo.id}`); revalidate(); onClose(); @@ -414,7 +416,7 @@ const ManageSlideOver = ({ isDefaultService() && (
deleteMediaFile()} + onClick={() => deleteMediaFile(false)} confirmText={intl.formatMessage( globalMessages.areyousure )} @@ -573,7 +575,7 @@ const ManageSlideOver = ({ {isDefaultService() && (
deleteMediaFile()} + onClick={() => deleteMediaFile(true)} confirmText={intl.formatMessage( globalMessages.areyousure )} diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index ff17e0989..ae12bb883 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -343,7 +343,9 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const deleteMediaFile = async () => { if (request.media) { - await axios.delete(`/api/v1/media/${request.media.id}/file`); + await axios.delete( + `/api/v1/media/${request.media.id}/file?is4k=${request.is4k}` + ); await axios.delete(`/api/v1/media/${request.media.id}`); revalidateList(); } From c86ee0ddb1b1e24c296a2935aa964e7e2fb2b905 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <31414959+0xSysR3ll@users.noreply.github.com> Date: Wed, 6 Aug 2025 00:22:21 +0200 Subject: [PATCH 5/7] fix(api): make username field nullable in UserSettings API schema (#1835) --- jellyseerr-api.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index 7ec1076c6..60f16e51c 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -143,6 +143,7 @@ components: properties: username: type: string + nullable: true example: 'Mr User' email: type: string From 3292f113081cf83aa01d522c9d19c3b5ce0e281a Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <31414959+0xSysR3ll@users.noreply.github.com> Date: Sun, 10 Aug 2025 21:33:06 +0200 Subject: [PATCH 6/7] fix(MediaRequestSubscriber): use event manager to get fresh media state for MEDIA_AVAILABLE notifications (#1825) * fix(MediaRequestSubscriber): use event manager to get fresh media state for MEDIA_AVAILABLE notifications * refactor(MediaRequestSubscriber): streamline media availability notifications --- server/subscriber/MediaRequestSubscriber.ts | 193 ++++++++++++-------- 1 file changed, 118 insertions(+), 75 deletions(-) diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index e77c96a90..530b3a5a2 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -33,52 +33,93 @@ import { EventSubscriber } from 'typeorm'; export class MediaRequestSubscriber implements EntitySubscriberInterface { - private async notifyAvailableMovie(entity: MediaRequest) { + private async notifyAvailableMovie( + entity: MediaRequest, + event?: UpdateEvent + ) { + // Get fresh media state using event manager + let latestMedia: Media | null = null; + if (event?.manager) { + latestMedia = await event.manager.findOne(Media, { + where: { id: entity.media.id }, + }); + } + if (!latestMedia) { + const mediaRepository = getRepository(Media); + latestMedia = await mediaRepository.findOne({ + where: { id: entity.media.id }, + }); + } + + // Check availability using fresh media state if ( - entity.media[entity.is4k ? 'status4k' : 'status'] === - MediaStatus.AVAILABLE + !latestMedia || + latestMedia[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE ) { - const tmdb = new TheMovieDb(); + return; + } - try { - const movie = await tmdb.getMovie({ - movieId: entity.media.tmdbId, - }); + const tmdb = new TheMovieDb(); - notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { - event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`, - notifyAdmin: false, - notifySystem: true, - notifyUser: entity.requestedBy, - subject: `${movie.title}${ - movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' - }`, - message: truncate(movie.overview, { - length: 500, - separator: /\s/, - omission: '…', - }), - media: entity.media, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, - request: entity, - }); - } catch (e) { - logger.error('Something went wrong sending media notification(s)', { - label: 'Notifications', - errorMessage: e.message, - mediaId: entity.id, - }); - } + try { + const movie = await tmdb.getMovie({ + movieId: entity.media.tmdbId, + }); + + notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { + event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`, + notifyAdmin: false, + notifySystem: true, + notifyUser: entity.requestedBy, + subject: `${movie.title}${ + movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' + }`, + message: truncate(movie.overview, { + length: 500, + separator: /\s/, + omission: '…', + }), + media: latestMedia, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + request: entity, + }); + } catch (e) { + logger.error('Something went wrong sending media notification(s)', { + label: 'Notifications', + errorMessage: e.message, + mediaId: entity.id, + }); } } - private async notifyAvailableSeries(entity: MediaRequest) { - // Find all seasons in the related media entity - // and see if they are available, then we can check - // if the request contains the same seasons + private async notifyAvailableSeries( + entity: MediaRequest, + event?: UpdateEvent + ) { + // Get fresh media state with seasons using event manager + let latestMedia: Media | null = null; + if (event?.manager) { + latestMedia = await event.manager.findOne(Media, { + where: { id: entity.media.id }, + relations: { seasons: true }, + }); + } + if (!latestMedia) { + const mediaRepository = getRepository(Media); + latestMedia = await mediaRepository.findOne({ + where: { id: entity.media.id }, + relations: { seasons: true }, + }); + } + + if (!latestMedia) { + return; + } + + // Check availability using fresh media state const requestedSeasons = entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? []; - const availableSeasons = entity.media.seasons.filter( + const availableSeasons = latestMedia.seasons.filter( (season) => season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE && requestedSeasons.includes(season.seasonNumber) @@ -87,44 +128,46 @@ export class MediaRequestSubscriber availableSeasons.length > 0 && availableSeasons.length === requestedSeasons.length; - if (isMediaAvailable) { - const tmdb = new TheMovieDb(); + if (!isMediaAvailable) { + return; + } - try { - const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); + const tmdb = new TheMovieDb(); - notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { - event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`, - subject: `${tv.name}${ - tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' - }`, - message: truncate(tv.overview, { - length: 500, - separator: /\s/, - omission: '…', - }), - notifyAdmin: false, - notifySystem: true, - notifyUser: entity.requestedBy, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, - media: entity.media, - extra: [ - { - name: 'Requested Seasons', - value: entity.seasons - .map((season) => season.seasonNumber) - .join(', '), - }, - ], - request: entity, - }); - } catch (e) { - logger.error('Something went wrong sending media notification(s)', { - label: 'Notifications', - errorMessage: e.message, - mediaId: entity.id, - }); - } + try { + const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); + + notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { + event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`, + subject: `${tv.name}${ + tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' + }`, + message: truncate(tv.overview, { + length: 500, + separator: /\s/, + omission: '…', + }), + notifyAdmin: false, + notifySystem: true, + notifyUser: entity.requestedBy, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, + media: latestMedia, + extra: [ + { + name: 'Requested Seasons', + value: entity.seasons + .map((season) => season.seasonNumber) + .join(', '), + }, + ], + request: entity, + }); + } catch (e) { + logger.error('Something went wrong sending media notification(s)', { + label: 'Notifications', + errorMessage: e.message, + mediaId: entity.id, + }); } } @@ -782,10 +825,10 @@ export class MediaRequestSubscriber if (event.entity.status === MediaRequestStatus.COMPLETED) { if (event.entity.media.mediaType === MediaType.MOVIE) { - this.notifyAvailableMovie(event.entity as MediaRequest); + this.notifyAvailableMovie(event.entity as MediaRequest, event); } if (event.entity.media.mediaType === MediaType.TV) { - this.notifyAvailableSeries(event.entity as MediaRequest); + this.notifyAvailableSeries(event.entity as MediaRequest, event); } } } From 17d4f13afe389a9d0edd6eaa9a0728380a80d892 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <31414959+0xSysR3ll@users.noreply.github.com> Date: Thu, 14 Aug 2025 21:57:30 +0200 Subject: [PATCH 7/7] fix(api): update Plex Watchlist URL (#1847) --- server/api/plextv.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 2fc4523a8..ef2cee428 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -291,7 +291,7 @@ class PlexTvAPI extends ExternalAPI { headers: { 'If-None-Match': cachedWatchlist?.etag, }, - baseURL: 'https://metadata.provider.plex.tv', + baseURL: 'https://discover.provider.plex.tv', validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error } ); @@ -315,7 +315,7 @@ class PlexTvAPI extends ExternalAPI { const detailedResponse = await this.getRolling( `/library/metadata/${watchlistItem.ratingKey}`, { - baseURL: 'https://metadata.provider.plex.tv', + baseURL: 'https://discover.provider.plex.tv', } );