From e4a6a50c7ee63cb0678b8e7fe483911083f0f352 Mon Sep 17 00:00:00 2001 From: aussierk Date: Tue, 28 Jul 2026 13:11:34 -0400 Subject: [PATCH 1/4] feat(override-rules): add certification condition to request override rules Adds a certification condition to override rules, so admins can target requests by content rating(e.g. US:PG-13) in addition to genre, language, and keywords. Country is pinned per entry since TMDB certification codes aren't comparable across regions. This change only implements the backend portion of the change and involves a database migration to add the certification field.] feature #301 --- server/entity/MediaRequest.ts | 30 +++++++++++++++++- server/entity/OverrideRule.ts | 3 ++ ...5866761-AddCertificationToOverrideRules.ts | 17 ++++++++++ ...5659122-AddCertificationToOverrideRules.ts | 31 +++++++++++++++++++ server/routes/overrideRule.ts | 4 +++ 5 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 server/migration/postgres/1785245866761-AddCertificationToOverrideRules.ts create mode 100644 server/migration/sqlite/1785245659122-AddCertificationToOverrideRules.ts diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 60681f8f06..4620e2cc23 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -325,13 +325,41 @@ export class MediaRequest { ) { return false; } + if ( + rule.certification && + !rule.certification.split(',').some((entry) => { + const [countryCode, certificationValue] = entry.split(':'); + + if ('release_dates' in tmdbMedia) { + const countryReleases = tmdbMedia.release_dates.results.find( + (r) => r.iso_3166_1 === countryCode + ); + return countryReleases?.release_dates.some( + (rd) => rd.certification === certificationValue + ); + } else if ('content_ratings' in tmdbMedia) { + const countryRating = tmdbMedia.content_ratings.results.find( + (r) => r.iso_3166_1 === countryCode + ); + return countryRating?.rating === certificationValue; + } + return false; + }) + ) { + return false; + } return true; }); // hacky way to prioritize rules // TODO: make this better const prioritizedRule = appliedOverrideRules.sort((a, b) => { - const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords']; + const keys: (keyof OverrideRule)[] = [ + 'genre', + 'language', + 'keywords', + 'certification', + ]; const aSpecificity = keys.filter((key) => a[key] !== null).length; const bSpecificity = keys.filter((key) => b[key] !== null).length; diff --git a/server/entity/OverrideRule.ts b/server/entity/OverrideRule.ts index f77d721d48..f598c8b8ba 100644 --- a/server/entity/OverrideRule.ts +++ b/server/entity/OverrideRule.ts @@ -29,6 +29,9 @@ class OverrideRule { @Column({ nullable: true }) public keywords?: string; + @Column({ nullable: true }) + public certification?: string; + @Column({ type: 'int', nullable: true }) public profileId?: number; diff --git a/server/migration/postgres/1785245866761-AddCertificationToOverrideRules.ts b/server/migration/postgres/1785245866761-AddCertificationToOverrideRules.ts new file mode 100644 index 0000000000..a7caac32f1 --- /dev/null +++ b/server/migration/postgres/1785245866761-AddCertificationToOverrideRules.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCertificationToOverrideRules1785245866761 implements MigrationInterface { + name = 'AddCertificationToOverrideRules1785245866761'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "override_rule" ADD "certification" character varying` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "override_rule" DROP COLUMN "certification"` + ); + } +} diff --git a/server/migration/sqlite/1785245659122-AddCertificationToOverrideRules.ts b/server/migration/sqlite/1785245659122-AddCertificationToOverrideRules.ts new file mode 100644 index 0000000000..6fc3660ba4 --- /dev/null +++ b/server/migration/sqlite/1785245659122-AddCertificationToOverrideRules.ts @@ -0,0 +1,31 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCertificationToOverrideRules1785245659122 implements MigrationInterface { + name = 'AddCertificationToOverrideRules1785245659122'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "certification" varchar)` + ); + await queryRunner.query( + `INSERT INTO "temporary_override_rule"("id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt") SELECT "id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt" FROM "override_rule"` + ); + await queryRunner.query(`DROP TABLE "override_rule"`); + await queryRunner.query( + `ALTER TABLE "temporary_override_rule" RENAME TO "override_rule"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "override_rule" RENAME TO "temporary_override_rule"` + ); + await queryRunner.query( + `CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP))` + ); + await queryRunner.query( + `INSERT INTO "override_rule"("id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt") SELECT "id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt" FROM "temporary_override_rule"` + ); + await queryRunner.query(`DROP TABLE "temporary_override_rule"`); + } +} diff --git a/server/routes/overrideRule.ts b/server/routes/overrideRule.ts index 912a68aae6..5ec35d6c89 100644 --- a/server/routes/overrideRule.ts +++ b/server/routes/overrideRule.ts @@ -31,6 +31,7 @@ overrideRuleRoutes.post< genre?: string; language?: string; keywords?: string; + certification?: string; profileId?: number; rootFolder?: string; tags?: string; @@ -46,6 +47,7 @@ overrideRuleRoutes.post< genre: req.body.genre, language: req.body.language, keywords: req.body.keywords, + certification: req.body.certification, profileId: req.body.profileId, rootFolder: req.body.rootFolder, tags: req.body.tags, @@ -69,6 +71,7 @@ overrideRuleRoutes.put< genre?: string; language?: string; keywords?: string; + certification?: string; profileId?: number; rootFolder?: string; tags?: string; @@ -93,6 +96,7 @@ overrideRuleRoutes.put< rule.genre = req.body.genre; rule.language = req.body.language; rule.keywords = req.body.keywords; + rule.certification = req.body.certification; rule.profileId = req.body.profileId; rule.rootFolder = req.body.rootFolder; rule.tags = req.body.tags; From c4b59a54aae9e43aa4af68e23486641bf0e6fcbc Mon Sep 17 00:00:00 2001 From: aussierk Date: Tue, 28 Jul 2026 15:51:47 -0400 Subject: [PATCH 2/4] feat(override-rules): add certification selector to override rule UI Adds a flat multi-select certification picker to the override rule create/edit modal and its list-view tiles, wired to the backend certification condition already in place. Modified a previously unused CertificationSelector from the repo to match values and method expected in the override rules certification condition. feature #301 --- .../Selector/CertificationSelector.tsx | 283 +++--------------- src/components/Selector/index.tsx | 1 + .../OverrideRule/OverrideRuleModal.tsx | 29 +- .../OverrideRule/OverrideRuleTiles.tsx | 22 ++ src/i18n/locale/en.json | 7 +- 5 files changed, 101 insertions(+), 241 deletions(-) diff --git a/src/components/Selector/CertificationSelector.tsx b/src/components/Selector/CertificationSelector.tsx index 671c97e5dc..62d22240ba 100644 --- a/src/components/Selector/CertificationSelector.tsx +++ b/src/components/Selector/CertificationSelector.tsx @@ -21,29 +21,17 @@ interface CertificationResponse { interface CertificationOption { value: string; label: string; - certification?: string; } interface CertificationSelectorProps { - type: string; - certificationCountry?: string; + type: 'movie' | 'tv'; certification?: string; - certificationGte?: string; - certificationLte?: string; - onChange: (params: { - certificationCountry?: string; - certification?: string; - certificationGte?: string; - certificationLte?: string; - }) => void; - showRange?: boolean; + isDisabled?: boolean; + onChange: (value: string | undefined) => void; } const messages = defineMessages('components.Selector.CertificationSelector', { - selectCountry: 'Select a country', selectCertification: 'Select a certification', - minRating: 'Minimum rating', - maxRating: 'Maximum rating', noOptions: 'No options available', starttyping: 'Starting typing to search.', errorLoading: 'Failed to load certifications', @@ -51,27 +39,14 @@ const messages = defineMessages('components.Selector.CertificationSelector', { const CertificationSelector: React.FC = ({ type, - certificationCountry, certification, - certificationGte, - certificationLte, - showRange = false, + isDisabled, onChange, }) => { const intl = useIntl(); - const [selectedCountry, setSelectedCountry] = - useState( - certificationCountry - ? { value: certificationCountry, label: certificationCountry } - : null - ); - const [selectedCertification, setSelectedCertification] = - useState(null); - const [selectedCertificationGte, setSelectedCertificationGte] = - useState(null); - const [selectedCertificationLte, setSelectedCertificationLte] = - useState(null); - + const [selectedValues, setSelectedValues] = useState( + [] + ); const { data: certificationData, error: certificationError, @@ -90,61 +65,34 @@ const CertificationSelector: React.FC = ({ }, [regionsData] ); + const allOptions = useCallback((): CertificationOption[] => { + if (!certificationData) return []; + return Object.entries(certificationData.certifications).flatMap( + ([countryCode, certificationValue]) => + certificationValue + .filter((c) => c.certification) + .map((c) => ({ + value: `${countryCode}:${c.certification}`, + label: `${getCountryName(countryCode)} - ${c.certification}${ + c.meaning ? ` (${c.meaning})` : '' + }`, + })) + ); + }, [certificationData, getCountryName]); useEffect(() => { - if (certificationCountry && regionsData) { - setSelectedCountry({ - value: certificationCountry, - label: getCountryName(certificationCountry), - }); - } - }, [certificationCountry, regionsData, getCountryName]); - - useEffect(() => { - if (!certificationData || !certificationCountry) return; - - const certifications = ( - certificationData.certifications[certificationCountry] || [] - ) - .sort((a, b) => { - if (a.order !== undefined && b.order !== undefined) { - return a.order - b.order; - } - return a.certification.localeCompare(b.certification); - }) - .map((cert) => ({ - value: cert.certification, - label: `${cert.certification}${ - cert.meaning ? ` - ${cert.meaning}` : '' - }`, - certification: cert.certification, - })); - - if (certification) { - setSelectedCertification( - certifications.find((c) => c.value === certification) || null - ); + if (!certification || !certificationData) { + setSelectedValues([]); + return; } - - if (certificationGte) { - setSelectedCertificationGte( - certifications.find((c) => c.value === certificationGte) || null - ); - } - - if (certificationLte) { - setSelectedCertificationLte( - certifications.find((c) => c.value === certificationLte) || null - ); - } - }, [ - certificationData, - certificationCountry, - certification, - certificationGte, - certificationLte, - ]); - + const entries = certification.split(','); + const options = allOptions(); + setSelectedValues( + entries + .map((entry) => options.find((o) => o.value === entry)) + .filter((o): o is CertificationOption => !!o) + ); + }, [certification, certificationData, allOptions]); if (certificationError) { return (
@@ -157,104 +105,17 @@ const CertificationSelector: React.FC = ({ return ; } - const loadCountryOptions = async (inputValue: string) => { - if (!certificationData || !regionsData) return []; - - return Object.keys(certificationData.certifications) - .filter( - (code) => - certificationData.certifications[code] && - certificationData.certifications[code].length > 0 && - (code.toLowerCase().includes(inputValue.toLowerCase()) || - getCountryName(code) - .toLowerCase() - .includes(inputValue.toLowerCase())) - ) - .sort((a, b) => getCountryName(a).localeCompare(getCountryName(b))) - .map((code) => ({ - value: code, - label: getCountryName(code), - })); - }; - const loadCertificationOptions = async (inputValue: string) => { - if (!certificationData || !certificationCountry) return []; - - return (certificationData.certifications[certificationCountry] || []) - .sort((a, b) => { - if (a.order !== undefined && b.order !== undefined) { - return a.order - b.order; - } - return a.certification.localeCompare(b.certification); - }) - .map((cert) => ({ - value: cert.certification, - label: `${cert.certification}${ - cert.meaning ? ` - ${cert.meaning}` : '' - }`, - certification: cert.certification, - })) - .filter((cert) => - cert.label.toLowerCase().includes(inputValue.toLowerCase()) - ); - }; - - const handleCountryChange = (option: CertificationOption | null) => { - setSelectedCountry(option); - setSelectedCertification(null); - setSelectedCertificationGte(null); - setSelectedCertificationLte(null); - - onChange({ - certificationCountry: option?.value, - certification: undefined, - certificationGte: undefined, - certificationLte: undefined, - }); - }; - - const handleCertificationChange = (option: CertificationOption | null) => { - setSelectedCertification(option); - - onChange({ - certificationCountry, - certification: option?.value, - certificationGte: undefined, - certificationLte: undefined, - }); - }; - - const handleMinCertificationChange = (option: CertificationOption | null) => { - setSelectedCertificationGte(option); - - onChange({ - certificationCountry, - certification: undefined, - certificationGte: option?.value, - certificationLte: certificationLte, - }); - }; - - const handleMaxCertificationChange = (option: CertificationOption | null) => { - setSelectedCertificationLte(option); - - onChange({ - certificationCountry, - certification: undefined, - certificationGte: certificationGte, - certificationLte: option?.value, - }); + return allOptions().filter((option) => + option.label.toLowerCase().includes(inputValue.toLowerCase()) + ); }; - - const formatCertificationLabel = ( - option: CertificationOption, - { context }: { context: string } - ) => { - if (context === 'value') { - return option.certification || option.value; - } - // Show the full label with description in the menu - return option.label; + const handleChange = (options: readonly CertificationOption[] | null) => { + const values = options ?? []; + setSelectedValues(values as CertificationOption[]); + onChange( + values.length > 0 ? values.map((o) => o.value).join(',') : undefined + ); }; return ( @@ -262,12 +123,14 @@ const CertificationSelector: React.FC = ({ inputValue === '' @@ -275,59 +138,7 @@ const CertificationSelector: React.FC = ({ : intl.formatMessage(messages.noOptions) } /> - - {certificationCountry && !showRange && ( - intl.formatMessage(messages.noOptions)} - /> - )} - - {certificationCountry && showRange && ( -
-
- intl.formatMessage(messages.noOptions)} - /> -
-
- intl.formatMessage(messages.noOptions)} - /> -
-
- )}
); }; - export default CertificationSelector; diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index b83009d95b..4f7666e903 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -633,4 +633,5 @@ export const UserSelector = ({ ); }; +export { default as CertificationSelector } from './CertificationSelector'; export { default as USCertificationSelector } from './USCertificationSelector'; diff --git a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx index 5cdd4fb9dd..fd8978086f 100644 --- a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx +++ b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx @@ -1,6 +1,7 @@ import Modal from '@app/components/Common/Modal'; import LanguageSelector from '@app/components/LanguageSelector'; import { + CertificationSelector, GenreSelector, KeywordSelector, UserSelector, @@ -36,6 +37,7 @@ const messages = defineMessages('components.Settings.OverrideRuleModal', { genres: 'Genres', languages: 'Languages', keywords: 'Keywords', + certification: 'Certification', rootfolder: 'Root Folder', selectRootFolder: 'Select root folder', qualityprofile: 'Quality Profile', @@ -155,6 +157,7 @@ const OverrideRuleModal = ({ genre: rule?.genre, language: rule?.language, keywords: rule?.keywords, + certification: rule?.certification, profileId: rule?.profileId, rootFolder: rule?.rootFolder, tags: rule?.tags, @@ -166,6 +169,7 @@ const OverrideRuleModal = ({ genre: values.genre || null, language: values.language || null, keywords: values.keywords || null, + certification: values.certification || null, profileId: Number(values.profileId) || null, rootFolder: values.rootFolder || null, tags: values.tags || null, @@ -217,7 +221,8 @@ const OverrideRuleModal = ({ (!values.users && !values.genre && !values.language && - !values.keywords) || + !values.keywords && + !values.certification) || (!values.rootFolder && !values.profileId && !values.tags) } onOk={() => handleSubmit()} @@ -413,6 +418,28 @@ const OverrideRuleModal = ({ )} +
+ +
+
+ { + setFieldValue('certification', value); + }} + /> +
+ {errors.certification && + touched.certification && + typeof errors.certification === 'string' && ( +
{errors.certification}
+ )} +
+

{intl.formatMessage(messages.settings)}

diff --git a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx index 3e434077d0..c579ad5d32 100644 --- a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx +++ b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx @@ -25,6 +25,7 @@ const messages = defineMessages('components.Settings.OverrideRuleTile', { genre: 'Genre', language: 'Language', keywords: 'Keywords', + certification: 'Certification', conditions: 'Conditions', settings: 'Settings', }); @@ -223,6 +224,27 @@ const OverrideRuleTiles = ({

)} + {rule.certification && ( +

+ + {intl.formatMessage(messages.certification)} + +

+ {rule.certification.split(',').map((entry) => { + const [countryCode, certificationValue] = + entry.split(':'); + const countryName = + intl.formatDisplayName(countryCode, { + type: 'region', + fallback: 'none', + }) ?? countryCode; + return ( + {`${countryName}: ${certificationValue}`} + ); + })} +
+

+ )} {intl.formatMessage(messages.settings)} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index d30b6e1fdc..1c014551f6 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -619,11 +619,8 @@ "components.Search.search": "Search", "components.Search.searchresults": "Search Results", "components.Selector.CertificationSelector.errorLoading": "Failed to load certifications", - "components.Selector.CertificationSelector.maxRating": "Maximum rating", - "components.Selector.CertificationSelector.minRating": "Minimum rating", "components.Selector.CertificationSelector.noOptions": "No options available", "components.Selector.CertificationSelector.selectCertification": "Select a certification", - "components.Selector.CertificationSelector.selectCountry": "Select a country", "components.Selector.CertificationSelector.starttyping": "Starting typing to search.", "components.Selector.canceled": "Canceled", "components.Selector.ended": "Ended", @@ -814,6 +811,7 @@ "components.Settings.Notifications.webhookThreadIdTip": "The ID of the thread channel to post notifications in. Leave empty to post in the webhook channel", "components.Settings.Notifications.webhookUrl": "Webhook URL", "components.Settings.Notifications.webhookUrlTip": "Create a webhook integration in your server", + "components.Settings.OverrideRuleModal.certification": "Certification", "components.Settings.OverrideRuleModal.conditions": "Conditions", "components.Settings.OverrideRuleModal.conditionsDescription": "Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).", "components.Settings.OverrideRuleModal.create": "Create rule", @@ -837,6 +835,7 @@ "components.Settings.OverrideRuleModal.settingsDescription": "Specifies which settings will be changed when the above conditions are met.", "components.Settings.OverrideRuleModal.tags": "Tags", "components.Settings.OverrideRuleModal.users": "Users", + "components.Settings.OverrideRuleTile.certification": "Certification", "components.Settings.OverrideRuleTile.conditions": "Conditions", "components.Settings.OverrideRuleTile.genre": "Genre", "components.Settings.OverrideRuleTile.keywords": "Keywords", @@ -1334,7 +1333,7 @@ "components.Setup.librarieserror": "Unable to load libraries from your media server. Check that it is reachable and still configured correctly.", "components.Setup.servertype": "Choose Server Type", "components.Setup.setup": "Setup", - "components.Setup.signin": "Sign In", + "components.Setup.signin": "Sign in to your account", "components.Setup.signinMessage": "Get started by signing in", "components.Setup.signinWithEmby": "Enter your Emby details", "components.Setup.signinWithJellyfin": "Enter your Jellyfin details", From c592862f60b596f027a4c54b421424baead1b86a Mon Sep 17 00:00:00 2001 From: aussierk Date: Sat, 1 Aug 2026 10:09:31 -0400 Subject: [PATCH 3/4] refactor(override-rules): modify sorting of certification options and meaning display in ui modified the ui for certification option in override rules to sort by country then maturity rating. Moved meaning from display in-line to tool tip for better scrollability. feature #301 --- .../Selector/CertificationSelector.tsx | 88 ++++++++++++------- .../OverrideRule/OverrideRuleTiles.tsx | 19 ++-- 2 files changed, 68 insertions(+), 39 deletions(-) diff --git a/src/components/Selector/CertificationSelector.tsx b/src/components/Selector/CertificationSelector.tsx index 62d22240ba..80586eec99 100644 --- a/src/components/Selector/CertificationSelector.tsx +++ b/src/components/Selector/CertificationSelector.tsx @@ -1,6 +1,6 @@ import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; +import Tooltip from '@app/components/Common/Tooltip'; import defineMessages from '@app/utils/defineMessages'; -import type { Region } from '@server/lib/settings'; import React, { useCallback, useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import AsyncSelect from 'react-select/async'; @@ -21,6 +21,7 @@ interface CertificationResponse { interface CertificationOption { value: string; label: string; + meaning?: string; } interface CertificationSelectorProps { @@ -53,31 +54,39 @@ const CertificationSelector: React.FC = ({ isLoading: certificationLoading, } = useSWR(`/api/v1/certifications/${type}`); - const { data: regionsData } = useSWR('/api/v1/regions'); - // Get the country name from its code const getCountryName = useCallback( (countryCode: string): string => { - const region = regionsData?.find( - (region) => region.iso_3166_1 === countryCode - ); - return region?.name || countryCode; + const [base, subdivision] = countryCode.split('-'); + try { + const baseName = + intl.formatDisplayName(base, { type: 'region', fallback: 'none' }) ?? + base; + return subdivision ? `${baseName} (${subdivision})` : baseName; + } catch { + return countryCode; + } }, - [regionsData] + [intl] ); const allOptions = useCallback((): CertificationOption[] => { if (!certificationData) return []; - return Object.entries(certificationData.certifications).flatMap( - ([countryCode, certificationValue]) => + return Object.entries(certificationData.certifications) + .flatMap(([countryCode, certificationValue]) => certificationValue .filter((c) => c.certification) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .map((c) => ({ value: `${countryCode}:${c.certification}`, - label: `${getCountryName(countryCode)} - ${c.certification}${ - c.meaning ? ` (${c.meaning})` : '' - }`, + label: `${getCountryName(countryCode)} - ${c.certification}`, + meaning: c.meaning, })) - ); + ) + .sort((a, b) => + getCountryName(a.value.split(':')[0]).localeCompare( + getCountryName(b.value.split(':')[0]) + ) + ); }, [certificationData, getCountryName]); useEffect(() => { @@ -119,26 +128,37 @@ const CertificationSelector: React.FC = ({ }; return ( -
- - inputValue === '' - ? intl.formatMessage(messages.starttyping) - : intl.formatMessage(messages.noOptions) - } - /> -
+ + context === 'menu' && option.meaning ? ( + + {option.label} + + ) : ( + option.label + ) + } + placeholder={intl.formatMessage(messages.selectCertification)} + isClearable + noOptionsMessage={({ inputValue }) => + inputValue === '' + ? intl.formatMessage(messages.starttyping) + : intl.formatMessage(messages.noOptions) + } + /> ); }; export default CertificationSelector; diff --git a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx index c579ad5d32..4986d9735d 100644 --- a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx +++ b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx @@ -233,11 +233,20 @@ const OverrideRuleTiles = ({ {rule.certification.split(',').map((entry) => { const [countryCode, certificationValue] = entry.split(':'); - const countryName = - intl.formatDisplayName(countryCode, { - type: 'region', - fallback: 'none', - }) ?? countryCode; + const [base, subdivision] = countryCode.split('-'); + let countryName: string; + try { + const baseName = + intl.formatDisplayName(base, { + type: 'region', + fallback: 'none', + }) ?? base; + countryName = subdivision + ? `${baseName} (${subdivision})` + : baseName; + } catch { + countryName = countryCode; + } return ( {`${countryName}: ${certificationValue}`} ); From 84e1e309ca1cba60692bf6c977f8b0c76c661236 Mon Sep 17 00:00:00 2001 From: aussierk Date: Fri, 7 Aug 2026 09:50:33 -0400 Subject: [PATCH 4/4] fix(override-rules): add key to certification in overriderule tile and reset on service change Adressed codeRabbit findings by adding a key to the overrideruletiles for certification and reset the certification value if the service was changed while in the overriderule modal. feature #301 --- src/components/Settings/OverrideRule/OverrideRuleModal.tsx | 3 +++ src/components/Settings/OverrideRule/OverrideRuleTiles.tsx | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx index fd8978086f..399169d15d 100644 --- a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx +++ b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx @@ -258,6 +258,7 @@ const OverrideRuleModal = ({ if (e.target.value.startsWith('radarr-')) { setFieldValue('radarrServiceId', id); setFieldValue('sonarrServiceId', null); + setFieldValue('certification', null); const match = radarrServices.find( (s) => s.id === id ); @@ -267,6 +268,7 @@ const OverrideRuleModal = ({ } else if (e.target.value.startsWith('sonarr-')) { setFieldValue('radarrServiceId', null); setFieldValue('sonarrServiceId', id); + setFieldValue('certification', null); const match = sonarrServices.find( (s) => s.id === id ); @@ -276,6 +278,7 @@ const OverrideRuleModal = ({ } else { setFieldValue('radarrServiceId', null); setFieldValue('sonarrServiceId', null); + setFieldValue('certification', null); setIsValidated(false); } }} diff --git a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx index 4986d9735d..99d2a1c088 100644 --- a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx +++ b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx @@ -248,7 +248,9 @@ const OverrideRuleTiles = ({ countryName = countryCode; } return ( - {`${countryName}: ${certificationValue}`} + {`${countryName}: ${certificationValue}`} ); })}