From 22d4029e202b32715483b7ed559d8df70988a459 Mon Sep 17 00:00:00 2001 From: Samuel Chau Date: Thu, 12 Jun 2025 15:42:29 +0930 Subject: [PATCH] feat: media management (#205) - implemented new data page for media management - renaming options to set movie / episode / folder formats - misc options for propers/repacks/video analysis - quality definitions - syncing with instances - improved mobile view for tabs / navbar --- .gitignore | 5 +- backend/app/config/config.py | 3 +- backend/app/git/status/incoming_changes.py | 12 +- backend/app/git/status/outgoing_changes.py | 12 +- backend/app/git/status/utils.py | 27 ++ backend/app/main.py | 2 + backend/app/media_management/__init__.py | 141 ++++++++++ backend/app/media_management/sync.py | 258 +++++++++++++++++ backend/app/media_management/utils.py | 211 ++++++++++++++ docker-compose.yml | 5 +- frontend/package-lock.json | 41 +++ frontend/package.json | 1 + frontend/src/App.jsx | 5 + frontend/src/api/mediaManagement.js | 87 ++++++ .../media-management/CategoryActions.jsx | 43 +++ .../media-management/CategoryContainer.jsx | 67 +++++ .../media-management/MediaManagementPage.jsx | 259 ++++++++++++++++++ .../media-management/MiscSettings.jsx | 92 +++++++ .../media-management/NamingSettings.jsx | 238 ++++++++++++++++ .../media-management/QualityDefinitions.jsx | 229 ++++++++++++++++ .../media-management/QualityGroup.jsx | 55 ++++ .../media-management/QualityItem.jsx | 257 +++++++++++++++++ .../components/media-management/SyncModal.jsx | 212 ++++++++++++++ .../settings/git/status/ChangeRow.jsx | 5 +- .../settings/git/status/PushRow.jsx | 4 +- .../settings/git/status/ViewChanges.jsx | 105 +++---- .../src/components/ui/DataBar/AddButton.jsx | 4 +- .../src/components/ui/DataBar/DataBar.jsx | 33 ++- .../src/components/ui/DataBar/FilterMenu.jsx | 4 +- .../src/components/ui/DataBar/SearchBar.jsx | 12 +- .../components/ui/DataBar/SortDropdown.jsx | 4 +- .../ui/DataBar/ToggleSelectButton.jsx | 4 +- frontend/src/components/ui/Dropdown.jsx | 110 ++++++++ frontend/src/components/ui/Modal.jsx | 4 +- frontend/src/components/ui/MonospaceInput.jsx | 77 ++++++ frontend/src/components/ui/Navbar.jsx | 103 ++++++- frontend/src/components/ui/NumberInput.jsx | 5 +- frontend/src/components/ui/Slider.jsx | 71 +++++ frontend/src/components/ui/TabViewer.jsx | 59 +++- frontend/src/constants/commits.js | 13 +- frontend/src/index.css | 11 + 41 files changed, 2788 insertions(+), 102 deletions(-) create mode 100644 backend/app/media_management/__init__.py create mode 100644 backend/app/media_management/sync.py create mode 100644 backend/app/media_management/utils.py create mode 100644 frontend/src/api/mediaManagement.js create mode 100644 frontend/src/components/media-management/CategoryActions.jsx create mode 100644 frontend/src/components/media-management/CategoryContainer.jsx create mode 100644 frontend/src/components/media-management/MediaManagementPage.jsx create mode 100644 frontend/src/components/media-management/MiscSettings.jsx create mode 100644 frontend/src/components/media-management/NamingSettings.jsx create mode 100644 frontend/src/components/media-management/QualityDefinitions.jsx create mode 100644 frontend/src/components/media-management/QualityGroup.jsx create mode 100644 frontend/src/components/media-management/QualityItem.jsx create mode 100644 frontend/src/components/media-management/SyncModal.jsx create mode 100644 frontend/src/components/ui/Dropdown.jsx create mode 100644 frontend/src/components/ui/MonospaceInput.jsx create mode 100644 frontend/src/components/ui/Slider.jsx diff --git a/.gitignore b/.gitignore index dc127c3..222b279 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ __pycache__/ .DS_Store # build files -backend/app/static/ \ No newline at end of file +backend/app/static/ + +# Config data +config/ \ No newline at end of file diff --git a/backend/app/config/config.py b/backend/app/config/config.py index e5362be..6d70114 100644 --- a/backend/app/config/config.py +++ b/backend/app/config/config.py @@ -11,6 +11,7 @@ class Config: REGEX_DIR = os.path.join(DB_DIR, 'regex_patterns') FORMAT_DIR = os.path.join(DB_DIR, 'custom_formats') PROFILE_DIR = os.path.join(DB_DIR, 'profiles') + MEDIA_MANAGEMENT_DIR = os.path.join(DB_DIR, 'media_management') # Logging LOG_DIR = os.path.join(CONFIG_DIR, 'log') @@ -40,7 +41,7 @@ class Config: """Create all required directories if they don't exist.""" directories = [ Config.CONFIG_DIR, Config.DB_DIR, Config.REGEX_DIR, - Config.FORMAT_DIR, Config.PROFILE_DIR, Config.LOG_DIR + Config.FORMAT_DIR, Config.PROFILE_DIR, Config.MEDIA_MANAGEMENT_DIR, Config.LOG_DIR ] logger = logging.getLogger(__name__) for directory in directories: diff --git a/backend/app/git/status/incoming_changes.py b/backend/app/git/status/incoming_changes.py index c94ea38..101dad0 100644 --- a/backend/app/git/status/incoming_changes.py +++ b/backend/app/git/status/incoming_changes.py @@ -3,19 +3,13 @@ import yaml import logging from git import GitCommandError from .comparison import create_change_summary -from .utils import determine_type, parse_commit_message +from .utils import determine_type, parse_commit_message, extract_name_from_path logger = logging.getLogger(__name__) -def extract_name(file_path): - """Extract name from file path by removing type prefix and extension""" - # Remove the file extension - name = os.path.splitext(file_path)[0] - # Remove the type prefix (everything before the first '/') - if '/' in name: - name = name.split('/', 1)[1] - return name +# Use the centralized extract_name_from_path function from utils +extract_name = extract_name_from_path def check_merge_conflict(repo, branch, file_path): diff --git a/backend/app/git/status/outgoing_changes.py b/backend/app/git/status/outgoing_changes.py index f8c15f1..c80c507 100644 --- a/backend/app/git/status/outgoing_changes.py +++ b/backend/app/git/status/outgoing_changes.py @@ -3,19 +3,13 @@ import yaml import logging from git import GitCommandError from .comparison import create_change_summary -from .utils import determine_type +from .utils import determine_type, extract_name_from_path logger = logging.getLogger(__name__) -def extract_name(file_path): - """Extract name from file path by removing type prefix and extension""" - # Remove the file extension - name = os.path.splitext(file_path)[0] - # Remove the type prefix (everything before the first '/') - if '/' in name: - name = name.split('/', 1)[1] - return name +# Use the centralized extract_name_from_path function from utils +extract_name = extract_name_from_path def get_outgoing_changes(repo): diff --git a/backend/app/git/status/utils.py b/backend/app/git/status/utils.py index 18e7951..b1554a2 100644 --- a/backend/app/git/status/utils.py +++ b/backend/app/git/status/utils.py @@ -38,9 +38,36 @@ def determine_type(file_path): return 'Custom Format' elif 'profiles' in file_path: return 'Quality Profile' + elif 'media_management' in file_path: + return 'Media Management' return 'Unknown' +def format_media_management_name(name): + """Format media management category names for display""" + name_mapping = { + 'misc': 'Miscellaneous', + 'naming': 'Naming', + 'quality_definitions': 'Quality Definitions' + } + return name_mapping.get(name, name) + + +def extract_name_from_path(file_path): + """Extract and format name from file path""" + # Remove the file extension + name = os.path.splitext(file_path)[0] + # Remove the type prefix (everything before the first '/') + if '/' in name: + name = name.split('/', 1)[1] + + # Format media management names + if 'media_management' in file_path: + return format_media_management_name(name) + + return name + + def interpret_git_status(x, y): if x == 'D' or y == 'D': return 'Deleted' diff --git a/backend/app/main.py b/backend/app/main.py index c0d0c5e..f6c743a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from .db import run_migrations, get_settings from .auth import bp as auth_bp from .settings import bp as settings_bp from .logs import bp as logs_bp +from .media_management import media_management_bp from .middleware import init_middleware from .init import setup_logging, init_app_config, init_git_user @@ -71,6 +72,7 @@ def create_app(): app.register_blueprint(importarr_bp, url_prefix='/api/import') app.register_blueprint(arr_bp, url_prefix='/api/arr') app.register_blueprint(tasks_bp, url_prefix='/api/tasks') + app.register_blueprint(media_management_bp) # Initialize middleware logger.info("Initializing middleware") diff --git a/backend/app/media_management/__init__.py b/backend/app/media_management/__init__.py new file mode 100644 index 0000000..58d6da9 --- /dev/null +++ b/backend/app/media_management/__init__.py @@ -0,0 +1,141 @@ +from flask import Blueprint, jsonify, request +import logging +from .utils import ( + get_media_management_data, + save_media_management_data, + update_media_management_data, + get_all_media_management_data, + MEDIA_MANAGEMENT_CATEGORIES +) +from .sync import ( + sync_naming_config, + sync_media_management_config, + sync_quality_definitions +) +from ..arr.manager import get_arr_config + +logger = logging.getLogger(__name__) + +media_management_bp = Blueprint('media_management', __name__) + + +@media_management_bp.route('/api/media-management', methods=['GET']) +def get_all_media_management(): + """Get all media management data for all categories""" + try: + data = get_all_media_management_data() + return jsonify(data), 200 + except Exception as e: + logger.error(f"Error retrieving media management data: {e}") + return jsonify({'error': str(e)}), 500 + + +@media_management_bp.route('/api/media-management/', methods=['GET']) +def get_media_management(category): + """Get media management data for a specific category""" + if category not in MEDIA_MANAGEMENT_CATEGORIES: + return jsonify({'error': f'Invalid category: {category}'}), 400 + + try: + data = get_media_management_data(category) + return jsonify(data), 200 + except Exception as e: + logger.error(f"Error retrieving {category}: {e}") + return jsonify({'error': str(e)}), 500 + + +@media_management_bp.route('/api/media-management/', methods=['PUT']) +def update_media_management(category): + """Update media management data for a specific category""" + if category not in MEDIA_MANAGEMENT_CATEGORIES: + return jsonify({'error': f'Invalid category: {category}'}), 400 + + try: + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + updated_data = update_media_management_data(category, data) + return jsonify(updated_data), 200 + except Exception as e: + logger.error(f"Error updating {category}: {e}") + return jsonify({'error': str(e)}), 500 + + +@media_management_bp.route('/api/media-management/sync', methods=['POST']) +def sync_media_management(): + """Sync media management data to arr instance""" + try: + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + arr_id = data.get('arr_id') + categories = data.get('categories', []) + + if not arr_id: + return jsonify({'error': 'arr_id is required'}), 400 + + if not categories: + return jsonify({'error': 'categories list is required'}), 400 + + # Validate categories + invalid_categories = [cat for cat in categories if cat not in MEDIA_MANAGEMENT_CATEGORIES] + if invalid_categories: + return jsonify({'error': f'Invalid categories: {invalid_categories}'}), 400 + + # Get arr config + arr_result = get_arr_config(arr_id) + if not arr_result.get('success'): + return jsonify({'error': 'Arr configuration not found'}), 404 + + arr_config = arr_result.get('data') + base_url = arr_config['arrServer'] + api_key = arr_config['apiKey'] + arr_type = arr_config['type'] + + results = {} + + # Sync each requested category + for category in categories: + try: + # Get the current media management data for this category + category_data = get_media_management_data(category) + logger.info(f"Raw category_data for {category}: {category_data}") + arr_type_data = category_data.get(arr_type, {}) + logger.info(f"Extracted arr_type_data for {arr_type}: {arr_type_data}") + + if category == 'naming': + success, message = sync_naming_config(base_url, api_key, arr_type, arr_type_data) + elif category == 'misc': + success, message = sync_media_management_config(base_url, api_key, arr_type, arr_type_data) + elif category == 'quality_definitions': + # Quality definitions has a nested structure: qualityDefinitions -> arr_type -> qualities + quality_defs = category_data.get('qualityDefinitions', {}).get(arr_type, {}) + success, message = sync_quality_definitions(base_url, api_key, arr_type, quality_defs) + else: + success, message = False, f"Unknown category: {category}" + + results[category] = { + 'success': success, + 'message': message + } + + except Exception as e: + logger.error(f"Error syncing {category}: {e}") + results[category] = { + 'success': False, + 'message': str(e) + } + + # Determine overall success + overall_success = all(result['success'] for result in results.values()) + + return jsonify({ + 'success': overall_success, + 'results': results + }), 200 + + except Exception as e: + logger.error(f"Error in media management sync: {e}") + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/backend/app/media_management/sync.py b/backend/app/media_management/sync.py new file mode 100644 index 0000000..644c7da --- /dev/null +++ b/backend/app/media_management/sync.py @@ -0,0 +1,258 @@ +import logging +import requests +from typing import Dict, Any, Tuple + +logger = logging.getLogger(__name__) + +def sync_naming_config(base_url: str, api_key: str, arr_type: str, naming_data: Dict[str, Any]) -> Tuple[bool, str]: + """ + Sync naming configuration to arr instance. + First GET current config, update with our data, then PUT back. + + Args: + base_url: The arr instance base URL + api_key: The arr instance API key + arr_type: Either 'radarr' or 'sonarr' + naming_data: The naming configuration from our YML file + + Returns: + Tuple of (success, message) + """ + try: + # Construct the endpoint URL + endpoint = f"{base_url}/api/v3/config/naming" + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json" + } + + # GET current naming config + logger.info(f"Fetching current naming config from {arr_type} at {base_url}") + response = requests.get(endpoint, headers=headers, timeout=10) + response.raise_for_status() + + current_config = response.json() + logger.info(f"Current naming config for {arr_type}:") + logger.info(current_config) + + # Update current_config with fields from naming_data + if arr_type == 'radarr': + # Map our YML fields to Radarr API fields + if 'rename' in naming_data: + current_config['renameMovies'] = naming_data['rename'] + if 'replaceIllegalCharacters' in naming_data: + current_config['replaceIllegalCharacters'] = naming_data['replaceIllegalCharacters'] + if 'colonReplacementFormat' in naming_data: + current_config['colonReplacementFormat'] = naming_data['colonReplacementFormat'] + if 'movieFormat' in naming_data: + current_config['standardMovieFormat'] = naming_data['movieFormat'] + if 'movieFolderFormat' in naming_data: + current_config['movieFolderFormat'] = naming_data['movieFolderFormat'] + else: # sonarr + # Map our YML fields to Sonarr API fields + if 'rename' in naming_data: + current_config['renameEpisodes'] = naming_data['rename'] + if 'replaceIllegalCharacters' in naming_data: + current_config['replaceIllegalCharacters'] = naming_data['replaceIllegalCharacters'] + if 'colonReplacementFormat' in naming_data: + current_config['colonReplacementFormat'] = naming_data['colonReplacementFormat'] + if 'customColonReplacementFormat' in naming_data: + current_config['customColonReplacementFormat'] = naming_data['customColonReplacementFormat'] + if 'multiEpisodeStyle' in naming_data: + current_config['multiEpisodeStyle'] = naming_data['multiEpisodeStyle'] + if 'standardEpisodeFormat' in naming_data: + current_config['standardEpisodeFormat'] = naming_data['standardEpisodeFormat'] + if 'dailyEpisodeFormat' in naming_data: + current_config['dailyEpisodeFormat'] = naming_data['dailyEpisodeFormat'] + if 'animeEpisodeFormat' in naming_data: + current_config['animeEpisodeFormat'] = naming_data['animeEpisodeFormat'] + if 'seriesFolderFormat' in naming_data: + current_config['seriesFolderFormat'] = naming_data['seriesFolderFormat'] + if 'seasonFolderFormat' in naming_data: + current_config['seasonFolderFormat'] = naming_data['seasonFolderFormat'] + if 'specialsFolderFormat' in naming_data: + current_config['specialsFolderFormat'] = naming_data['specialsFolderFormat'] + + # PUT the updated config back + logger.info(f"Updating naming config for {arr_type}") + logger.info(f"Request body for naming sync:") + logger.info(current_config) + put_response = requests.put(endpoint, json=current_config, headers=headers, timeout=10) + put_response.raise_for_status() + + logger.info(f"Successfully synced naming config for {arr_type}") + return True, "Naming config sync successful" + + except requests.exceptions.RequestException as e: + error_msg = f"Failed to sync naming config: {str(e)}" + logger.error(error_msg) + return False, error_msg + except Exception as e: + error_msg = f"Unexpected error syncing naming config: {str(e)}" + logger.error(error_msg) + return False, error_msg + + +def sync_media_management_config(base_url: str, api_key: str, arr_type: str, misc_data: Dict[str, Any]) -> Tuple[bool, str]: + """ + Sync media management (misc) configuration to arr instance. + First GET current config, update with our data, then PUT back. + + Args: + base_url: The arr instance base URL + api_key: The arr instance API key + arr_type: Either 'radarr' or 'sonarr' + misc_data: The misc configuration from our YML file + + Returns: + Tuple of (success, message) + """ + try: + # Construct the endpoint URL + endpoint = f"{base_url}/api/v3/config/mediamanagement" + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json" + } + + # GET current media management config + logger.info(f"Fetching current media management config from {arr_type} at {base_url}") + response = requests.get(endpoint, headers=headers, timeout=10) + response.raise_for_status() + + current_config = response.json() + logger.info(f"Current media management config for {arr_type}:") + logger.info(current_config) + + # Update current_config with fields from misc_data + # We only manage two fields: propersRepacks and enableMediaInfo + if 'propersRepacks' in misc_data: + current_config['downloadPropersAndRepacks'] = misc_data['propersRepacks'] + if 'enableMediaInfo' in misc_data: + current_config['enableMediaInfo'] = misc_data['enableMediaInfo'] + + # PUT the updated config back + logger.info(f"Updating media management config for {arr_type}") + logger.info(f"Request body for media management sync:") + logger.info(current_config) + put_response = requests.put(endpoint, json=current_config, headers=headers, timeout=10) + put_response.raise_for_status() + + logger.info(f"Successfully synced media management config for {arr_type}") + return True, "Media management config sync successful" + + except requests.exceptions.RequestException as e: + error_msg = f"Failed to sync media management config: {str(e)}" + logger.error(error_msg) + return False, error_msg + except Exception as e: + error_msg = f"Unexpected error syncing media management config: {str(e)}" + logger.error(error_msg) + return False, error_msg + + +def sync_quality_definitions(base_url: str, api_key: str, arr_type: str, quality_data: Dict[str, Any]) -> Tuple[bool, str]: + """ + Sync quality definitions to arr instance. + Quality definitions contain all required data, so we can directly PUT. + + Args: + base_url: The arr instance base URL + api_key: The arr instance API key + arr_type: Either 'radarr' or 'sonarr' + quality_data: The quality definitions from our YML file + + Returns: + Tuple of (success, message) + """ + try: + # Construct the endpoint URL + endpoint = f"{base_url}/api/v3/qualitydefinition" + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json" + } + + # GET current quality definitions (for logging/comparison) + logger.info(f"Fetching current quality definitions from {arr_type} at {base_url}") + response = requests.get(endpoint, headers=headers, timeout=10) + response.raise_for_status() + + current_definitions = response.json() + logger.info(f"Current quality definitions for {arr_type}:") + logger.info(current_definitions) + + if arr_type == 'sonarr': + # Log the quality data we received from YML + logger.info(f"Quality data from YML:") + logger.info(quality_data) + + # Create a mapping of quality names to current definitions for easier lookup + quality_map = {def_['quality']['name']: def_ for def_ in current_definitions} + + # Update each quality definition with our values + for quality_name, settings in quality_data.items(): + if quality_name in quality_map: + definition = quality_map[quality_name] + # Update size limits from our YML data + if 'min' in settings: + definition['minSize'] = settings['min'] + if 'preferred' in settings: + definition['preferredSize'] = settings['preferred'] + if 'max' in settings: + definition['maxSize'] = settings['max'] + + # PUT the updated definitions back + logger.info(f"Updating quality definitions for {arr_type}") + logger.info(f"Request body for quality definitions sync:") + logger.info(current_definitions) + + # Sonarr expects the full array of definitions at the update endpoint + update_endpoint = f"{base_url}/api/v3/qualitydefinition/update" + put_response = requests.put(update_endpoint, json=current_definitions, headers=headers, timeout=10) + put_response.raise_for_status() + + logger.info(f"Successfully synced quality definitions for {arr_type}") + return True, "Quality definitions sync successful" + + else: # radarr + # Log the quality data we received from YML + logger.info(f"Quality data from YML:") + logger.info(quality_data) + + # Create a mapping of quality names to current definitions for easier lookup + quality_map = {def_['quality']['name']: def_ for def_ in current_definitions} + + # Update each quality definition with our values + for quality_name, settings in quality_data.items(): + if quality_name in quality_map: + definition = quality_map[quality_name] + # Update size limits from our YML data + if 'min' in settings: + definition['minSize'] = settings['min'] + if 'preferred' in settings: + definition['preferredSize'] = settings['preferred'] + if 'max' in settings: + definition['maxSize'] = settings['max'] + + # PUT the updated definitions back + logger.info(f"Updating quality definitions for {arr_type}") + logger.info(f"Request body for quality definitions sync:") + logger.info(current_definitions) + + # Radarr expects the full array of definitions at the update endpoint + update_endpoint = f"{base_url}/api/v3/qualitydefinition/update" + put_response = requests.put(update_endpoint, json=current_definitions, headers=headers, timeout=10) + put_response.raise_for_status() + + logger.info(f"Successfully synced quality definitions for {arr_type}") + return True, "Quality definitions sync successful" + + except requests.exceptions.RequestException as e: + error_msg = f"Failed to sync quality definitions: {str(e)}" + logger.error(error_msg) + return False, error_msg + except Exception as e: + error_msg = f"Unexpected error syncing quality definitions: {str(e)}" + logger.error(error_msg) + return False, error_msg \ No newline at end of file diff --git a/backend/app/media_management/utils.py b/backend/app/media_management/utils.py new file mode 100644 index 0000000..aa0bb6b --- /dev/null +++ b/backend/app/media_management/utils.py @@ -0,0 +1,211 @@ +import os +import yaml +import logging +from typing import Dict, Any +from datetime import datetime +from ..config.config import config + +logger = logging.getLogger(__name__) + +# Media management directory +MEDIA_MANAGEMENT_DIR = config.MEDIA_MANAGEMENT_DIR + +# Media management categories +MEDIA_MANAGEMENT_CATEGORIES = ["misc", "naming", "quality_definitions"] + +def _preserve_order(data: Dict[str, Any], category: str) -> Dict[str, Any]: + """Preserve the desired key order based on category""" + if category == "misc": + # Order: radarr, sonarr + ordered = {} + for arr_type in ["radarr", "sonarr"]: + if arr_type in data: + arr_data = data[arr_type] + # Order within each: propersRepacks, enableMediaInfo + ordered_arr = {} + for key in ["propersRepacks", "enableMediaInfo"]: + if key in arr_data: + ordered_arr[key] = arr_data[key] + # Add any remaining keys + for key, value in arr_data.items(): + if key not in ordered_arr: + ordered_arr[key] = value + ordered[arr_type] = ordered_arr + # Add any remaining top-level keys + for key, value in data.items(): + if key not in ordered: + ordered[key] = value + return ordered + + elif category == "naming": + # Order: radarr, sonarr + ordered = {} + for arr_type in ["radarr", "sonarr"]: + if arr_type in data: + arr_data = data[arr_type] + ordered_arr = {} + + if arr_type == "radarr": + # Radarr order: rename, movieFormat, movieFolderFormat, replaceIllegalCharacters, colonReplacementFormat + for key in ["rename", "movieFormat", "movieFolderFormat", "replaceIllegalCharacters", "colonReplacementFormat"]: + if key in arr_data: + ordered_arr[key] = arr_data[key] + elif arr_type == "sonarr": + # Sonarr order: rename, standardEpisodeFormat, dailyEpisodeFormat, animeEpisodeFormat, seriesFolderFormat, seasonFolderFormat, replaceIllegalCharacters, colonReplacementFormat, customColonReplacementFormat, multiEpisodeStyle + for key in ["rename", "standardEpisodeFormat", "dailyEpisodeFormat", "animeEpisodeFormat", "seriesFolderFormat", "seasonFolderFormat", "replaceIllegalCharacters", "colonReplacementFormat", "customColonReplacementFormat", "multiEpisodeStyle"]: + if key in arr_data: + ordered_arr[key] = arr_data[key] + + # Add any remaining keys + for key, value in arr_data.items(): + if key not in ordered_arr: + ordered_arr[key] = value + ordered[arr_type] = ordered_arr + # Add any remaining top-level keys + for key, value in data.items(): + if key not in ordered: + ordered[key] = value + return ordered + + elif category == "quality_definitions": + # For quality_definitions, preserve the structure: qualityDefinitions -> radarr/sonarr -> qualities + return data + + return data + + +def _get_file_path(category: str) -> str: + """Get the file path for a media management category""" + return os.path.join(MEDIA_MANAGEMENT_DIR, f"{category}.yml") + + +def _load_yaml_file(file_path: str) -> Dict[str, Any]: + """Load YAML file and return contents""" + if not os.path.exists(file_path): + logger.error(f"File not found: {file_path}") + raise FileNotFoundError(f"File not found: {file_path}") + + try: + with open(file_path, 'r') as f: + return yaml.safe_load(f) or {} + except Exception as e: + logger.error(f"Error loading {file_path}: {e}") + raise + + +def _save_yaml_file(file_path: str, data: Dict[str, Any], category: str = None) -> None: + """Save data to YAML file""" + try: + # Preserve key order if category is specified + if category: + data = _preserve_order(data, category) + + with open(file_path, 'w') as f: + yaml.safe_dump( + data, + f, + sort_keys=False, + default_flow_style=False, + width=1000, # Prevent line wrapping + allow_unicode=True + ) + except Exception as e: + logger.error(f"Error saving {file_path}: {e}") + raise + + +def get_media_management_data(category: str) -> Dict[str, Any]: + """Get media management data for a specific category""" + if category not in MEDIA_MANAGEMENT_CATEGORIES: + raise ValueError(f"Invalid category: {category}") + + file_path = _get_file_path(category) + + # If file doesn't exist, return empty dict + if not os.path.exists(file_path): + logger.info(f"Media management file not found: {file_path}") + return {} + + try: + data = _load_yaml_file(file_path) + return data + except Exception as e: + logger.error(f"Error reading {category}: {e}") + # Return empty dict on error + return {} + + +def save_media_management_data(category: str, data: Dict[str, Any]) -> Dict[str, Any]: + """Save media management data for a specific category""" + if category not in MEDIA_MANAGEMENT_CATEGORIES: + raise ValueError(f"Invalid category: {category}") + + file_path = _get_file_path(category) + + try: + _save_yaml_file(file_path, data, category) + logger.info(f"Saved {category} data") + return get_media_management_data(category) + except Exception as e: + logger.error(f"Error saving {category}: {e}") + raise + + +def update_media_management_data(category: str, data: Dict[str, Any]) -> Dict[str, Any]: + """Update media management data for a specific category""" + if category not in MEDIA_MANAGEMENT_CATEGORIES: + raise ValueError(f"Invalid category: {category}") + + # For media management, update is the same as save + # since these files can't be deleted + return save_media_management_data(category, data) + + +def get_all_media_management_data() -> Dict[str, Any]: + """Get all media management data for all categories, transformed to have arr type at top level""" + # First get all data in original structure + original_data = {} + + for category in MEDIA_MANAGEMENT_CATEGORIES: + try: + data = get_media_management_data(category) + # Only include if data exists + if data: + original_data[category] = data + except Exception as e: + logger.error(f"Error getting {category} data: {e}") + + # Transform to have radarr/sonarr at top level + result = { + "radarr": {}, + "sonarr": {} + } + + for category, data in original_data.items(): + if category == "misc": + # misc has radarr/sonarr subdivisions + if "radarr" in data and data["radarr"]: + result["radarr"]["misc"] = data["radarr"] + if "sonarr" in data and data["sonarr"]: + result["sonarr"]["misc"] = data["sonarr"] + elif category == "naming": + # naming has radarr/sonarr subdivisions + if "radarr" in data and data["radarr"]: + result["radarr"]["naming"] = data["radarr"] + if "sonarr" in data and data["sonarr"]: + result["sonarr"]["naming"] = data["sonarr"] + elif category == "quality_definitions": + # quality_definitions has qualityDefinitions.radarr/sonarr + quality_defs = data.get("qualityDefinitions", {}) + if "radarr" in quality_defs and quality_defs["radarr"]: + result["radarr"]["quality_definitions"] = quality_defs["radarr"] + if "sonarr" in quality_defs and quality_defs["sonarr"]: + result["sonarr"]["quality_definitions"] = quality_defs["sonarr"] + + # Remove empty arr types + if not result["radarr"]: + del result["radarr"] + if not result["sonarr"]: + del result["sonarr"] + + return result \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 676d161..0be269c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,11 +17,8 @@ services: - '5000:5000' volumes: - ./backend:/app - - config_data:/config + - ./config:/config env_file: - .env restart: always -volumes: - config_data: - name: regexerr_config diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 05a269c..2a8a517 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.428.0", "prop-types": "^15.8.1", + "rc-slider": "^11.1.8", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.3", @@ -1351,6 +1352,11 @@ "node": ">=6" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3315,6 +3321,41 @@ } ] }, + "node_modules/rc-slider": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.8.tgz", + "integrity": "sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1945481..192f8b0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.428.0", "prop-types": "^15.8.1", + "rc-slider": "^11.1.8", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.3", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c0ef959..131b93f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import RegexPage from './components/regex/RegexPage'; import FormatPage from './components/format/FormatPage'; import ProfilePage from './components/profile/ProfilePage'; import SettingsPage from './components/settings/SettingsPage'; +import MediaManagementPage from './components/media-management/MediaManagementPage'; import SetupPage from './components/auth/SetupPage'; import LoginPage from './components/auth/LoginPage'; import Navbar from '@ui/Navbar'; @@ -154,6 +155,10 @@ function App() { path='/profile' element={} /> + } + /> } diff --git a/frontend/src/api/mediaManagement.js b/frontend/src/api/mediaManagement.js new file mode 100644 index 0000000..495d5b0 --- /dev/null +++ b/frontend/src/api/mediaManagement.js @@ -0,0 +1,87 @@ +import axios from 'axios'; + +const BASE_URL = '/api/media-management'; + +/** + * Get all media management data for all categories + * @returns {Promise} Media management data organized by arr type + */ +export const getMediaManagementData = async () => { + try { + const response = await axios.get(BASE_URL); + return response.data; + } catch (error) { + console.error('Error fetching media management data:', error); + throw error; + } +}; + +/** + * Get media management data for a specific category + * @param {string} category - The category to fetch (misc, naming, quality_definitions) + * @returns {Promise} Category data + */ +export const getMediaManagementCategory = async (category) => { + try { + const response = await axios.get(`${BASE_URL}/${category}`); + return response.data; + } catch (error) { + console.error(`Error fetching ${category} data:`, error); + throw error; + } +}; + +/** + * Update media management data for a specific category + * @param {string} category - The category to update (misc, naming, quality_definitions) + * @param {Object} data - The data to save + * @returns {Promise} Updated category data + */ +export const updateMediaManagementCategory = async (category, data) => { + try { + const response = await axios.put(`${BASE_URL}/${category}`, data); + return response.data; + } catch (error) { + console.error(`Error updating ${category}:`, error); + throw error; + } +}; + +/** + * Sync media management data to arr instance + * @param {number} arrId - The arr instance ID to sync to + * @param {string[]} categories - Array of categories to sync + * @returns {Promise} Sync results + */ +export const syncMediaManagement = async (arrId, categories) => { + try { + const response = await axios.post(`${BASE_URL}/sync`, { + arr_id: arrId, + categories: categories + }); + return response.data; + } catch (error) { + console.error('Error syncing media management:', error); + throw error; + } +}; + +// Organized export pattern for convenience +export const MediaManagement = { + // Main operations + getAll: getMediaManagementData, + getCategory: getMediaManagementCategory, + updateCategory: updateMediaManagementCategory, + + // Category-specific helpers + getMisc: () => getMediaManagementCategory('misc'), + getNaming: () => getMediaManagementCategory('naming'), + getQualityDefinitions: () => getMediaManagementCategory('quality_definitions'), + + updateMisc: (data) => updateMediaManagementCategory('misc', data), + updateNaming: (data) => updateMediaManagementCategory('naming', data), + updateQualityDefinitions: (data) => updateMediaManagementCategory('quality_definitions', data), + + // Sync functionality + sync: syncMediaManagement +}; \ No newline at end of file diff --git a/frontend/src/components/media-management/CategoryActions.jsx b/frontend/src/components/media-management/CategoryActions.jsx new file mode 100644 index 0000000..0a651ad --- /dev/null +++ b/frontend/src/components/media-management/CategoryActions.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Save, RefreshCw, Check } from 'lucide-react'; + +const CategoryActions = ({ onSync, onSave, isSaving = false, isSyncing = false }) => { + return ( +
+ + +
+ ); +}; + +CategoryActions.propTypes = { + onSync: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + isSaving: PropTypes.bool, + isSyncing: PropTypes.bool +}; + +export default CategoryActions; \ No newline at end of file diff --git a/frontend/src/components/media-management/CategoryContainer.jsx b/frontend/src/components/media-management/CategoryContainer.jsx new file mode 100644 index 0000000..1c208a1 --- /dev/null +++ b/frontend/src/components/media-management/CategoryContainer.jsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import CategoryActions from './CategoryActions'; + +const CategoryContainer = ({ + title, + children, + onSync, + onSave, + isSaving = false, + isSyncing = false, + isExpanded = true +}) => { + const [expanded, setExpanded] = useState(isExpanded); + + return ( +
+ {/* Header Section */} + + + {/* Body Section */} + {expanded && ( +
+ {children} +
+ )} +
+ ); +}; + +CategoryContainer.propTypes = { + title: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + onSync: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + isSaving: PropTypes.bool, + isSyncing: PropTypes.bool, + isExpanded: PropTypes.bool +}; + +export default CategoryContainer; \ No newline at end of file diff --git a/frontend/src/components/media-management/MediaManagementPage.jsx b/frontend/src/components/media-management/MediaManagementPage.jsx new file mode 100644 index 0000000..ad8a1a7 --- /dev/null +++ b/frontend/src/components/media-management/MediaManagementPage.jsx @@ -0,0 +1,259 @@ +import React, { useState, useEffect } from 'react'; +import { MediaManagement } from '@api/mediaManagement'; +import Alert from '@ui/Alert'; +import { Loader, RefreshCw } from 'lucide-react'; +import MiscSettings from './MiscSettings'; +import NamingSettings from './NamingSettings'; +import QualityDefinitions from './QualityDefinitions'; +import SyncModal from './SyncModal'; + +const loadingMessages = [ + 'Configuring media management...', + 'Organizing file naming rules...', + 'Setting up quality definitions...', + 'Preparing media settings...', + 'Loading configuration options...', + 'Initializing management tools...' +]; + +const LoadingState = () => ( +
+ +

+ { + loadingMessages[ + Math.floor(Math.random() * loadingMessages.length) + ] + } +

+
+); + +const MediaManagementPage = () => { + const [activeTab, setActiveTab] = useState('radarr'); + const [mediaData, setMediaData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [savingStates, setSavingStates] = useState({ + misc: false, + naming: false, + quality_definitions: false + }); + const [syncModal, setSyncModal] = useState({ + isOpen: false, + category: null + }); + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const data = await MediaManagement.getAll(); + setMediaData(data); + + // Set activeTab to first available arr type if current one has no data + if (data) { + if ((!data[activeTab] || Object.keys(data[activeTab]).length === 0)) { + if (data.radarr && Object.keys(data.radarr).length > 0) { + setActiveTab('radarr'); + } else if (data.sonarr && Object.keys(data.sonarr).length > 0) { + setActiveTab('sonarr'); + } + } + } + } catch (err) { + setError(err.message); + console.error('Error fetching media management data:', err); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const handleTabChange = (tab) => { + setActiveTab(tab); + }; + + const transformDataForSave = (category, data) => { + // Transform the data back to the format expected by the backend + if (category === 'misc' || category === 'naming') { + return { + radarr: activeTab === 'radarr' ? data : (mediaData.radarr?.[category] || {}), + sonarr: activeTab === 'sonarr' ? data : (mediaData.sonarr?.[category] || {}) + }; + } else if (category === 'quality_definitions') { + return { + qualityDefinitions: { + radarr: activeTab === 'radarr' ? data : (mediaData.radarr?.quality_definitions || {}), + sonarr: activeTab === 'sonarr' ? data : (mediaData.sonarr?.quality_definitions || {}) + } + }; + } + return data; + }; + + const handleSave = async (category, data) => { + setSavingStates(prev => ({ ...prev, [category]: true })); + + try { + // Transform data to match backend expectations + const transformedData = transformDataForSave(category, data); + + await MediaManagement.updateCategory(category, transformedData); + + // Update local state with new data + setMediaData(prev => ({ + ...prev, + [activeTab]: { + ...prev[activeTab], + [category]: data + } + })); + + Alert.success(`${category.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())} settings saved successfully`); + } catch (err) { + console.error(`Error saving ${category}:`, err); + Alert.error(`Failed to save ${category.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())} settings`); + } finally { + setSavingStates(prev => ({ ...prev, [category]: false })); + } + }; + + const handleSync = (category) => { + setSyncModal({ + isOpen: true, + category: category + }); + }; + + const handleSyncAll = () => { + setSyncModal({ + isOpen: true, + category: null // null means sync all categories + }); + }; + + const closeSyncModal = () => { + setSyncModal({ + isOpen: false, + category: null + }); + }; + + // Check if there's any data at all + const hasData = mediaData && ( + (mediaData.radarr && Object.keys(mediaData.radarr).length > 0) || + (mediaData.sonarr && Object.keys(mediaData.sonarr).length > 0) + ); + + return ( +
+ {/* Tab Navigation - only show if there's data */} + {hasData && ( + + )} + + {/* Loading State */} + {loading && } + + {error && ( +
+

Error loading settings: {error}

+
+ )} + + {/* Content */} + {!loading && !error && ( +
+ {/* Check if no data exists at all */} + {!hasData ? ( +
+

No media management settings found.

+

Connect to a database to sync media management settings.

+
+ ) : (!mediaData[activeTab] || Object.keys(mediaData[activeTab]).length === 0) ? ( +
+

No media management settings found.

+

Connect to a database to sync {activeTab} media management settings.

+
+ ) : ( + <> + {/* Only show categories that have data */} + {mediaData[activeTab]?.naming && Object.keys(mediaData[activeTab].naming).length > 0 && ( + handleSave('naming', data)} + onSync={() => handleSync('naming')} + isSaving={savingStates.naming} + /> + )} + {mediaData[activeTab]?.misc && Object.keys(mediaData[activeTab].misc).length > 0 && ( + handleSave('misc', data)} + onSync={() => handleSync('misc')} + isSaving={savingStates.misc} + /> + )} + {mediaData[activeTab]?.quality_definitions && Object.keys(mediaData[activeTab].quality_definitions).length > 0 && ( + handleSave('quality_definitions', data)} + onSync={() => handleSync('quality_definitions')} + isSaving={savingStates.quality_definitions} + /> + )} + + )} +
+ )} + + {/* Sync Modal */} + +
+ ); +}; + +export default MediaManagementPage; \ No newline at end of file diff --git a/frontend/src/components/media-management/MiscSettings.jsx b/frontend/src/components/media-management/MiscSettings.jsx new file mode 100644 index 0000000..c603414 --- /dev/null +++ b/frontend/src/components/media-management/MiscSettings.jsx @@ -0,0 +1,92 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import CategoryContainer from './CategoryContainer'; +import Dropdown from '../ui/Dropdown'; + +const MiscSettings = ({ data, arrType, onSave, onSync, isSaving }) => { + const [localData, setLocalData] = useState({ + propersRepacks: 'doNotPrefer', + enableMediaInfo: true + }); + + useEffect(() => { + if (data) { + setLocalData(data); + } + }, [data]); + + const handleChange = (field, value) => { + setLocalData(prev => ({ + ...prev, + [field]: value + })); + }; + + const handleSave = () => { + onSave && onSave(localData); + }; + + const handleSync = () => { + onSync && onSync(); + }; + + return ( + +
+
+ + handleChange('propersRepacks', e.target.value)} + options={[ + { value: 'preferAndUpgrade', label: 'Prefer and Upgrade' }, + { value: 'doNotUpgrade', label: 'Do Not Upgrade' }, + { value: 'doNotPrefer', label: 'Do Not Prefer' } + ]} + placeholder="Select option" + /> +

+ Choose how to handle proper and repack releases. Do Not Prefer is needed to allow custom formats to work properly. +

+
+ +
+ +

+ Extract video information such as resolution, runtime and codec information from files. +

+
+
+
+ ); +}; + +MiscSettings.propTypes = { + data: PropTypes.shape({ + propersRepacks: PropTypes.string, + enableMediaInfo: PropTypes.bool + }), + arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired, + onSave: PropTypes.func, + onSync: PropTypes.func, + isSaving: PropTypes.bool +}; + +export default MiscSettings; \ No newline at end of file diff --git a/frontend/src/components/media-management/NamingSettings.jsx b/frontend/src/components/media-management/NamingSettings.jsx new file mode 100644 index 0000000..b8f5242 --- /dev/null +++ b/frontend/src/components/media-management/NamingSettings.jsx @@ -0,0 +1,238 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import CategoryContainer from './CategoryContainer'; +import MonospaceInput from '../ui/MonospaceInput'; +import Dropdown from '../ui/Dropdown'; +import { Info } from 'lucide-react'; + +const NamingSettings = ({ data, arrType, onSave, onSync, isSaving }) => { + const [localData, setLocalData] = useState({}); + + useEffect(() => { + if (data) { + setLocalData(data); + } + }, [data]); + + const handleChange = (field, value) => { + setLocalData(prev => ({ + ...prev, + [field]: value + })); + }; + + const handleSave = () => { + onSave && onSave(localData); + }; + + const handleSync = () => { + onSync && onSync(); + }; + + const colonReplacementOptions = arrType === 'radarr' + ? [ + { value: 'delete', label: 'Delete' }, + { value: 'dash', label: 'Replace with Dash' }, + { value: 'spaceDash', label: 'Replace with Space Dash' }, + { value: 'spaceDashSpace', label: 'Replace with Space Dash Space' }, + { value: 'smart', label: 'Smart Replace' } + ] + : [ + { value: 0, label: 'Delete' }, + { value: 1, label: 'Replace with Dash' }, + { value: 2, label: 'Replace with Space Dash' }, + { value: 3, label: 'Replace with Space Dash Space' }, + { value: 4, label: 'Smart Replace' }, + { value: 5, label: 'Custom' } + ]; + + return ( + +
+
+ +
+ + {arrType === 'radarr' ? ( + <> +
+ + handleChange('movieFormat', e.target.value)} + disabled={!localData.rename} + rows={2} + /> +
+ +
+ + handleChange('movieFolderFormat', e.target.value)} + /> +
+ + ) : ( + <> +
+ + handleChange('standardEpisodeFormat', e.target.value)} + disabled={!localData.rename} + rows={2} + /> +
+ +
+ + handleChange('dailyEpisodeFormat', e.target.value)} + disabled={!localData.rename} + rows={2} + /> +
+ +
+ + handleChange('animeEpisodeFormat', e.target.value)} + disabled={!localData.rename} + rows={2} + /> +
+ +
+ + handleChange('seriesFolderFormat', e.target.value)} + /> +
+ +
+ + handleChange('seasonFolderFormat', e.target.value)} + /> +
+ + )} + +
+ +
+ +
+ + handleChange('colonReplacementFormat', arrType === 'sonarr' ? parseInt(e.target.value) : e.target.value)} + options={colonReplacementOptions} + disabled={!localData.replaceIllegalCharacters} + placeholder="Select replacement" + /> +
+ + {arrType === 'sonarr' && localData.colonReplacementFormat === 5 && ( +
+ + handleChange('customColonReplacementFormat', e.target.value)} + placeholder="Enter custom replacement" + disabled={!localData.replaceIllegalCharacters} + /> +
+ )} + + {arrType === 'sonarr' && ( +
+ + handleChange('multiEpisodeStyle', parseInt(e.target.value))} + options={[ + { value: 0, label: 'Extend' }, + { value: 1, label: 'Duplicate' }, + { value: 2, label: 'Repeat' }, + { value: 3, label: 'Scene' }, + { value: 4, label: 'Range' }, + { value: 5, label: 'Prefixed Range' } + ]} + placeholder="Select style" + /> +
+ )} + + {/* Disclaimer */} +
+ +

+ Please ensure all formats follow {arrType === 'radarr' ? 'Radarr' : 'Sonarr'}'s naming requirements. + Profilarr does not validate formats before syncing. +

+
+
+
+ ); +}; + +NamingSettings.propTypes = { + data: PropTypes.object, + arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired, + onSave: PropTypes.func, + onSync: PropTypes.func, + isSaving: PropTypes.bool +}; + +export default NamingSettings; \ No newline at end of file diff --git a/frontend/src/components/media-management/QualityDefinitions.jsx b/frontend/src/components/media-management/QualityDefinitions.jsx new file mode 100644 index 0000000..96e4aba --- /dev/null +++ b/frontend/src/components/media-management/QualityDefinitions.jsx @@ -0,0 +1,229 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import CategoryContainer from './CategoryContainer'; +import QualityGroup from './QualityGroup'; +import QualityItem from './QualityItem'; +import Dropdown from '../ui/Dropdown'; +import { Info } from 'lucide-react'; + +const QualityDefinitions = ({ data, arrType, onSave, onSync, isSaving }) => { + const [expandedGroups, setExpandedGroups] = useState({}); + const [localData, setLocalData] = useState({}); + const [viewMode, setViewMode] = useState('mbPerMin'); + + useEffect(() => { + if (data) { + setLocalData(data); + } + }, [data]); + + const handleQualityChange = (qualityName, newSettings) => { + setLocalData(prev => ({ + ...prev, + [qualityName]: newSettings + })); + }; + + const handleSave = () => { + onSave && onSave(localData); + }; + + const handleSync = () => { + onSync && onSync(); + }; + + const toggleGroup = (group) => { + setExpandedGroups(prev => ({ + ...prev, + [group]: !prev[group] + })); + }; + + const viewModeOptions = [ + { value: 'mbPerMin', label: 'Megabytes per minute' }, + { value: 'gbPer20min', label: 'Gigabytes per 20min' }, + { value: 'gbPer30min', label: 'Gigabytes per 30min' }, + { value: 'gbPer40min', label: 'Gigabytes per 40min' }, + { value: 'gbPerHour', label: 'Gigabytes per hour' }, + { value: 'gbPer90min', label: 'Gigabytes per 1h 30min' }, + { value: 'gbPer2hr', label: 'Gigabytes per 2 hours' }, + { value: 'gbPer150min', label: 'Gigabytes per 2h 30min' }, + { value: 'gbPer3hr', label: 'Gigabytes per 3 hours' }, + { value: 'mbps', label: 'Megabits per second' } + ]; + + // Convert MB/min to display value based on view mode + const convertValue = (mbPerMin) => { + if (!mbPerMin && mbPerMin !== 0) return 0; + + switch (viewMode) { + case 'gbPer20min': + return (mbPerMin * 20 / 1024).toFixed(2); + case 'gbPer30min': + return (mbPerMin * 30 / 1024).toFixed(2); + case 'gbPer40min': + return (mbPerMin * 40 / 1024).toFixed(2); + case 'gbPerHour': + return (mbPerMin * 60 / 1024).toFixed(2); + case 'gbPer90min': + return (mbPerMin * 90 / 1024).toFixed(2); + case 'gbPer2hr': + return (mbPerMin * 120 / 1024).toFixed(2); + case 'gbPer150min': + return (mbPerMin * 150 / 1024).toFixed(2); + case 'gbPer3hr': + return (mbPerMin * 180 / 1024).toFixed(2); + case 'mbps': + return (mbPerMin * 8 / 60).toFixed(1); + default: // mbPerMin + return mbPerMin; + } + }; + + // Convert display value back to MB/min for storage + const convertBack = (displayValue) => { + const value = parseFloat(displayValue) || 0; + + switch (viewMode) { + case 'gbPer20min': + return Math.round(value * 1024 / 20); + case 'gbPer30min': + return Math.round(value * 1024 / 30); + case 'gbPer40min': + return Math.round(value * 1024 / 40); + case 'gbPerHour': + return Math.round(value * 1024 / 60); + case 'gbPer90min': + return Math.round(value * 1024 / 90); + case 'gbPer2hr': + return Math.round(value * 1024 / 120); + case 'gbPer150min': + return Math.round(value * 1024 / 150); + case 'gbPer3hr': + return Math.round(value * 1024 / 180); + case 'mbps': + return Math.round(value * 60 / 8); + default: // mbPerMin + return Math.round(value); + } + }; + + // Get unit label for current view mode + const getUnitLabel = () => { + switch (viewMode) { + case 'mbPerMin': + return 'MB/min'; + case 'gbPer20min': + return 'GB/20min'; + case 'gbPer30min': + return 'GB/30min'; + case 'gbPer40min': + return 'GB/40min'; + case 'gbPerHour': + return 'GB/hr'; + case 'gbPer90min': + return 'GB/90min'; + case 'gbPer2hr': + return 'GB/2hr'; + case 'gbPer150min': + return 'GB/2.5hr'; + case 'gbPer3hr': + return 'GB/3hr'; + case 'mbps': + return 'Mbps'; + default: + return ''; + } + }; + + // Group qualities by resolution + const groupQualities = (qualities) => { + const groups = { + 'Prereleases': ['WORKPRINT', 'CAM', 'TELESYNC', 'TELECINE', 'DVDSCR'], + 'SD': ['DVD', 'DVD-R', 'WEBDL-480p', 'WEBRip-480p', 'Bluray-480p', 'Bluray-576p', 'SDTV'], + 'HD-720p': ['HDTV-720p', 'WEBDL-720p', 'WEBRip-720p', 'Bluray-720p'], + 'HD-1080p': ['HDTV-1080p', 'WEBDL-1080p', 'WEBRip-1080p', 'Bluray-1080p', 'Remux-1080p', 'Bluray-1080p Remux'], + 'UHD-2160p': ['HDTV-2160p', 'WEBDL-2160p', 'WEBRip-2160p', 'Bluray-2160p', 'Remux-2160p', 'Bluray-2160p Remux'], + 'Other': ['BR-DISK', 'Raw-HD', 'Unknown', 'REGIONAL'] + }; + + const grouped = {}; + Object.entries(groups).forEach(([groupName, qualityNames]) => { + const groupQualities = {}; + qualityNames.forEach(name => { + if (qualities && qualities[name]) { + groupQualities[name] = qualities[name]; + } + }); + if (Object.keys(groupQualities).length > 0) { + grouped[groupName] = groupQualities; + } + }); + + return grouped; + }; + + const qualityGroups = groupQualities(localData); + + return ( + + {/* View Mode Selector and Disclaimer */} +
+
+ +
+

Preferred size is rarely used when combining quality profiles with custom formats.

+

Min/max values are useful for setting absolute limits on what can be grabbed.

+
+
+
+ setViewMode(e.target.value)} + options={viewModeOptions} + /> +
+
+ +
+ {Object.entries(qualityGroups).map(([groupName, qualities]) => ( + toggleGroup(groupName)} + unitLabel={getUnitLabel()} + > + {Object.entries(qualities).map(([qualityName, settings]) => ( + handleQualityChange(qualityName, newSettings)} + /> + ))} + + ))} +
+
+ ); +}; + +QualityDefinitions.propTypes = { + data: PropTypes.object, + arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired, + onSave: PropTypes.func, + onSync: PropTypes.func, + isSaving: PropTypes.bool +}; + +export default QualityDefinitions; \ No newline at end of file diff --git a/frontend/src/components/media-management/QualityGroup.jsx b/frontend/src/components/media-management/QualityGroup.jsx new file mode 100644 index 0000000..be683e6 --- /dev/null +++ b/frontend/src/components/media-management/QualityGroup.jsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +const QualityGroup = ({ title, children, isExpanded = false, onToggle, unitLabel }) => { + return ( +
+ {/* Header Section */} + + + {/* Body Section */} + {isExpanded && ( +
+ + + + + + + + + + + + {children} + +
QualityRange ({unitLabel})MinPreferredMax
+
+ )} +
+ ); +}; + +QualityGroup.propTypes = { + title: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + isExpanded: PropTypes.bool, + onToggle: PropTypes.func.isRequired, + unitLabel: PropTypes.string.isRequired +}; + +export default QualityGroup; \ No newline at end of file diff --git a/frontend/src/components/media-management/QualityItem.jsx b/frontend/src/components/media-management/QualityItem.jsx new file mode 100644 index 0000000..7b61b2f --- /dev/null +++ b/frontend/src/components/media-management/QualityItem.jsx @@ -0,0 +1,257 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import NumberInput from '../ui/NumberInput'; +import Slider from '../ui/Slider'; + +const QualityItem = ({ name, settings, arrType, viewMode, convertValue, convertBack, onChange, disabled = false }) => { + const handleInputChange = (field, value) => { + // Convert display value back to MB/min + let numValue = convertBack(value); + + // Validate and clamp values based on field + if (field === 'min') { + // Min must be >= 0 + numValue = Math.max(0, numValue); + // Min cannot exceed max - 20 (to leave room for preferred) + if (settings.max) { + numValue = Math.min(numValue, settings.max - 20); + } + } else if (field === 'preferred') { + // Preferred must be at least min + 10 + if (settings.min !== undefined) { + numValue = Math.max(settings.min + 10, numValue); + } + // Preferred must be at most max - 10 + if (settings.max !== undefined) { + numValue = Math.min(settings.max - 10, numValue); + } + } else if (field === 'max') { + // Max cannot exceed the arr type limit + numValue = Math.min(numValue, maxValue); + // Max must be at least min + 20 (to leave room for preferred) + if (settings.min !== undefined) { + numValue = Math.max(settings.min + 20, numValue); + } + } + + // Create updated settings + const updatedSettings = { + ...settings, + [field]: numValue + }; + + // If we changed min or max, we might need to adjust preferred + if (field === 'min' || field === 'max') { + const currentPreferred = updatedSettings.preferred || 0; + const minPreferred = (updatedSettings.min || 0) + 10; + const maxPreferred = (updatedSettings.max || maxValue) - 10; + + if (currentPreferred < minPreferred) { + updatedSettings.preferred = minPreferred; + } else if (currentPreferred > maxPreferred) { + updatedSettings.preferred = maxPreferred; + } + } + + onChange(updatedSettings); + }; + + const handleSliderChange = (values) => { + // Apply the same validation as input changes + let { min, preferred, max } = values; + + // Clamp min to valid range + min = Math.max(0, Math.min(min, maxValue - 20)); + + // Clamp max to valid range + max = Math.max(min + 20, Math.min(max, maxValue)); + + // Clamp preferred to valid range + preferred = Math.max(min + 10, Math.min(preferred, max - 10)); + + onChange({ + min, + preferred, + max + }); + }; + + // Set max value based on arr type + const maxValue = arrType === 'sonarr' ? 1000 : 2000; + + // Convert values for display + const displayMin = convertValue(settings.min || 0); + const displayPreferred = convertValue(settings.preferred || 0); + const displayMax = convertValue(settings.max || 0); + const displayMaxLimit = convertValue(maxValue); + + // Mobile layout + const [isMobile, setIsMobile] = React.useState(false); + + React.useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 640); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + if (isMobile) { + return ( + + +
+ {/* Quality name */} +
+ + {name} + +
+ + {/* Slider */} +
+ +
+ + {/* Number inputs row */} +
+
+ + handleInputChange('min', value)} + min={0} + max={displayMaxLimit} + disabled={disabled} + step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)} + className="text-xs" + /> +
+
+ + handleInputChange('preferred', value)} + min={0} + max={displayMaxLimit} + disabled={disabled} + step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)} + className="text-xs" + /> +
+
+ + handleInputChange('max', value)} + min={0} + max={displayMaxLimit} + disabled={disabled} + step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)} + className="text-xs" + /> +
+
+
+ + + ); + } + + // Desktop layout + return ( + + {/* Quality Name */} + + + {name} + + + + {/* Slider Section */} + + + + + {/* Min Input */} + +
+ handleInputChange('min', value)} + min={0} + max={displayMaxLimit} + disabled={disabled} + step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)} + /> +
+ + + {/* Preferred Input */} + +
+ handleInputChange('preferred', value)} + min={0} + max={displayMaxLimit} + disabled={disabled} + step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)} + /> +
+ + + {/* Max Input */} + +
+ handleInputChange('max', value)} + min={0} + max={displayMaxLimit} + disabled={disabled} + step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)} + /> +
+ + + ); +}; + +QualityItem.propTypes = { + name: PropTypes.string.isRequired, + settings: PropTypes.shape({ + min: PropTypes.number, + preferred: PropTypes.number, + max: PropTypes.number + }).isRequired, + arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired, + viewMode: PropTypes.string.isRequired, + convertValue: PropTypes.func.isRequired, + convertBack: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool +}; + +export default QualityItem; \ No newline at end of file diff --git a/frontend/src/components/media-management/SyncModal.jsx b/frontend/src/components/media-management/SyncModal.jsx new file mode 100644 index 0000000..5695172 --- /dev/null +++ b/frontend/src/components/media-management/SyncModal.jsx @@ -0,0 +1,212 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import Modal from '../ui/Modal'; +import { getArrConfigs } from '@api/arr'; +import { syncMediaManagement } from '@api/mediaManagement'; +import Alert from '@ui/Alert'; +import { Loader, RefreshCw, Server, Check } from 'lucide-react'; + +const SyncModal = ({ isOpen, onClose, arrType, category = null }) => { + const [arrConfigs, setArrConfigs] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedConfigs, setSelectedConfigs] = useState([]); + const [syncing, setSyncing] = useState(false); + + useEffect(() => { + if (isOpen) { + setSelectedConfigs([]); // Reset selection when modal opens + fetchArrConfigs(); + } + }, [isOpen, arrType]); + + const fetchArrConfigs = async () => { + setLoading(true); + try { + const response = await getArrConfigs(); + + // Backend returns { success: true, data: [...configs...] } + if (response.success && response.data) { + // Filter configs by the current arr type (radarr or sonarr) + const filteredConfigs = response.data.filter(config => + config.type.toLowerCase() === arrType.toLowerCase() + ); + setArrConfigs(filteredConfigs); + } else { + console.error('API returned unsuccessful response:', response); + Alert.error(response.error || 'Failed to load arr configurations'); + setArrConfigs([]); + } + } catch (error) { + console.error('Error fetching arr configs:', error); + Alert.error('Failed to load arr configurations'); + setArrConfigs([]); + } finally { + setLoading(false); + } + }; + + const handleConfigToggle = (configId) => { + setSelectedConfigs(prev => + prev.includes(configId) + ? prev.filter(id => id !== configId) + : [...prev, configId] + ); + }; + + const handleSelectAll = () => { + if (selectedConfigs.length === arrConfigs.length) { + setSelectedConfigs([]); + } else { + setSelectedConfigs(arrConfigs.map(config => config.id)); + } + }; + + const handleSync = async () => { + if (selectedConfigs.length === 0) { + Alert.warning('Please select at least one arr instance to sync with'); + return; + } + + const syncCategories = category ? [category] : ['misc', 'naming', 'quality_definitions']; + const syncType = category ? `${category} settings` : 'all settings'; + + setSyncing(true); + try { + // Sync to each selected arr instance + const syncPromises = selectedConfigs.map(async (configId) => { + try { + const result = await syncMediaManagement(configId, syncCategories); + return { configId, success: result.success, results: result.results }; + } catch (error) { + return { configId, success: false, error: error.message }; + } + }); + + const results = await Promise.all(syncPromises); + + // Show results + const successCount = results.filter(r => r.success).length; + const totalCount = results.length; + + if (successCount === totalCount) { + Alert.success(`Successfully synced ${syncType} to ${successCount} arr instance(s)`); + } else if (successCount > 0) { + Alert.warning(`Synced ${syncType} to ${successCount}/${totalCount} arr instances. Check logs for details.`); + } else { + Alert.error(`Failed to sync ${syncType} to any arr instances. Check logs for details.`); + } + + console.log('Sync results:', results); + onClose(); + + } catch (error) { + console.error('Error during sync:', error); + Alert.error('An error occurred during sync'); + } finally { + setSyncing(false); + } + }; + + const modalTitle = category + ? `Sync ${category.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())} - ${arrType.charAt(0).toUpperCase() + arrType.slice(1)}` + : `Sync All Settings - ${arrType.charAt(0).toUpperCase() + arrType.slice(1)}`; + + return ( + + + + } + > + {loading ? ( +
+ + Loading... +
+ ) : arrConfigs.length === 0 ? ( +
+

No {arrType} instances configured

+
+ ) : ( +
+ + + + + + + + + + {arrConfigs.map((config, index) => ( + handleConfigToggle(config.id)} + > + + + + + ))} + +
NameTagsSelect
+ {config.name} + + {config.tags && config.tags.length > 0 ? ( +
+ {config.tags.map(tag => ( + + {tag} + + ))} +
+ ) : ( + + )} +
+
+
+ {selectedConfigs.includes(config.id) && ( + + )} +
+
+
+
+ )} +
+ ); +}; + +SyncModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired, + category: PropTypes.string // null for sync all, or specific category name +}; + +export default SyncModal; \ No newline at end of file diff --git a/frontend/src/components/settings/git/status/ChangeRow.jsx b/frontend/src/components/settings/git/status/ChangeRow.jsx index d703f7c..279fc42 100644 --- a/frontend/src/components/settings/git/status/ChangeRow.jsx +++ b/frontend/src/components/settings/git/status/ChangeRow.jsx @@ -10,7 +10,8 @@ import { FileText, Settings, File, - Check + Check, + Cog } from 'lucide-react'; import Tooltip from '@ui/Tooltip'; import ViewChanges from './ViewChanges'; @@ -56,6 +57,8 @@ const ChangeRow = ({ return ; case 'Quality Profile': return ; + case 'Media Management': + return ; default: return ; } diff --git a/frontend/src/components/settings/git/status/PushRow.jsx b/frontend/src/components/settings/git/status/PushRow.jsx index 3f3365e..eec7c6c 100644 --- a/frontend/src/components/settings/git/status/PushRow.jsx +++ b/frontend/src/components/settings/git/status/PushRow.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import {GitCommit, Code, FileText, Settings, File} from 'lucide-react'; +import {GitCommit, Code, FileText, Settings, File, Cog} from 'lucide-react'; const PushRow = ({change}) => { const getTypeIcon = type => { @@ -10,6 +10,8 @@ const PushRow = ({change}) => { return ; case 'Quality Profile': return ; + case 'Media Management': + return ; default: return ; } diff --git a/frontend/src/components/settings/git/status/ViewChanges.jsx b/frontend/src/components/settings/git/status/ViewChanges.jsx index 0fef1d6..3bdfa1d 100644 --- a/frontend/src/components/settings/git/status/ViewChanges.jsx +++ b/frontend/src/components/settings/git/status/ViewChanges.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Modal from '@ui/Modal'; import DiffCommit from './DiffCommit'; -import {FileText} from 'lucide-react'; +import {FileText, Info} from 'lucide-react'; import useChangeParser from '@hooks/useChangeParser'; import {COMMIT_TYPES, FILE_TYPES, COMMIT_SCOPES} from '@constants/commits'; import Tooltip from '@ui/Tooltip'; @@ -67,52 +67,65 @@ const ViewChanges = ({isOpen, onClose, change, isIncoming}) => { )} -
- - - - - - - - - - - {parsedChanges.map(item => ( - - - - - + {parsedChanges.length > 0 ? ( +
+
- Change - - Key - - Previous - - Current -
- {item.changeType} - - - {item.key} - - -
- {item.from} -
-
-
- {item.to} -
-
+ + + + + + - ))} - -
+ Change + + Key + + Previous + + Current +
-
+ + + {parsedChanges.map(item => ( + + + {item.changeType} + + + + {item.key} + + + +
+ {item.from} +
+ + +
+ {item.to} +
+ + + ))} + + + + ) : ( +
+
+ + Formatting Changes Only + + + Only whitespace, indentation, or syntax changes detected. No content values were changed. + +
+
+ )} ); diff --git a/frontend/src/components/ui/DataBar/AddButton.jsx b/frontend/src/components/ui/DataBar/AddButton.jsx index 546e49d..ba8bb83 100644 --- a/frontend/src/components/ui/DataBar/AddButton.jsx +++ b/frontend/src/components/ui/DataBar/AddButton.jsx @@ -5,7 +5,7 @@ const AddButton = ({onClick, label = 'Add New'}) => { return ( ); }; diff --git a/frontend/src/components/ui/DataBar/DataBar.jsx b/frontend/src/components/ui/DataBar/DataBar.jsx index 34bb8df..2823317 100644 --- a/frontend/src/components/ui/DataBar/DataBar.jsx +++ b/frontend/src/components/ui/DataBar/DataBar.jsx @@ -9,7 +9,7 @@ const FloatingBar = ({children}) => ( <>
-
{children}
+
{children}
@@ -39,13 +39,30 @@ const DataBar = ({ className }) => { const [isFloating, setIsFloating] = useState(false); + const [isMobile, setIsMobile] = useState(() => { + if (typeof window !== 'undefined') { + return window.innerWidth < 768; + } + return false; + }); + const [isSearchFocused, setIsSearchFocused] = useState(false); useEffect(() => { const handleScroll = () => { setIsFloating(window.scrollY > 64); }; + const handleResize = () => { + setIsMobile(window.innerWidth < 768); + }; + + handleResize(); window.addEventListener('scroll', handleScroll, {passive: true}); - return () => window.removeEventListener('scroll', handleScroll); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleResize); + }; }, []); const controls = ( @@ -57,11 +74,14 @@ const DataBar = ({ onAddTerm={onAddTerm} onRemoveTerm={onRemoveTerm} onClearTerms={onClearTerms} - placeholder={searchPlaceholder} - className='flex-1' + placeholder={isMobile ? 'Search' : searchPlaceholder} + className={`${isMobile && isSearchFocused ? 'w-full' : 'flex-1'}`} requireEnter + onFocus={() => setIsSearchFocused(true)} + onBlur={() => setIsSearchFocused(false)} /> -
+ {(!isMobile || !isSearchFocused) && ( +
+ )} ); @@ -95,7 +116,7 @@ const DataBar = ({ return (
-
{controls}
+
{controls}
); }; diff --git a/frontend/src/components/ui/DataBar/FilterMenu.jsx b/frontend/src/components/ui/DataBar/FilterMenu.jsx index 63b3b37..5c00433 100644 --- a/frontend/src/components/ui/DataBar/FilterMenu.jsx +++ b/frontend/src/components/ui/DataBar/FilterMenu.jsx @@ -38,7 +38,7 @@ function FilterMenu({ {isOpen && ( diff --git a/frontend/src/components/ui/DataBar/ToggleSelectButton.jsx b/frontend/src/components/ui/DataBar/ToggleSelectButton.jsx index 443f043..67099e5 100644 --- a/frontend/src/components/ui/DataBar/ToggleSelectButton.jsx +++ b/frontend/src/components/ui/DataBar/ToggleSelectButton.jsx @@ -6,7 +6,7 @@ const ToggleSelectButton = ({isSelectionMode, onClick, shortcutKey = 'A'}) => { ); }; diff --git a/frontend/src/components/ui/Dropdown.jsx b/frontend/src/components/ui/Dropdown.jsx new file mode 100644 index 0000000..da732fa --- /dev/null +++ b/frontend/src/components/ui/Dropdown.jsx @@ -0,0 +1,110 @@ +import React, { useState, useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { ChevronDown } from 'lucide-react'; + +const Dropdown = ({ + value, + onChange, + options = [], + placeholder = 'Select an option', + disabled = false, + className = '' +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const selectedOption = options.find(opt => opt.value === value); + + const handleSelect = (option) => { + onChange({ target: { value: option.value } }); + setIsOpen(false); + }; + + const baseClasses = ` + relative w-full px-4 py-2.5 + bg-gray-900/50 + border border-gray-700/50 + rounded + text-[13px] text-left + transition-all duration-200 + flex items-center justify-between + cursor-pointer + outline-none + `; + + const stateClasses = disabled + ? 'text-gray-500 cursor-not-allowed opacity-60' + : `text-gray-200 + hover:border-gray-600 hover:bg-gray-900/70 + focus:bg-gray-900 focus:border-blue-400 focus:outline-none + placeholder:text-gray-600`; + + return ( +
+ + + {isOpen && !disabled && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +}; + +Dropdown.propTypes = { + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + onChange: PropTypes.func.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + label: PropTypes.string.isRequired + })).isRequired, + placeholder: PropTypes.string, + disabled: PropTypes.bool, + className: PropTypes.string +}; + +export default Dropdown; \ No newline at end of file diff --git a/frontend/src/components/ui/Modal.jsx b/frontend/src/components/ui/Modal.jsx index f541275..5eed595 100644 --- a/frontend/src/components/ui/Modal.jsx +++ b/frontend/src/components/ui/Modal.jsx @@ -140,7 +140,7 @@ const Modal = ({ {title} {tabs && ( -
+
+ className={`${tabs ? '' : 'ml-auto'} text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors`}> { + const baseClasses = ` + w-full px-4 py-2.5 + bg-gray-900/50 + border border-gray-700/50 + rounded + font-mono text-[13px] + transition-all duration-200 + outline-none + `; + + const stateClasses = disabled + ? 'text-gray-500 cursor-not-allowed opacity-60' + : `text-gray-200 + hover:border-gray-600 hover:bg-gray-900/70 + focus:bg-gray-900 focus:border-blue-400 focus:outline-none focus-visible:outline-none + placeholder:text-gray-600`; + + const combinedClasses = `${baseClasses} ${stateClasses} ${className}`; + + if (rows > 1) { + return ( + + ); + } + + return ( + + ); +}; + +MonospaceInput.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func, + placeholder: PropTypes.string, + disabled: PropTypes.bool, + rows: PropTypes.number, + className: PropTypes.string +}; + +export default MonospaceInput; \ No newline at end of file diff --git a/frontend/src/components/ui/Navbar.jsx b/frontend/src/components/ui/Navbar.jsx index b091622..f5c77aa 100644 --- a/frontend/src/components/ui/Navbar.jsx +++ b/frontend/src/components/ui/Navbar.jsx @@ -37,6 +37,7 @@ ToggleSwitch.propTypes = { function Navbar({darkMode, setDarkMode}) { const [tabOffset, setTabOffset] = useState(0); const [tabWidth, setTabWidth] = useState(0); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const tabsRef = useRef({}); const location = useLocation(); const [isInitialized, setIsInitialized] = useState(false); @@ -46,6 +47,7 @@ function Navbar({darkMode, setDarkMode}) { if (pathname.startsWith('/regex')) return 'regex'; if (pathname.startsWith('/format')) return 'format'; if (pathname.startsWith('/profile')) return 'profile'; + if (pathname.startsWith('/media-management')) return 'media-management'; if (pathname.startsWith('/settings')) return 'settings'; return 'settings'; }; @@ -75,18 +77,22 @@ function Navbar({darkMode, setDarkMode}) { return () => resizeObserver.disconnect(); }, [activeTab]); + useEffect(() => { + setIsMobileMenuOpen(false); + }, [location]); + return ( -
-
+

profilarr

-
+
{isInitialized && (
Quality Profiles + (tabsRef.current['media-management'] = el)} + className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${ + activeTab === 'media-management' + ? 'text-white' + : 'text-gray-300 hover:bg-gray-700 hover:text-white' + }`}> + Media Management + (tabsRef.current['settings'] = el)} @@ -137,11 +153,84 @@ function Navbar({darkMode, setDarkMode}) {
- setDarkMode(!darkMode)} - /> +
+ setDarkMode(!darkMode)} + /> +
+
+ {isMobileMenuOpen && ( +
+
+ + Regex Patterns + + + Custom Formats + + + Quality Profiles + + + Media Management + + + Settings + +
+
+ setDarkMode(!darkMode)} + /> +
+
+ )}
); diff --git a/frontend/src/components/ui/NumberInput.jsx b/frontend/src/components/ui/NumberInput.jsx index d636999..e5c8430 100644 --- a/frontend/src/components/ui/NumberInput.jsx +++ b/frontend/src/components/ui/NumberInput.jsx @@ -71,13 +71,12 @@ const NumberInput = ({ }; const inputClasses = [ - 'w-20 h-8 px-2 py-1 text-sm border border-gray-700', + 'w-full h-8 px-2 py-1 text-sm border border-gray-700', 'rounded-l focus:outline-none text-left', 'bg-gray-800', isFocused ? 'text-blue-400' : 'text-gray-300', '[appearance:textfield]', - disabled && 'opacity-50 cursor-not-allowed', - className + disabled && 'opacity-50 cursor-not-allowed' ] .filter(Boolean) .join(' '); diff --git a/frontend/src/components/ui/Slider.jsx b/frontend/src/components/ui/Slider.jsx new file mode 100644 index 0000000..6d39dc6 --- /dev/null +++ b/frontend/src/components/ui/Slider.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import RcSlider from 'rc-slider'; +import 'rc-slider/assets/index.css'; + +const CustomSlider = ({ min = 0, max = 2000, minValue, preferredValue, maxValue, onChange, disabled = false }) => { + const handleChange = (values) => { + const minGap = 10; + let [newMin, newPreferred, newMax] = values; + + // Ensure minimum gaps between handles + if (newPreferred - newMin < minGap) { + newPreferred = newMin + minGap; + } + if (newMax - newPreferred < minGap) { + newMax = newPreferred + minGap; + } + + // Ensure we don't exceed bounds + if (newMax > max) { + newMax = max; + newPreferred = Math.min(newPreferred, newMax - minGap); + newMin = Math.min(newMin, newPreferred - minGap); + } + + onChange({ + min: newMin, + preferred: newPreferred, + max: newMax + }); + }; + + return ( +
+ +
+ ); +}; + +CustomSlider.propTypes = { + min: PropTypes.number, + max: PropTypes.number, + minValue: PropTypes.number.isRequired, + preferredValue: PropTypes.number.isRequired, + maxValue: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool +}; + +export default CustomSlider; \ No newline at end of file diff --git a/frontend/src/components/ui/TabViewer.jsx b/frontend/src/components/ui/TabViewer.jsx index 280aade..682034e 100644 --- a/frontend/src/components/ui/TabViewer.jsx +++ b/frontend/src/components/ui/TabViewer.jsx @@ -5,6 +5,13 @@ const TabViewer = ({tabs, activeTab, onTabChange}) => { const [tabWidth, setTabWidth] = useState(0); const tabsRef = useRef({}); const [isInitialized, setIsInitialized] = useState(false); + const [isMobile, setIsMobile] = useState(() => { + if (typeof window !== 'undefined') { + return window.innerWidth < 768; + } + return false; + }); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); const updateTabPosition = () => { if (tabsRef.current[activeTab]) { @@ -29,8 +36,58 @@ const TabViewer = ({tabs, activeTab, onTabChange}) => { return () => resizeObserver.disconnect(); }, [activeTab]); + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth < 768); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + setIsDropdownOpen(false); + }, [activeTab]); + if (!tabs?.length) return null; + const activeTabLabel = tabs.find(tab => tab.id === activeTab)?.label || ''; + + if (isMobile) { + return ( +
+ + {isDropdownOpen && ( +
+ {tabs.map(tab => ( + + ))} +
+ )} +
+ ); + } + return (
{isInitialized && ( @@ -60,4 +117,4 @@ const TabViewer = ({tabs, activeTab, onTabChange}) => { ); }; -export default TabViewer; +export default TabViewer; \ No newline at end of file diff --git a/frontend/src/constants/commits.js b/frontend/src/constants/commits.js index 91734dc..300ca35 100644 --- a/frontend/src/constants/commits.js +++ b/frontend/src/constants/commits.js @@ -6,7 +6,8 @@ import { Bug, // for fix Code, // for regex FileJson, // for format - Settings // for profile + Settings, // for profile + Cog // for media management } from 'lucide-react'; export const COMMIT_TYPES = [ @@ -67,6 +68,11 @@ export const FILE_TYPES = { bg: 'bg-amber-500/10', text: 'text-amber-400', icon: Settings + }, + 'Media Management': { + bg: 'bg-orange-500/10', + text: 'text-orange-400', + icon: Cog } }; @@ -85,5 +91,10 @@ export const COMMIT_SCOPES = [ value: 'profile', label: 'Quality Profile', description: 'Changes related to quality profiles' + }, + { + value: 'media', + label: 'Media Management', + description: 'Changes related to media management settings' } ]; diff --git a/frontend/src/index.css b/frontend/src/index.css index ad013e4..e4c0eee 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -16,6 +16,17 @@ pre, font-family: 'Fira Code', monospace; } +/* Hide scrollbar for Chrome, Safari and Opera */ +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.scrollbar-hide { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + /* Custom Scrollbar for Light Mode */ .scrollable::-webkit-scrollbar { width: 8px;