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
This commit is contained in:
0xsysr3ll
2025-08-01 11:03:22 +02:00
committed by GitHub
parent e52c63164f
commit ca1686425b
9 changed files with 114 additions and 38 deletions

View File

@@ -7324,11 +7324,22 @@ paths:
example: 1 example: 1
responses: responses:
'200': '200':
description: Keyword returned description: Keyword returned (null if not found)
content: content:
application/json: application/json:
schema: schema:
nullable: true
$ref: '#/components/schemas/Keyword' $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: /watchproviders/regions:
get: get:
summary: Get watch provider regions summary: Get watch provider regions

View File

@@ -1054,7 +1054,7 @@ class TheMovieDb extends ExternalAPI {
keywordId, keywordId,
}: { }: {
keywordId: number; keywordId: number;
}): Promise<TmdbKeyword> { }): Promise<TmdbKeyword | null> {
try { try {
const data = await this.get<TmdbKeyword>( const data = await this.get<TmdbKeyword>(
`/keyword/${keywordId}`, `/keyword/${keywordId}`,
@@ -1064,6 +1064,9 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
if (e.response?.status === 404) {
return null;
}
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`); throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
} }
} }

View File

@@ -72,6 +72,7 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
const blacklistedTagsArr = blacklistedTags.split(','); const blacklistedTagsArr = blacklistedTags.split(',');
const pageLimit = settings.main.blacklistedTagsLimit; const pageLimit = settings.main.blacklistedTagsLimit;
const invalidKeywords = new Set<string>();
if (blacklistedTags.length === 0) { if (blacklistedTags.length === 0) {
return; return;
@@ -87,6 +88,19 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
// Iterate for each tag // Iterate for each tag
for (const tag of blacklistedTagsArr) { 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 queryMax = pageLimit * SortOptionsIterable.length;
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag 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<StatusBase> {
throw new AbortTransaction(); throw new AbortTransaction();
} }
const response = await getDiscover({ try {
page, const response = await getDiscover({
sortBy, page,
keywords: tag, sortBy,
}); keywords: tag,
await this.processResults(response, tag, type, em); });
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
this.progress++; await this.processResults(response, tag, type, em);
if (page === 1 && response.total_pages <= queryMax) { await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
// We will finish the tag with less queries than expected, move progress accordingly
this.progress += queryMax - response.total_pages; this.progress++;
fixedSortMode = true; if (page === 1 && response.total_pages <= queryMax) {
queryMax = response.total_pages; // 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( private async processResults(

View File

@@ -128,11 +128,15 @@ discoverRoutes.get('/movies', async (req, res, next) => {
if (keywords) { if (keywords) {
const splitKeywords = keywords.split(','); const splitKeywords = keywords.split(',');
keywordData = await Promise.all( const keywordResults = await Promise.all(
splitKeywords.map(async (keywordId) => { splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) }); return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
}) })
); );
keywordData = keywordResults.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
} }
return res.status(200).json({ return res.status(200).json({
@@ -415,11 +419,15 @@ discoverRoutes.get('/tv', async (req, res, next) => {
if (keywords) { if (keywords) {
const splitKeywords = keywords.split(','); const splitKeywords = keywords.split(',');
keywordData = await Promise.all( const keywordResults = await Promise.all(
splitKeywords.map(async (keywordId) => { splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) }); return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
}) })
); );
keywordData = keywordResults.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
} }
return res.status(200).json({ return res.status(200).json({

View File

@@ -29,14 +29,10 @@ const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
const keywordIds = data.blacklistedTags.slice(1, -1).split(','); const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
Promise.all( Promise.all(
keywordIds.map(async (keywordId) => { keywordIds.map(async (keywordId) => {
try { const { data } = await axios.get<Keyword | null>(
const { data } = await axios.get<Keyword>( `/api/v1/keyword/${keywordId}`
`/api/v1/keyword/${keywordId}` );
); return data?.name || `[Invalid: ${keywordId}]`;
return data.name;
} catch (err) {
return '';
}
}) })
).then((keywords) => { ).then((keywords) => {
setTagNamesBlacklistedFor(keywords.join(', ')); setTagNamesBlacklistedFor(keywords.join(', '));

View File

@@ -5,7 +5,10 @@ import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { ArrowDownIcon } from '@heroicons/react/24/solid'; 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 type { Keyword } from '@server/models/common';
import axios from 'axios'; import axios from 'axios';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
@@ -124,15 +127,19 @@ const ControlledKeywordSelector = ({
const keywords = await Promise.all( const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => { defaultValue.split(',').map(async (keywordId) => {
const { data } = await axios.get<Keyword>( const { data } = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}` `/api/v1/keyword/${keywordId}`
); );
return data; return data;
}) })
); );
const validKeywords: TmdbKeyword[] = keywords.filter(
(keyword): keyword is TmdbKeyword => keyword !== null
);
onChange( onChange(
keywords.map((keyword) => ({ validKeywords.map((keyword) => ({
label: keyword.name, label: keyword.name,
value: keyword.id, value: keyword.id,
})) }))

View File

@@ -77,16 +77,19 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const keywords = await Promise.all( const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => { slider.data.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>( const keyword = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}` `/api/v1/keyword/${keywordId}`
); );
return keyword.data; return keyword.data;
}) })
); );
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setDefaultDataValue( setDefaultDataValue(
keywords.map((keyword) => ({ validKeywords.map((keyword) => ({
label: keyword.name, label: keyword.name,
value: keyword.id, value: keyword.id,
})) }))

View File

@@ -309,16 +309,19 @@ export const KeywordSelector = ({
const keywords = await Promise.all( const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => { defaultValue.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>( const keyword = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}` `/api/v1/keyword/${keywordId}`
); );
return keyword.data; return keyword.data;
}) })
); );
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setDefaultDataValue( setDefaultDataValue(
keywords.map((keyword) => ({ validKeywords.map((keyword) => ({
label: keyword.name, label: keyword.name,
value: keyword.id, value: keyword.id,
})) }))

View File

@@ -113,12 +113,16 @@ const OverrideRuleTiles = ({
.flat() .flat()
.filter((keywordId) => keywordId) .filter((keywordId) => keywordId)
.map(async (keywordId) => { .map(async (keywordId) => {
const response = await axios.get(`/api/v1/keyword/${keywordId}`); const response = await axios.get<Keyword | null>(
const keyword: Keyword = response.data; `/api/v1/keyword/${keywordId}`
return keyword; );
return response.data;
}) })
); );
setKeywords(keywords); const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setKeywords(validKeywords);
const allUsersFromRules = rules const allUsersFromRules = rules
.map((rule) => rule.users) .map((rule) => rule.users)
.filter((users) => users) .filter((users) => users)