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; diff --git a/src/components/Selector/CertificationSelector.tsx b/src/components/Selector/CertificationSelector.tsx index 671c97e5dc..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,29 +21,18 @@ interface CertificationResponse { interface CertificationOption { value: string; label: string; - certification?: string; + meaning?: 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,100 +40,68 @@ 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, 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] ); - - 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 (certificationGte) { - setSelectedCertificationGte( - certifications.find((c) => c.value === certificationGte) || null + const allOptions = useCallback((): CertificationOption[] => { + if (!certificationData) return []; + 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}`, + meaning: c.meaning, + })) + ) + .sort((a, b) => + getCountryName(a.value.split(':')[0]).localeCompare( + getCountryName(b.value.split(':')[0]) + ) ); - } + }, [certificationData, getCountryName]); - if (certificationLte) { - setSelectedCertificationLte( - certifications.find((c) => c.value === certificationLte) || null - ); + useEffect(() => { + if (!certification || !certificationData) { + setSelectedValues([]); + return; } - }, [ - 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,177 +114,51 @@ 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 ( -
- - inputValue === '' - ? intl.formatMessage(messages.starttyping) - : intl.formatMessage(messages.noOptions) - } - /> - - {certificationCountry && !showRange && ( - intl.formatMessage(messages.noOptions)} - /> - )} - - {certificationCountry && showRange && ( -
-
- intl.formatMessage(messages.noOptions)} - /> -
-
- 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/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..399169d15d 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()} @@ -253,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 ); @@ -262,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 ); @@ -271,6 +278,7 @@ const OverrideRuleModal = ({ } else { setFieldValue('radarrServiceId', null); setFieldValue('sonarrServiceId', null); + setFieldValue('certification', null); setIsValidated(false); } }} @@ -413,6 +421,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..99d2a1c088 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,38 @@ const OverrideRuleTiles = ({

)} + {rule.certification && ( +

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

+ {rule.certification.split(',').map((entry) => { + const [countryCode, certificationValue] = + entry.split(':'); + 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}`} + ); + })} +
+

+ )} {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",