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 = ({
)}
+
+
+ {intl.formatMessage(messages.certification)}
+
+
+
+ {
+ 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",