diff --git a/backend/app/compile/profile_compiler.py b/backend/app/compile/profile_compiler.py
index 71d8330..dc285d0 100644
--- a/backend/app/compile/profile_compiler.py
+++ b/backend/app/compile/profile_compiler.py
@@ -172,22 +172,39 @@ class ProfileConverter:
return converted_group
def convert_profile(self, profile: Dict) -> ConvertedProfile:
- language = profile.get('language')
- if language != 'any':
+ language = profile.get('language', 'any')
+
+ # Handle language processing for advanced mode (with behavior_language format)
+ if language != 'any' and '_' in language:
language_parts = language.split('_', 1)
- behaviour, language = language_parts
+ behaviour, language_code = language_parts
try:
language_formats = self._process_language_formats(
- behaviour, language)
+ behaviour, language_code)
if 'custom_formats' not in profile:
profile['custom_formats'] = []
profile['custom_formats'].extend(language_formats)
except Exception as e:
logger.error(f"Failed to process language formats: {e}")
- selected_language = ValueResolver.get_language('any',
- self.target_app,
- for_profile=True)
+ # Simple mode: just use the language directly without custom formats
+ # This lets the Arr application's built-in language filter handle it
+
+ # Get the appropriate language data for the profile
+ if language != 'any' and '_' not in language:
+ # Simple mode - use the language directly
+ selected_language = ValueResolver.get_language(language,
+ self.target_app,
+ for_profile=True)
+ logger.info(f"Using simple language mode: {language}")
+ logger.info(f"Selected language data: {selected_language}")
+ else:
+ # Advanced mode or 'any' - set language to 'any' as filtering is done via formats
+ selected_language = ValueResolver.get_language('any',
+ self.target_app,
+ for_profile=True)
+ logger.info(
+ f"Using advanced mode or 'any', setting language to 'any'")
converted_profile = ConvertedProfile(
name=profile["name"],
diff --git a/backend/app/importarr/profile.py b/backend/app/importarr/profile.py
index 128410f..cedc1e8 100644
--- a/backend/app/importarr/profile.py
+++ b/backend/app/importarr/profile.py
@@ -73,9 +73,19 @@ def import_profiles_to_arr(profile_names: List[str], original_names: List[str],
profile_language = profile_data.get('language', 'any')
if profile_language != 'any':
- logger.info(
- f"Profile '{profile_name}' has language override: {profile_language}"
- )
+ # Detect if we're using simple or advanced mode
+ is_simple_mode = '_' not in profile_language
+ if is_simple_mode:
+ logger.info(
+ f"Profile '{profile_name}' has simple mode language: {profile_language}"
+ )
+ logger.info(
+ f"Simple mode will set language filter to: {profile_language}"
+ )
+ else:
+ logger.info(
+ f"Profile '{profile_name}' has advanced mode language: {profile_language}"
+ )
logger.info("Compiling quality profile...")
compiled_profiles = compile_quality_profile(
diff --git a/frontend/src/components/format/FormatPage.jsx b/frontend/src/components/format/FormatPage.jsx
index 55966ec..1c8d2b7 100644
--- a/frontend/src/components/format/FormatPage.jsx
+++ b/frontend/src/components/format/FormatPage.jsx
@@ -106,7 +106,7 @@ function FormatPage() {
lastSelectedIndex
} = useMassSelection();
- useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true});
+ useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true});
const {
name,
diff --git a/frontend/src/components/profile/ProfileCard.jsx b/frontend/src/components/profile/ProfileCard.jsx
index 56c59e4..bb23d9b 100644
--- a/frontend/src/components/profile/ProfileCard.jsx
+++ b/frontend/src/components/profile/ProfileCard.jsx
@@ -10,6 +10,7 @@ import {
} from 'lucide-react';
import Tooltip from '@ui/Tooltip';
import ReactMarkdown from 'react-markdown';
+import { LANGUAGES } from '@constants/languages';
function unsanitize(text) {
if (!text) return '';
@@ -17,19 +18,38 @@ function unsanitize(text) {
}
function parseLanguage(languageStr) {
- if (!languageStr || languageStr === 'any') return 'Any';
-
+ // Handle empty or "any" case
+ if (!languageStr || languageStr === 'any') return 'Any Language';
+
+ // Handle "original" language case
+ if (languageStr === 'original') return 'Original';
+
+ // Check if this is a simple language choice (not in format of type_language)
+ const matchedLanguage = LANGUAGES.find(lang => lang.id === languageStr);
+ if (matchedLanguage) {
+ return matchedLanguage.name;
+ }
+
+ // If we get here, it's an advanced language setting with type_language format
const [type, language] = languageStr.split('_');
- const capitalizedLanguage =
- language.charAt(0).toUpperCase() + language.slice(1);
+
+ // If language part is missing, just return the type
+ if (!language) return type;
+
+ // Find language name from constants
+ const langObj = LANGUAGES.find(lang => lang.id === language);
+ const languageName = langObj ? langObj.name : language.charAt(0).toUpperCase() + language.slice(1);
+ // Format based on type
switch (type) {
case 'only':
- return `Must Only Be: ${capitalizedLanguage}`;
+ return `Must Only Be: ${languageName}`;
case 'must':
- return `Must Include: ${capitalizedLanguage}`;
+ return `Must Include: ${languageName}`;
+ case 'mustnot':
+ return `Must Not Include: ${languageName}`;
default:
- return capitalizedLanguage;
+ return languageName;
}
}
diff --git a/frontend/src/components/profile/ProfileLangaugesTab.jsx b/frontend/src/components/profile/ProfileLangaugesTab.jsx
deleted file mode 100644
index a1ef56b..0000000
--- a/frontend/src/components/profile/ProfileLangaugesTab.jsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import {InfoIcon, AlertTriangle} from 'lucide-react';
-import {LANGUAGES} from '@constants/languages';
-
-const ProfileLanguagesTab = ({language, onLanguageChange}) => {
- const handleLanguageChange = (type, value) => {
- // If selecting 'any' behavior, just return 'any'
- if (type === 'behavior' && value === 'any') {
- onLanguageChange('any');
- return;
- }
-
- // For other cases, split current language setting
- const [behavior, lang] = (language || 'must_english').split('_');
-
- const newValue =
- type === 'behavior'
- ? `${value}_${lang || 'english'}`
- : `${behavior || 'must'}_${value}`;
-
- onLanguageChange(newValue);
- };
-
- // Split current language setting only if it's not 'any'
- const [currentBehavior, currentLanguage] =
- language === 'any'
- ? ['any', '']
- : (language || 'must_english').split('_');
-
- return (
-
-
-
-
- Language Requirements
-
-
- Configure language requirements for media content.
-
-
-
-
-
-
-
-
- Configure how languages should be handled for your media
- content. Select "Any" to accept all languages, or
- configure specific language requirements.
-
-
-
-
-
-
-
- Language Settings
-
-
- Configure language requirements for releases
-
-
-
-
-
-
- {currentBehavior !== 'any' && (
-
- )}
-
-
- {currentBehavior === 'only' && (
-
-
-
- "Must Only Be" will reject releases with
- multiple languages
-
-
- )}
-
-
-
-
- );
-};
-
-ProfileLanguagesTab.propTypes = {
- language: PropTypes.string.isRequired,
- onLanguageChange: PropTypes.func.isRequired
-};
-
-export default ProfileLanguagesTab;
diff --git a/frontend/src/components/profile/ProfileModal.jsx b/frontend/src/components/profile/ProfileModal.jsx
index 9ca4099..0d6c680 100644
--- a/frontend/src/components/profile/ProfileModal.jsx
+++ b/frontend/src/components/profile/ProfileModal.jsx
@@ -3,11 +3,11 @@ import PropTypes from 'prop-types';
import {Profiles} from '@api/data';
import Modal from '../ui/Modal';
import Alert from '@ui/Alert';
-import {Loader} from 'lucide-react';
+import {Loader, Save, Trash2, Check} from 'lucide-react';
import ProfileGeneralTab from './ProfileGeneralTab';
import ProfileScoringTab from './scoring/ProfileScoringTab';
-import ProfileQualitiesTab from './ProfileQualitiesTab';
-import ProfileLangaugesTab from './ProfileLangaugesTab';
+import ProfileQualitiesTab from './quality/ProfileQualitiesTab';
+import ProfileLangaugesTab from './language/ProfileLangaugesTab';
import QUALITIES from '../../constants/qualities';
function unsanitize(text) {
@@ -28,6 +28,7 @@ function ProfileModal({
const [description, setDescription] = useState('');
const [error, setError] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [modalTitle, setModalTitle] = useState('');
@@ -116,6 +117,8 @@ function ProfileModal({
useEffect(() => {
if (isOpen) {
setLoading(true);
+ setIsDeleting(false);
+ setIsSaving(false);
setModalTitle(
isCloning
@@ -322,46 +325,93 @@ function ProfileModal({
}, [initialProfile, isOpen, formats, isCloning]);
const handleSave = async () => {
- if (!name.trim()) {
- setError('Name is required.');
- Alert.error('Please enter a profile name');
- return;
- }
+ if (isSaving) {
+ // This is the confirmation click
+ if (!name.trim()) {
+ setError('Name is required.');
+ Alert.error('Please enter a profile name');
+ setIsSaving(false);
+ return;
+ }
- try {
- const profileData = {
- name,
- description,
- tags,
- upgradesAllowed,
- minCustomFormatScore,
- upgradeUntilScore,
- minScoreIncrement,
- custom_formats: (() => {
- // Check if selective mode is enabled
- const selectiveMode = localStorage.getItem('formatSettingsSelectiveMode');
- const useSelectiveMode = selectiveMode !== null && JSON.parse(selectiveMode);
-
- if (useSelectiveMode) {
- // In selective mode, save both:
- // 1. Formats with non-zero scores as usual
- // 2. Formats with zero score that have been explicitly selected in selectedFormatIds
-
- try {
- // Get the list of explicitly selected format IDs
- const selectedFormatIdsStr = localStorage.getItem('selectedFormatIds');
- const selectedFormatIds = selectedFormatIdsStr ? JSON.parse(selectedFormatIdsStr) : [];
-
- // Get formats with non-zero scores
- const nonZeroFormats = customFormats.filter(format => format.score !== 0);
-
- // Get formats with zero scores that are explicitly selected
- const explicitlySelectedZeroFormats = customFormats.filter(format =>
- format.score === 0 && selectedFormatIds.includes(format.id)
- );
-
- // Combine both lists
- return [...nonZeroFormats, ...explicitlySelectedZeroFormats]
+ try {
+ const profileData = {
+ name,
+ description,
+ tags,
+ upgradesAllowed,
+ minCustomFormatScore,
+ upgradeUntilScore,
+ minScoreIncrement,
+ custom_formats: (() => {
+ // Check if selective mode is enabled
+ const selectiveMode = localStorage.getItem(
+ 'formatSettingsSelectiveMode'
+ );
+ const useSelectiveMode =
+ selectiveMode !== null && JSON.parse(selectiveMode);
+
+ if (useSelectiveMode) {
+ // In selective mode, save both:
+ // 1. Formats with non-zero scores as usual
+ // 2. Formats with zero score that have been explicitly selected in selectedFormatIds
+
+ try {
+ // Get the list of explicitly selected format IDs
+ const selectedFormatIdsStr =
+ localStorage.getItem('selectedFormatIds');
+ const selectedFormatIds = selectedFormatIdsStr
+ ? JSON.parse(selectedFormatIdsStr)
+ : [];
+
+ // Get formats with non-zero scores
+ const nonZeroFormats = customFormats.filter(
+ format => format.score !== 0
+ );
+
+ // Get formats with zero scores that are explicitly selected
+ const explicitlySelectedZeroFormats =
+ customFormats.filter(
+ format =>
+ format.score === 0 &&
+ selectedFormatIds.includes(format.id)
+ );
+
+ // Combine both lists
+ return [
+ ...nonZeroFormats,
+ ...explicitlySelectedZeroFormats
+ ]
+ .sort((a, b) => {
+ // First sort by score (descending)
+ if (b.score !== a.score) {
+ return b.score - a.score;
+ }
+ // Then alphabetically for equal scores
+ return a.name.localeCompare(b.name);
+ })
+ .map(format => ({
+ name: format.name,
+ score: format.score
+ }));
+ } catch (e) {
+ // If there's any error parsing the selectedFormatIds, fall back to just non-zero scores
+ return customFormats
+ .filter(format => format.score !== 0)
+ .sort((a, b) => {
+ if (b.score !== a.score)
+ return b.score - a.score;
+ return a.name.localeCompare(b.name);
+ })
+ .map(format => ({
+ name: format.name,
+ score: format.score
+ }));
+ }
+ } else {
+ // Standard behavior - only include formats with non-zero scores
+ return customFormats
+ .filter(format => format.score !== 0)
.sort((a, b) => {
// First sort by score (descending)
if (b.score !== a.score) {
@@ -374,93 +424,69 @@ function ProfileModal({
name: format.name,
score: format.score
}));
- } catch (e) {
- // If there's any error parsing the selectedFormatIds, fall back to just non-zero scores
- return customFormats
- .filter(format => format.score !== 0)
- .sort((a, b) => {
- if (b.score !== a.score) return b.score - a.score;
- return a.name.localeCompare(b.name);
- })
- .map(format => ({
- name: format.name,
- score: format.score
- }));
}
- } else {
- // Standard behavior - only include formats with non-zero scores
- return customFormats
- .filter(format => format.score !== 0)
- .sort((a, b) => {
- // First sort by score (descending)
- if (b.score !== a.score) {
- return b.score - a.score;
- }
- // Then alphabetically for equal scores
- return a.name.localeCompare(b.name);
- })
- .map(format => ({
- name: format.name,
- score: format.score
- }));
- }
- })(),
- qualities: sortedQualities
- .filter(q => q.enabled)
- .map(q => {
- if ('qualities' in q) {
- return {
- id: q.id,
- name: q.name,
- description: q.description || '',
- qualities: q.qualities.map(subQ => ({
- id: subQ.id,
- name: subQ.name
- }))
- };
- } else {
- return {
- id: q.id,
- name: q.name
- };
- }
- }),
- upgrade_until: selectedUpgradeQuality
- ? {
- id: selectedUpgradeQuality.id,
- name: selectedUpgradeQuality.name,
- ...(selectedUpgradeQuality.description && {
- description: selectedUpgradeQuality.description
- })
- }
- : null,
- language
- };
+ })(),
+ qualities: sortedQualities
+ .filter(q => q.enabled)
+ .map(q => {
+ if ('qualities' in q) {
+ return {
+ id: q.id,
+ name: q.name,
+ description: q.description || '',
+ qualities: q.qualities.map(subQ => ({
+ id: subQ.id,
+ name: subQ.name
+ }))
+ };
+ } else {
+ return {
+ id: q.id,
+ name: q.name
+ };
+ }
+ }),
+ upgrade_until: selectedUpgradeQuality
+ ? {
+ id: selectedUpgradeQuality.id,
+ name: selectedUpgradeQuality.name,
+ ...(selectedUpgradeQuality.description && {
+ description: selectedUpgradeQuality.description
+ })
+ }
+ : null,
+ language
+ };
- if (isCloning || !initialProfile) {
- // Creating new profile
- await Profiles.create(profileData);
- Alert.success('Profile created successfully');
- } else {
- // Updating existing profile
- const originalName = initialProfile.content.name;
- const isNameChanged = originalName !== name;
- await Profiles.update(
- initialProfile.file_name.replace('.yml', ''),
- profileData,
- isNameChanged ? name : undefined
- );
- Alert.success('Profile updated successfully');
+ if (isCloning || !initialProfile) {
+ // Creating new profile
+ await Profiles.create(profileData);
+ Alert.success('Profile created successfully');
+ } else {
+ // Updating existing profile
+ const originalName = initialProfile.content.name;
+ const isNameChanged = originalName !== name;
+ await Profiles.update(
+ initialProfile.file_name.replace('.yml', ''),
+ profileData,
+ isNameChanged ? name : undefined
+ );
+ Alert.success('Profile updated successfully');
+ }
+
+ onSave();
+ onClose();
+ } catch (error) {
+ console.error('Error saving profile:', error);
+ const errorMessage =
+ error.message || 'An unexpected error occurred';
+ Alert.error(errorMessage);
+ setError(errorMessage);
+ setIsSaving(false);
}
-
- onSave();
- onClose();
- } catch (error) {
- console.error('Error saving profile:', error);
- const errorMessage =
- error.message || 'An unexpected error occurred';
- Alert.error(errorMessage);
- setError(errorMessage);
+ } else {
+ // First click - show confirmation
+ setIsSaving(true);
}
};
@@ -509,16 +535,24 @@ function ProfileModal({
{initialProfile && (
)}
}>
diff --git a/frontend/src/components/profile/ProfilePage.jsx b/frontend/src/components/profile/ProfilePage.jsx
index 1376ef0..8ff6b29 100644
--- a/frontend/src/components/profile/ProfilePage.jsx
+++ b/frontend/src/components/profile/ProfilePage.jsx
@@ -111,7 +111,7 @@ function ProfilePage() {
lastSelectedIndex
} = useMassSelection();
- useKeyboardShortcut('a', toggleSelectionMode, {ctrl: true});
+ useKeyboardShortcut('m', toggleSelectionMode, {ctrl: true});
useEffect(() => {
fetchGitStatus();
@@ -242,9 +242,10 @@ function ProfilePage() {
if (isSelectionMode) return;
const clonedProfile = {
...profile,
- id: 0,
- name: `${profile.name} [COPY]`,
- custom_formats: profile.custom_formats || []
+ content: {
+ ...profile.content,
+ name: `${profile.content.name} [COPY]`
+ }
};
setSelectedProfile(clonedProfile);
setIsModalOpen(true);
diff --git a/frontend/src/components/profile/QualityItem.jsx b/frontend/src/components/profile/QualityItem.jsx
deleted file mode 100644
index 078099f..0000000
--- a/frontend/src/components/profile/QualityItem.jsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import React from 'react';
-import RadarrLogo from '@logo/Radarr.svg';
-import SonarrLogo from '@logo/Sonarr.svg';
-import {Pencil, Trash2} from 'lucide-react';
-
-const QualityItem = ({
- quality,
- isDragging,
- listeners,
- attributes,
- style,
- onDelete,
- onEdit
-}) => {
- const isGroup = 'qualities' in quality;
-
- return (
-
- );
-};
-
-export default QualityItem;
diff --git a/frontend/src/components/profile/language/AdvancedView.jsx b/frontend/src/components/profile/language/AdvancedView.jsx
new file mode 100644
index 0000000..89835fa
--- /dev/null
+++ b/frontend/src/components/profile/language/AdvancedView.jsx
@@ -0,0 +1,133 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {InfoIcon, AlertTriangle} from 'lucide-react';
+import {LANGUAGES} from '@constants/languages';
+
+const AdvancedView = ({language, onLanguageChange}) => {
+ const handleLanguageChange = (type, value) => {
+ // If selecting 'any' behavior, just return 'any'
+ if (type === 'behavior' && value === 'any') {
+ onLanguageChange('any');
+ return;
+ }
+
+ // For other cases, split current language setting
+ const [behavior, lang] = (language || 'must_english').split('_');
+
+ const newValue =
+ type === 'behavior'
+ ? `${value}_${lang || 'english'}`
+ : `${behavior || 'must'}_${value}`;
+
+ onLanguageChange(newValue);
+ };
+
+ // Split current language setting only if it's not 'any'
+ const [currentBehavior, currentLanguage] =
+ language === 'any'
+ ? ['any', '']
+ : (language || 'must_english').split('_');
+
+ return (
+ <>
+ {/* Type Dropdown */}
+
+
+ {/* Language Dropdown */}
+ {currentBehavior !== 'any' && (
+
+ )}
+
+ {/* Help text below the controls is in the parent component */}
+
+
+ {currentBehavior === 'any' && (
+
+
+
Accept content in any language.
+
+ )}
+
+ {currentBehavior === 'must' && (
+
+
+
+ Content must include{' '}
+ {currentLanguage
+ ? LANGUAGES.find(
+ l => l.id === currentLanguage
+ )?.name || currentLanguage
+ : 'English'}
+ , but can include other languages as well.
+
+
+ )}
+
+ {currentBehavior === 'only' && (
+
+
+
+ Content must ONLY be in{' '}
+ {currentLanguage
+ ? LANGUAGES.find(
+ l => l.id === currentLanguage
+ )?.name || currentLanguage
+ : 'English'}
+ . This will reject releases containing
+ multiple languages.
+
+
+ )}
+
+ {currentBehavior === 'mustnot' && (
+
+
+
+ Content must NOT include{' '}
+ {currentLanguage
+ ? LANGUAGES.find(
+ l => l.id === currentLanguage
+ )?.name || currentLanguage
+ : 'English'}
+ . Any other language is acceptable.
+
+
+ )}
+
+
+
+ );
+};
+
+AdvancedView.propTypes = {
+ language: PropTypes.string.isRequired,
+ onLanguageChange: PropTypes.func.isRequired
+};
+
+export default AdvancedView;
diff --git a/frontend/src/components/profile/language/ProfileLangaugesTab.jsx b/frontend/src/components/profile/language/ProfileLangaugesTab.jsx
new file mode 100644
index 0000000..f0008b3
--- /dev/null
+++ b/frontend/src/components/profile/language/ProfileLangaugesTab.jsx
@@ -0,0 +1,447 @@
+import React, {useState, useEffect, useMemo} from 'react';
+import PropTypes from 'prop-types';
+import {
+ Settings,
+ List,
+ ChevronDown,
+ InfoIcon,
+ AlertTriangle
+} from 'lucide-react';
+import {LANGUAGES} from '@constants/languages';
+import SearchDropdown from '@ui/SearchDropdown';
+
+const ProfileLanguagesTab = ({language, onLanguageChange}) => {
+ // Determine advanced view based on language format
+ const [isAdvancedView, setIsAdvancedView] = useState(() => {
+ // If language includes an underscore (e.g., must_english) or doesn't exist, it's advanced mode
+ // If it's a simple language ID without underscore (e.g., english, original, any), it's simple mode
+ return !language || language.includes('_');
+ });
+
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+
+ // Update mode whenever language changes externally
+ useEffect(() => {
+ // If no language provided, set a default value for new profiles
+ if (!language) {
+ // Default for new profiles: Advanced mode with "must include original"
+ onLanguageChange('must_original');
+ } else {
+ // Otherwise determine mode from language format
+ setIsAdvancedView(!language || language.includes('_'));
+ }
+ }, [language, onLanguageChange]);
+
+ // For simple view - language options
+ const languageOptions = useMemo(() => {
+ return [
+ // Any language at the very top - special item
+ {
+ value: 'any',
+ label: 'Any Language',
+ description: 'Accepting content in any language',
+ isSpecial: true
+ },
+ // Original language next - special item
+ {
+ value: 'original',
+ label: 'Original',
+ description:
+ 'Content must include Original, but can include other languages as well',
+ isSpecial: true
+ },
+ // All other languages - sorted alphabetically
+ ...LANGUAGES.filter(lang => lang.id !== 'original') // Skip original since we added it manually above
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map(lang => ({
+ value: lang.id,
+ label: lang.name,
+ description: `Content must include ${lang.name}, but can include other languages as well`
+ }))
+ ];
+ }, []);
+
+ // For advanced view - handle language changes
+ const handleAdvancedLanguageChange = (type, value) => {
+ // If selecting 'any' behavior, just return 'any'
+ if (type === 'behavior' && value === 'any') {
+ onLanguageChange('any');
+ return;
+ }
+
+ // For other cases, split current language setting
+ const [behavior, lang] = (language || 'must_english').split('_');
+
+ const newValue =
+ type === 'behavior'
+ ? `${value}_${lang || 'english'}`
+ : `${behavior || 'must'}_${value}`;
+
+ onLanguageChange(newValue);
+ };
+
+ // Split current language setting only if it's not 'any'
+ const [currentBehavior, currentLanguage] =
+ language === 'any'
+ ? ['any', '']
+ : (language || 'must_english').split('_');
+
+ return (
+
+
+ {/* Simple header with title and description */}
+
+
+ Language Settings
+
+
+ Configure how language preferences are applied to your
+ profiles.
+ {isAdvancedView
+ ? ' Advanced mode creates custom formats for precise language control in both Radarr and Sonarr. This is required for Sonarr as it lacks built-in language settings.'
+ : ' Simple mode sets language preferences directly in Radarr without custom formats. For Sonarr, consider using Advanced mode since it has no built-in language filtering.'}
+
+
+
+ {/* Controls row - display mode dropdown with other controls */}
+
+ {/* Mode Selector (always visible) */}
+
+
+ {isDropdownOpen && (
+ <>
+
setIsDropdownOpen(false)}
+ />
+
+
+
+
+
+
+ >
+ )}
+
+
+ {/* SIMPLE MODE: just one language dropdown */}
+ {!isAdvancedView && (
+
+
+ handleAdvancedLanguageChange(
+ 'language',
+ e.target.value
+ )
+ }
+ options={[
+ // Special items at the top
+ {
+ value: 'original',
+ label: 'Original',
+ description:
+ 'Content must include Original, but can include other languages as well',
+ isSpecial: true
+ },
+ // All other languages - sorted alphabetically
+ ...LANGUAGES.filter(
+ lang => lang.id !== 'original'
+ ) // Skip original since we added it manually above
+ .sort((a, b) =>
+ a.name.localeCompare(b.name)
+ )
+ .map(lang => ({
+ value: lang.id,
+ label: lang.name,
+ description: `Content must include ${lang.name}, but can include other languages as well`
+ }))
+ ]}
+ placeholder='Select language...'
+ dropdownWidth='100%'
+ className='bg-gray-800 dark:border-gray-600 text-gray-100'
+ />
+
+ )}
+ >
+ )}
+
+
+ {/* Help text section - display the appropriate help text based on view mode and selection */}
+
+ {language === 'any' ? (
+ <>
+ Attempts to set{' '}
+
+ Any Language
+ {' '}
+ in Radarr profiles. For Sonarr, language
+ will default to "Original" since it
+ lacks native language settings.
+ >
+ ) : language === 'original' ? (
+ <>
+ Attempts to set{' '}
+
+ Original
+ {' '}
+ language in Radarr profiles. For Sonarr,
+ language will default to "Original"
+ since it lacks native language settings.
+ >
+ ) : (
+ <>
+ Attempts to set{' '}
+
+ {LANGUAGES.find(
+ l => l.id === language
+ )?.name || language}
+ {' '}
+ language in Radarr profiles. For Sonarr,
+ language will default to "Original"
+ since it lacks native language settings.
+ >
+ )}
+
+
+ )}
+
+ {/* Advanced mode help based on selections */}
+ {isAdvancedView && (
+ <>
+ {language === 'any' && (
+
+ Content must include{' '}
+
+ {language.split('_')[1]
+ ? LANGUAGES.find(
+ l =>
+ l.id ===
+ language.split('_')[1]
+ )?.name ||
+ language.split('_')[1]
+ : 'English'}
+
+ , but can include other languages as
+ well.
+
);
@@ -217,6 +186,19 @@ const ProfileQualitiesTab = ({
const handleQualityToggle = quality => {
if (!activeId) {
+ // Prevent disabling a quality that's set as the upgrade until quality
+ if (
+ quality.enabled &&
+ upgradesAllowed &&
+ selectedUpgradeQuality &&
+ isUpgradeUntilQuality(quality)
+ ) {
+ Alert.error(
+ "You can't disable a quality that's set as 'upgrade until'. Please set another quality as 'upgrade until' first."
+ );
+ return;
+ }
+
const currentEnabledCount = sortedQualities.filter(
q => q.enabled
).length;
@@ -252,34 +234,69 @@ const ProfileQualitiesTab = ({
onQualitiesChange(allEnabledQualities);
- // Only update the upgrade quality if we're disabling the current upgrade quality
+ // We shouldn't reach this point for the upgrade until quality,
+ // but keeping as a safety measure
if (
+ upgradesAllowed &&
selectedUpgradeQuality &&
quality.enabled === false && // We're disabling a quality
- (quality.id === selectedUpgradeQuality.id || // Direct match
- ('qualities' in quality && // Group match
+ (quality.id === selectedUpgradeQuality.id || // Direct match (group or quality)
+ ('qualities' in quality && // Quality is in a group that's being disabled
+ !('qualities' in selectedUpgradeQuality) &&
quality.qualities.some(
q => q.id === selectedUpgradeQuality.id
)))
) {
+ // Find another enabled quality to set as upgrade until
const nearestQuality = findNearestEnabledQuality(
newQualities,
quality.id
);
- onSelectedUpgradeQualityChange?.(nearestQuality);
+
+ if (nearestQuality) {
+ onSelectedUpgradeQualityChange?.(nearestQuality);
+ Alert.info(
+ `Upgrade until quality changed to ${nearestQuality.name}`
+ );
+ }
}
}
};
+ const handleUpgradeUntilClick = quality => {
+ // Make sure we're setting the quality object properly
+ if (quality) {
+ // For single qualities, pass as is
+ // For groups, we pass the group itself to maintain the group ID in the selection
+ onSelectedUpgradeQualityChange?.(quality);
+
+ // Provide user feedback
+ Alert.success(`${quality.name} set as upgrade until quality`);
+ }
+ };
+
const handleCreateOrUpdateGroup = groupData => {
- if (
- selectedUpgradeQuality &&
- !('qualities' in selectedUpgradeQuality)
- ) {
- const qualityMovingToGroup = groupData.qualities.some(
- q => q.id === selectedUpgradeQuality.id
- );
- if (qualityMovingToGroup) {
+ // Check if the currently selected upgrade quality is being moved into this group
+ if (selectedUpgradeQuality) {
+ // If the selected upgrade quality is a single quality (not a group itself)
+ if (!('qualities' in selectedUpgradeQuality)) {
+ const qualityMovingToGroup = groupData.qualities.some(
+ q => q.id === selectedUpgradeQuality.id
+ );
+
+ // If the current upgrade quality is being moved into this group
+ // Update the upgrade quality to be the group instead
+ if (qualityMovingToGroup) {
+ onSelectedUpgradeQualityChange({
+ id: groupData.id,
+ name: groupData.name,
+ description: groupData.description
+ });
+ }
+ }
+ // If the selected upgrade quality is the group we're editing
+ else if (selectedUpgradeQuality.id === editingGroup?.id) {
+ // Update the upgrade quality to reflect the new group data
onSelectedUpgradeQualityChange({
id: groupData.id,
name: groupData.name,
@@ -371,15 +388,39 @@ const ProfileQualitiesTab = ({
const handleDeleteGroup = group => {
// Check if we're deleting the currently selected upgrade group
if (selectedUpgradeQuality && selectedUpgradeQuality.id === group.id) {
- const firstQualityFromGroup = group.qualities[0];
- onSelectedUpgradeQualityChange(firstQualityFromGroup);
+ // Find the first quality from the group and set it as the upgrade until quality
+ if (group.qualities && group.qualities.length > 0) {
+ const firstQualityFromGroup = group.qualities[0];
+ onSelectedUpgradeQualityChange(firstQualityFromGroup);
+ Alert.info(
+ `Upgrade until quality changed to ${firstQualityFromGroup.name}`
+ );
+ } else {
+ // If somehow the group has no qualities, find the first enabled quality
+ const firstEnabledQuality = sortedQualities.find(
+ q => q.enabled && q.id !== group.id
+ );
+ if (firstEnabledQuality) {
+ onSelectedUpgradeQualityChange(firstEnabledQuality);
+ Alert.info(
+ `Upgrade until quality changed to ${firstEnabledQuality.name}`
+ );
+ }
+ }
}
onSortedQualitiesChange(prev => {
const index = prev.findIndex(q => q.id === group.id);
if (index === -1) return prev;
const newQualities = [...prev];
- newQualities.splice(index, 1, ...group.qualities);
+
+ // Make sure all qualities from the group are set as enabled
+ const enabledGroupQualities = group.qualities.map(q => ({
+ ...q,
+ enabled: true
+ }));
+
+ newQualities.splice(index, 1, ...enabledGroupQualities);
return newQualities;
});
};
@@ -410,75 +451,146 @@ const ProfileQualitiesTab = ({
setActiveId(null);
};
+ const isUpgradeUntilQuality = quality => {
+ if (!selectedUpgradeQuality) return false;
+
+ // Direct ID match (works for both individual qualities and groups)
+ if (quality.id === selectedUpgradeQuality.id) {
+ return true;
+ }
+
+ // Check if the selected upgrade quality is a member of this group
+ if (
+ 'qualities' in quality &&
+ !('qualities' in selectedUpgradeQuality) &&
+ quality.qualities.some(q => q.id === selectedUpgradeQuality.id)
+ ) {
+ return true;
+ }
+
+ return false;
+ };
+
return (
-
-
-
+
+
+
Quality Rankings
-
-
- Qualities higher in the list are more preferred even if
- not checked. Qualities within the same group are equal.
- Only checked qualities are wanted.
-
+
+ {/* Upgrade Until button - only shown when enabled and upgrade is allowed */}
+ {quality.enabled && onUpgradeUntilClick && (
+
+
+
+ )}
+
+ {/* Selected indicator - shows all three states */}
+