diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index ac5aaa26..60f16e51 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -141,14 +141,83 @@ components: UserSettings: type: object properties: + username: + type: string + nullable: true + 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 +4538,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 +4555,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 @@ -6599,9 +6656,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 @@ -7268,11 +7332,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/plextv.ts b/server/api/plextv.ts index 2fc4523a..ef2cee42 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', } ); diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index d55e3278..9d6f5089 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 eab46a1e..f7ca4f0f 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 72688b2f..4fdd1167 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/server/routes/imageproxy.ts b/server/routes/imageproxy.ts index 484a2598..ac2fbe08 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, diff --git a/server/routes/media.ts b/server/routes/media.ts index 5e90f4ba..b9983d8b 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/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index e77c96a9..530b3a5a 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); } } } diff --git a/src/components/BlacklistedTagsBadge/index.tsx b/src/components/BlacklistedTagsBadge/index.tsx index eb1c9a47..a96272a4 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 b83691ef..42139b59 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 32cca079..9c7493d2 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/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index ee32590e..c1b115d4 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 ff17e098..ae12bb88 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(); } diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index e6eb15ff..b8d07887 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 4208836f..3b08a1b2 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)