From f91fea113f00ed674f064957ee18c6cc7e0d2d10 Mon Sep 17 00:00:00 2001 From: santiagosayshey Date: Sun, 23 Feb 2025 04:12:10 +1030 Subject: [PATCH] fix/improve: arr config modal (#146) - Fix: remove / update dangling references on data delete / rename in arr config. - Fix: Show dangling references in data selector so they can be removed after git operation (cascade operations not possible). - Fix: improve onclick behaviour so that button clicks in arr modal don't incorrectly trigger save / update. - Refactor: Turn data selector into a non modal component - now exists on the same pane as the arr modal. - Feat: Add search / sort functionality to data selector. Sorted A-Z by default now. --- backend/app/data/utils.py | 25 +- backend/app/db/__init__.py | 11 +- backend/app/db/queries/arr.py | 100 ++++++- .../src/components/settings/arrs/ArrModal.jsx | 198 ++++++-------- .../components/settings/arrs/DataSelector.jsx | 250 ++++++++++++++++++ .../settings/arrs/DataSelectorModal.jsx | 139 ---------- .../src/components/ui/DataBar/SearchBar.jsx | 104 ++++---- frontend/src/components/ui/SortDropdown.jsx | 42 +-- 8 files changed, 525 insertions(+), 344 deletions(-) create mode 100644 frontend/src/components/settings/arrs/DataSelector.jsx delete mode 100644 frontend/src/components/settings/arrs/DataSelectorModal.jsx diff --git a/backend/app/data/utils.py b/backend/app/data/utils.py index 819a69f..884a664 100644 --- a/backend/app/data/utils.py +++ b/backend/app/data/utils.py @@ -7,6 +7,7 @@ from typing import Dict, List, Any, Tuple, Union import git import regex import logging +from ..db.queries.arr import update_arr_config_on_rename, update_arr_config_on_delete logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -165,8 +166,20 @@ def update_yaml_file(file_path: str, data: Dict[str, Any], # Update references before performing the rename try: + # Update regular references updated_files = update_references(category, old_name, new_name) logger.info(f"Updated references in: {updated_files}") + + # Update arr configs if this is a format or profile + if category in ['custom_format', 'profile']: + arr_category = 'customFormats' if category == 'custom_format' else 'profiles' + updated_configs = update_arr_config_on_rename( + arr_category, old_name, new_name) + if updated_configs: + logger.info( + f"Updated arr configs for {category} rename: {updated_configs}" + ) + except Exception as e: logger.error(f"Failed to update references: {e}") raise Exception(f"Failed to update references: {str(e)}") @@ -262,9 +275,9 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]: format_data = load_yaml_file(format_path) # Check each condition in the format for condition in format_data.get('conditions', []): - if (condition['type'] in [ + if condition['type'] in [ 'release_title', 'release_group', 'edition' - ] and condition.get('pattern') == check_name): + ] and condition.get('pattern') == check_name: references.append( f"custom format: {format_data['name']}") except Exception as e: @@ -299,6 +312,14 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]: f"Error checking profile file {profile_file}: {e}") continue + # Update arr configs for formats and profiles + if category in ['custom_format', 'profile']: + arr_category = 'customFormats' if category == 'custom_format' else 'profiles' + updated_configs = update_arr_config_on_delete(arr_category, name) + if updated_configs: + logger.info( + f"Removed {name} from arr configs: {updated_configs}") + if references: error_msg = f"Cannot delete - item is referenced in:\n" + "\n".join( f"- {ref}" for ref in references) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py index a8b4f80..3bc49f6 100644 --- a/backend/app/db/__init__.py +++ b/backend/app/db/__init__.py @@ -1,12 +1,15 @@ -# backend/app/db/__init__.py from .connection import get_db from .queries.settings import get_settings, get_secret_key, save_settings -from .queries.arr import get_unique_arrs -from .queries.format_renames import add_format_to_renames, remove_format_from_renames, is_format_in_renames +from .queries.arr import (get_unique_arrs, update_arr_config_on_rename, + update_arr_config_on_delete) +from .queries.format_renames import (add_format_to_renames, + remove_format_from_renames, + is_format_in_renames) from .migrations.runner import run_migrations __all__ = [ 'get_db', 'get_settings', 'get_secret_key', 'save_settings', - 'get_unique_arrs', 'run_migrations', 'add_format_to_renames', + 'get_unique_arrs', 'update_arr_config_on_rename', + 'update_arr_config_on_delete', 'run_migrations', 'add_format_to_renames', 'remove_format_from_renames', 'is_format_in_renames' ] diff --git a/backend/app/db/queries/arr.py b/backend/app/db/queries/arr.py index 5b62c1e..592bc02 100644 --- a/backend/app/db/queries/arr.py +++ b/backend/app/db/queries/arr.py @@ -1,14 +1,15 @@ -# backend/app/db/queries/arr.py from ..connection import get_db +import json +import logging + +logger = logging.getLogger(__name__) def get_unique_arrs(arr_ids): """ Get import_as_unique settings for a list of arr IDs. - Args: arr_ids (list): List of arr configuration IDs - Returns: dict: Dictionary mapping arr IDs to their import_as_unique settings and names """ @@ -18,12 +19,12 @@ def get_unique_arrs(arr_ids): with get_db() as conn: placeholders = ','.join('?' * len(arr_ids)) query = f''' - SELECT id, name, import_as_unique - FROM arr_config - WHERE id IN ({placeholders}) + SELECT id, name, import_as_unique + FROM arr_config + WHERE id IN ({placeholders}) ''' - results = conn.execute(query, arr_ids).fetchall() + return { row['id']: { 'import_as_unique': bool(row['import_as_unique']), @@ -31,3 +32,88 @@ def get_unique_arrs(arr_ids): } for row in results } + + +def update_arr_config_on_rename(category, old_name, new_name): + """ + Update arr_config data_to_sync when a format or profile is renamed. + Args: + category (str): Either 'customFormats' or 'profiles' + old_name (str): Original name being changed + new_name (str): New name to change to + Returns: + list: IDs of arr_config rows that were updated + """ + updated_ids = [] + + with get_db() as conn: + # Get all configs that might reference this name + rows = conn.execute( + 'SELECT id, data_to_sync FROM arr_config WHERE data_to_sync IS NOT NULL' + ).fetchall() + + for row in rows: + try: + data = json.loads(row['data_to_sync']) + # Check if this config has the relevant category data + if category in data: + # Update any matching names + if old_name in data[category]: + # Replace old name with new name + data[category] = [ + new_name if x == old_name else x + for x in data[category] + ] + # Save changes back to database + conn.execute( + 'UPDATE arr_config SET data_to_sync = ? WHERE id = ?', + (json.dumps(data), row['id'])) + updated_ids.append(row['id']) + except json.JSONDecodeError: + logger.error(f"Invalid JSON in arr_config id={row['id']}") + continue + + if updated_ids: + conn.commit() + + return updated_ids + + +def update_arr_config_on_delete(category, name): + """ + Update arr_config data_to_sync when a format or profile is deleted. + Args: + category (str): Either 'customFormats' or 'profiles' + name (str): Name being deleted + Returns: + list: IDs of arr_config rows that were updated + """ + updated_ids = [] + + with get_db() as conn: + # Get all configs that might reference this name + rows = conn.execute( + 'SELECT id, data_to_sync FROM arr_config WHERE data_to_sync IS NOT NULL' + ).fetchall() + + for row in rows: + try: + data = json.loads(row['data_to_sync']) + # Check if this config has the relevant category data + if category in data: + # Remove any matching names + if name in data[category]: + data[category].remove(name) + # Save changes back to database + conn.execute( + 'UPDATE arr_config SET data_to_sync = ? WHERE id = ?', + (json.dumps(data), row['id'])) + updated_ids.append(row['id']) + except json.JSONDecodeError: + logger.error(f"Invalid JSON in arr_config id={row['id']}") + continue + + if updated_ids: + conn.commit() + + return updated_ids diff --git a/frontend/src/components/settings/arrs/ArrModal.jsx b/frontend/src/components/settings/arrs/ArrModal.jsx index 7c79ed2..88b558c 100644 --- a/frontend/src/components/settings/arrs/ArrModal.jsx +++ b/frontend/src/components/settings/arrs/ArrModal.jsx @@ -1,8 +1,10 @@ +// ArrModal.jsx + import React from 'react'; import {Plus, TestTube, Loader, Save, X, Trash, Check} from 'lucide-react'; import Modal from '@ui/Modal'; import {useArrModal} from '@hooks/useArrModal'; -import DataSelectorModal from './DataSelectorModal'; +import DataSelector from './DataSelector'; import SyncModal from './SyncModal'; const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => { @@ -44,43 +46,34 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => { {value: 'schedule', label: 'Scheduled'} ]; - // Ensure data_to_sync always has the required structure const safeSelectedData = { profiles: formData.data_to_sync?.profiles || [], customFormats: formData.data_to_sync?.customFormats || [] }; - // Handle sync method change - const handleSyncMethodChange = e => { - const newMethod = e.target.value; - handleInputChange({ - target: { - id: 'sync_method', - value: newMethod - } - }); - - // Reset data_to_sync when switching to manual - if (newMethod === 'manual') { - handleInputChange({ - target: { - id: 'data_to_sync', - value: {profiles: [], customFormats: []} - } - }); - } + const handleFormSubmit = e => { + e.preventDefault(); + e.stopPropagation(); + handleSubmit(e); }; - const inputClasses = errorKey => - `w-full px-3 py-2 text-sm rounded-lg border ${ - errors[errorKey] - ? 'border-red-500' - : 'border-gray-300 dark:border-gray-600' - } bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 ${ - errors[errorKey] - ? 'focus:ring-red-500 focus:border-red-500' - : 'focus:ring-blue-500 focus:border-blue-500' - } placeholder-gray-400 dark:placeholder-gray-500 transition-all`; + const inputClasses = errorKey => ` + w-full px-3 py-2 text-sm rounded-lg border ${ + errors[errorKey] + ? 'border-red-500' + : 'border-gray-300 dark:border-gray-600' + } bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 ${ + errors[errorKey] + ? 'focus:ring-red-500 focus:border-red-500' + : 'focus:ring-blue-500 focus:border-blue-500' + } placeholder-gray-400 dark:placeholder-gray-500 transition-all + `; + + const handleSyncMethodChange = e => { + e.preventDefault(); + e.stopPropagation(); + handleInputChange(e); + }; return ( { !formData.apiKey } className='flex items-center px-3 py-2 text-sm rounded-lg bg-emerald-600 hover:bg-emerald-700 - disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors'> + disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors'> {isTestingConnection ? ( <> @@ -142,7 +135,7 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => { type='submit' form='arrForm' className='flex items-center px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 - text-white font-medium transition-colors'> + text-white font-medium transition-colors'> {saveConfirm ? ( <> @@ -162,8 +155,16 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => { }> -
- {/* Name Field */} + { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + } + }} + className='space-y-4'>
- {/* Type Field */}
- {/* Tags Field */}
- {/* Server URL Field */}
- {/* API Key Field */}
- {/* Sync Method Field */}
- {/* Import as Unique - Now always visible */}
Creates a unique hash from the data and target - instance name, allowing the same profile/format to - be imported multiple times + instance name
- {/* Conditional Fields for Sync Method */} {formData.sync_method === 'schedule' && (
)} - {/* Sync Options */} {formData.sync_method !== 'manual' && ( <> - - +
+

+ Select Data to Sync +

+ +
{errors.data_to_sync && (

{errors.data_to_sync} @@ -436,34 +397,23 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => { )} )} - - setIsDataDrawerOpen(false)} - isLoading={isLoading} - availableData={availableData} - selectedData={safeSelectedData} - onDataToggle={handleDataToggle} - error={errors.data_to_sync} - /> - {showSyncConfirm && ( - { - setShowSyncConfirm(false); - onSubmit(); - }} - onSkip={() => { - setShowSyncConfirm(false); - onSubmit(); - }} - onSync={handleManualSync} - isSyncing={isInitialSyncing} - /> - )} + + {showSyncConfirm && ( + { + setShowSyncConfirm(false); + onSubmit(); + }} + onSkip={() => { + setShowSyncConfirm(false); + onSubmit(); + }} + onSync={handleManualSync} + isSyncing={isInitialSyncing} + /> + )} ); }; diff --git a/frontend/src/components/settings/arrs/DataSelector.jsx b/frontend/src/components/settings/arrs/DataSelector.jsx new file mode 100644 index 0000000..1c970ad --- /dev/null +++ b/frontend/src/components/settings/arrs/DataSelector.jsx @@ -0,0 +1,250 @@ +import React from 'react'; +import {Loader, AlertTriangle} from 'lucide-react'; +import useSearch from '@hooks/useSearch'; +import {useSorting} from '@hooks/useSorting'; +import SearchBar from '@ui/DataBar/SearchBar'; +import SortDropdown from '@ui/SortDropdown'; + +const DataSelector = ({ + isLoading, + availableData = {profiles: [], customFormats: []}, + selectedData = {profiles: [], customFormats: []}, + onDataToggle, + error +}) => { + const profiles = selectedData?.profiles || []; + const customFormats = selectedData?.customFormats || []; + + const availableProfileNames = new Set( + availableData.profiles.map(p => p.content.name) + ); + const availableFormatNames = new Set( + availableData.customFormats.map(f => f.content.name) + ); + + const missingProfiles = profiles.filter( + name => !availableProfileNames.has(name) + ); + const missingFormats = customFormats.filter( + name => !availableFormatNames.has(name) + ); + + const combinedProfiles = [ + ...missingProfiles.map(name => ({name, isMissing: true})), + ...availableData.profiles.map(profile => ({ + name: profile.content.name, + isMissing: false + })) + ]; + + const combinedFormats = [ + ...missingFormats.map(name => ({name, isMissing: true})), + ...availableData.customFormats.map(format => ({ + name: format.content.name, + isMissing: false + })) + ]; + + const { + items: filteredProfiles, + searchTerms: searchTermsProfiles, + currentInput: currentInputProfiles, + setCurrentInput: setCurrentInputProfiles, + addSearchTerm: addSearchTermProfiles, + removeSearchTerm: removeSearchTermProfiles, + clearSearchTerms: clearSearchTermsProfiles + } = useSearch(combinedProfiles, { + searchableFields: ['name'] + }); + + const { + items: filteredFormats, + searchTerms: searchTermsFormats, + currentInput: currentInputFormats, + setCurrentInput: setCurrentInputFormats, + addSearchTerm: addSearchTermFormats, + removeSearchTerm: removeSearchTermFormats, + clearSearchTerms: clearSearchTermsFormats + } = useSearch(combinedFormats, { + searchableFields: ['name'] + }); + + const { + sortConfig: profilesSortConfig, + updateSort: updateProfilesSort, + sortData: sortProfiles + } = useSorting({field: 'name', direction: 'asc'}); + + const { + sortConfig: formatsSortConfig, + updateSort: updateFormatsSort, + sortData: sortFormats + } = useSorting({field: 'name', direction: 'asc'}); + + const sortedProfiles = sortProfiles(filteredProfiles); + const sortedFormats = sortFormats(filteredFormats); + + const sortOptions = [{label: 'Name', value: 'name'}]; + + const renderItem = (item, type) => ( +

+ onDataToggle(type, item.name)} + className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-offset-0' + /> + + {item.name} + + {item.isMissing && ( +
+ + File not found +
+ )} +
+ + ); + + return ( +
+ {isLoading ? ( +
+ +
+ ) : ( + <> +
+
+

+ Quality Profiles +

+
+ {missingProfiles.length > 0 && ( + + {missingProfiles.length} missing + + )} + + {profiles.length} selected + +
+
+
+ { + clearSearchTermsProfiles(); + setCurrentInputProfiles(''); + }} + className='flex-1' + /> +
{ + e.stopPropagation(); + }}> + +
+
+
+ {sortedProfiles.map(item => + renderItem(item, 'profiles') + )} +
+
+ +
+
+
+

+ Custom Formats +

+
+ {missingFormats.length > 0 && ( + + {missingFormats.length} missing + + )} + + {customFormats.length} selected + +
+
+

+ Note: Custom formats used in selected quality + profiles are automatically imported and don't + need to be selected here. +

+
+
+ { + clearSearchTermsFormats(); + setCurrentInputFormats(''); + }} + className='flex-1' + /> +
{ + e.stopPropagation(); + }}> + +
+
+
+ {sortedFormats.map(item => + renderItem(item, 'customFormats') + )} +
+
+ + {error && ( +
+

{error}

+
+ )} + + )} +
+ ); +}; + +export default DataSelector; diff --git a/frontend/src/components/settings/arrs/DataSelectorModal.jsx b/frontend/src/components/settings/arrs/DataSelectorModal.jsx deleted file mode 100644 index 6b3cb8e..0000000 --- a/frontend/src/components/settings/arrs/DataSelectorModal.jsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react'; -import {Loader} from 'lucide-react'; -import Modal from '@ui/Modal'; - -const DataSelectorModal = ({ - isOpen, - onClose, - isLoading, - availableData = {profiles: [], customFormats: []}, - selectedData = {profiles: [], customFormats: []}, - onDataToggle, - error -}) => { - // Ensure we have safe defaults for selectedData - const profiles = selectedData?.profiles || []; - const customFormats = selectedData?.customFormats || []; - - return ( - -
- {isLoading ? ( -
- -
- ) : ( - <> - {/* Quality Profiles Section */} -
-
-

- Quality Profiles -

- - {profiles.length} selected - -
-
- {(availableData?.profiles || []).map( - profile => ( - - ) - )} -
-
- - {/* Custom Formats Section */} -
-
-
-

- Custom Formats -

- - {customFormats.length} selected - -
-

- Note: Custom formats used in selected - quality profiles are automatically imported - and don't need to be selected here. -

-
-
- {(availableData?.customFormats || []).map( - format => ( - - ) - )} -
-
- - {error && ( -
-

{error}

-
- )} - - )} -
-
- ); -}; - -export default DataSelectorModal; diff --git a/frontend/src/components/ui/DataBar/SearchBar.jsx b/frontend/src/components/ui/DataBar/SearchBar.jsx index cad58ba..2d4a76d 100644 --- a/frontend/src/components/ui/DataBar/SearchBar.jsx +++ b/frontend/src/components/ui/DataBar/SearchBar.jsx @@ -1,3 +1,5 @@ +// SearchBar.jsx + import React, {useState, useEffect} from 'react'; import {Search, X} from 'lucide-react'; @@ -15,7 +17,7 @@ const SearchBar = ({ const [isFocused, setIsFocused] = useState(false); useEffect(() => { - const handleKeyDown = e => { + const handleKeyDownGlobal = e => { if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); document.querySelector('input[type="text"]')?.focus(); @@ -24,93 +26,90 @@ const SearchBar = ({ onClearTerms(); } }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + document.addEventListener('keydown', handleKeyDownGlobal); + return () => + document.removeEventListener('keydown', handleKeyDownGlobal); }, [onClearTerms]); const handleKeyDown = e => { - // Handle backspace when input is empty and there are search terms + if (requireEnter && e.key === 'Enter' && currentInput.trim()) { + e.preventDefault(); + onAddTerm(currentInput); + return; + } if (e.key === 'Backspace' && !currentInput && searchTerms.length > 0) { e.preventDefault(); onRemoveTerm(searchTerms[searchTerms.length - 1]); } }; - const handleKeyPress = e => { - if (requireEnter && e.key === 'Enter' && currentInput.trim()) { - onAddTerm(currentInput); - } - }; - return (
-
+ w-full min-h-10 pl-9 pr-8 rounded-md + transition-all duration-200 ease-in-out + border shadow-sm flex items-center flex-wrap gap-2 p-2 + ${ + isFocused + ? 'border-blue-500 ring-2 ring-blue-500/20 bg-white/5' + : 'border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600' + } + bg-white dark:bg-gray-800 + `}> {searchTerms.map((term, index) => (
+ bg-blue-500/10 dark:bg-blue-500/20 + border border-blue-500/20 dark:border-blue-400/20 + text-blue-600 dark:text-blue-400 + rounded-md shadow-sm + hover:bg-blue-500/15 dark:hover:bg-blue-500/25 + hover:border-blue-500/30 dark:hover:border-blue-400/30 + group/badge + transition-all duration-200 + '> {term}
))} + onInputChange(e.target.value)} - onKeyPress={handleKeyPress} - onKeyDown={handleKeyDown} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} + onChange={e => onInputChange(e.target.value)} + onKeyDown={handleKeyDown} placeholder={ searchTerms.length ? 'Add another filter...' : placeholder } className='flex-1 min-w-[200px] bg-transparent - text-gray-900 dark:text-gray-100 - placeholder:text-gray-500 dark:placeholder:text-gray-400 - focus:outline-none' + text-gray-900 dark:text-gray-100 + placeholder:text-gray-500 dark:placeholder:text-gray-400 + focus:outline-none' />
@@ -118,12 +117,11 @@ const SearchBar = ({ )} diff --git a/frontend/src/components/ui/SortDropdown.jsx b/frontend/src/components/ui/SortDropdown.jsx index 8e52fb1..8169142 100644 --- a/frontend/src/components/ui/SortDropdown.jsx +++ b/frontend/src/components/ui/SortDropdown.jsx @@ -1,5 +1,7 @@ +// SortDropdown.jsx + import React, {useState} from 'react'; -import {ChevronDown, ChevronUp, ArrowDown, ArrowUp} from 'lucide-react'; +import {ArrowDown, ArrowUp} from 'lucide-react'; const SortDropdown = ({ sortOptions, @@ -24,14 +26,19 @@ const SortDropdown = ({ return (