mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ __pycache__/
|
||||
|
||||
# build files
|
||||
backend/app/static/
|
||||
|
||||
# Config data
|
||||
config/
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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")
|
||||
|
||||
141
backend/app/media_management/__init__.py
Normal file
141
backend/app/media_management/__init__.py
Normal file
@@ -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/<category>', 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/<category>', 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
|
||||
258
backend/app/media_management/sync.py
Normal file
258
backend/app/media_management/sync.py
Normal file
@@ -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
|
||||
211
backend/app/media_management/utils.py
Normal file
211
backend/app/media_management/utils.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
41
frontend/package-lock.json
generated
41
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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={<ProfilePage />}
|
||||
/>
|
||||
<Route
|
||||
path='/media-management'
|
||||
element={<MediaManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path='/settings'
|
||||
element={<SettingsPage />}
|
||||
|
||||
87
frontend/src/api/mediaManagement.js
Normal file
87
frontend/src/api/mediaManagement.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const BASE_URL = '/api/media-management';
|
||||
|
||||
/**
|
||||
* Get all media management data for all categories
|
||||
* @returns {Promise<Object>} 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<Object>} 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<Object>} 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<Object>} 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
|
||||
};
|
||||
43
frontend/src/components/media-management/CategoryActions.jsx
Normal file
43
frontend/src/components/media-management/CategoryActions.jsx
Normal file
@@ -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 (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onSync}
|
||||
disabled={isSyncing || isSaving}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded bg-gray-700/50 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 text-blue-500" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Sync</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={isSaving || isSyncing}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded bg-gray-700/50 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 text-blue-500" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Save</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CategoryActions.propTypes = {
|
||||
onSync: PropTypes.func.isRequired,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
isSaving: PropTypes.bool,
|
||||
isSyncing: PropTypes.bool
|
||||
};
|
||||
|
||||
export default CategoryActions;
|
||||
@@ -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 (
|
||||
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-lg mb-6 border border-gray-700">
|
||||
{/* Header Section */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={`w-full bg-gray-800/50 px-6 py-3.5 hover:bg-gray-700/50 transition-[background-color] ${
|
||||
expanded ? 'border-b border-gray-700' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h1 className="text-sm sm:text-base font-semibold text-gray-100">
|
||||
{title}
|
||||
</h1>
|
||||
{expanded ?
|
||||
<ChevronDown size={16} className="text-gray-400" /> :
|
||||
<ChevronRight size={16} className="text-gray-400" />
|
||||
}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<CategoryActions
|
||||
onSync={onSync}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
isSyncing={isSyncing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Body Section */}
|
||||
{expanded && (
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
259
frontend/src/components/media-management/MediaManagementPage.jsx
Normal file
259
frontend/src/components/media-management/MediaManagementPage.jsx
Normal file
@@ -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 = () => (
|
||||
<div className='w-full min-h-[70vh] flex flex-col items-center justify-center'>
|
||||
<Loader className='w-8 h-8 animate-spin text-blue-500 mb-4' />
|
||||
<p className='text-lg font-medium text-gray-300'>
|
||||
{
|
||||
loadingMessages[
|
||||
Math.floor(Math.random() * loadingMessages.length)
|
||||
]
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div>
|
||||
{/* Tab Navigation - only show if there's data */}
|
||||
{hasData && (
|
||||
<nav className='flex justify-between items-center my-4'>
|
||||
<div className='flex space-x-2'>
|
||||
{mediaData.radarr && Object.keys(mediaData.radarr).length > 0 && (
|
||||
<div
|
||||
onClick={() => handleTabChange('radarr')}
|
||||
className={`cursor-pointer px-3 py-2 rounded-md text-sm font-medium ${
|
||||
activeTab === 'radarr'
|
||||
? 'bg-gray-600 border border-gray-600 text-white'
|
||||
: 'bg-gray-800 border border-gray-700 text-white'
|
||||
}`}>
|
||||
Radarr
|
||||
</div>
|
||||
)}
|
||||
{mediaData.sonarr && Object.keys(mediaData.sonarr).length > 0 && (
|
||||
<div
|
||||
onClick={() => handleTabChange('sonarr')}
|
||||
className={`cursor-pointer px-3 py-2 rounded-md text-sm font-medium ${
|
||||
activeTab === 'sonarr'
|
||||
? 'bg-gray-600 border border-gray-600 text-white'
|
||||
: 'bg-gray-800 border border-gray-700 text-white'
|
||||
}`}>
|
||||
Sonarr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSyncAll}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded bg-gray-700/50 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors text-sm"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 text-blue-500" />
|
||||
<span>Sync All</span>
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <LoadingState />}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-600 rounded-lg p-4 mb-6">
|
||||
<p className="text-red-400">Error loading settings: {error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!loading && !error && (
|
||||
<div className="space-y-6">
|
||||
{/* Check if no data exists at all */}
|
||||
{!hasData ? (
|
||||
<div className="bg-gray-800/50 rounded-lg p-8 text-center mt-6">
|
||||
<p className="text-gray-400 mb-2">No media management settings found.</p>
|
||||
<p className="text-sm text-gray-500">Connect to a database to sync media management settings.</p>
|
||||
</div>
|
||||
) : (!mediaData[activeTab] || Object.keys(mediaData[activeTab]).length === 0) ? (
|
||||
<div className="bg-gray-800/50 rounded-lg p-8 text-center mt-6">
|
||||
<p className="text-gray-400 mb-2">No media management settings found.</p>
|
||||
<p className="text-sm text-gray-500">Connect to a database to sync {activeTab} media management settings.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Only show categories that have data */}
|
||||
{mediaData[activeTab]?.naming && Object.keys(mediaData[activeTab].naming).length > 0 && (
|
||||
<NamingSettings
|
||||
data={mediaData[activeTab].naming}
|
||||
arrType={activeTab}
|
||||
onSave={(data) => handleSave('naming', data)}
|
||||
onSync={() => handleSync('naming')}
|
||||
isSaving={savingStates.naming}
|
||||
/>
|
||||
)}
|
||||
{mediaData[activeTab]?.misc && Object.keys(mediaData[activeTab].misc).length > 0 && (
|
||||
<MiscSettings
|
||||
data={mediaData[activeTab].misc}
|
||||
arrType={activeTab}
|
||||
onSave={(data) => handleSave('misc', data)}
|
||||
onSync={() => handleSync('misc')}
|
||||
isSaving={savingStates.misc}
|
||||
/>
|
||||
)}
|
||||
{mediaData[activeTab]?.quality_definitions && Object.keys(mediaData[activeTab].quality_definitions).length > 0 && (
|
||||
<QualityDefinitions
|
||||
data={mediaData[activeTab].quality_definitions}
|
||||
arrType={activeTab}
|
||||
onSave={(data) => handleSave('quality_definitions', data)}
|
||||
onSync={() => handleSync('quality_definitions')}
|
||||
isSaving={savingStates.quality_definitions}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync Modal */}
|
||||
<SyncModal
|
||||
isOpen={syncModal.isOpen}
|
||||
onClose={closeSyncModal}
|
||||
arrType={activeTab}
|
||||
category={syncModal.category}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaManagementPage;
|
||||
92
frontend/src/components/media-management/MiscSettings.jsx
Normal file
92
frontend/src/components/media-management/MiscSettings.jsx
Normal file
@@ -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 (
|
||||
<CategoryContainer
|
||||
title="Miscellaneous"
|
||||
onSync={handleSync}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 dark:text-gray-300 mb-2">
|
||||
Propers and Repacks
|
||||
</label>
|
||||
<Dropdown
|
||||
value={localData.propersRepacks}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-400 dark:text-gray-400">
|
||||
Choose how to handle proper and repack releases. <span className="font-semibold text-gray-300">Do Not Prefer</span> is needed to allow custom formats to work properly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localData.enableMediaInfo}
|
||||
onChange={(e) => handleChange('enableMediaInfo', e.target.checked)}
|
||||
className="w-4 h-4 text-blue-400 bg-gray-900/50 border border-gray-700/50 rounded focus:ring-blue-400 focus:ring-1 transition-all duration-200"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-100 dark:text-gray-100">
|
||||
Analyze video files
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-2 text-xs text-gray-400 dark:text-gray-400">
|
||||
Extract video information such as resolution, runtime and codec information from files.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CategoryContainer>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
238
frontend/src/components/media-management/NamingSettings.jsx
Normal file
238
frontend/src/components/media-management/NamingSettings.jsx
Normal file
@@ -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 (
|
||||
<CategoryContainer
|
||||
title="Naming"
|
||||
onSync={handleSync}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-3 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localData.rename || false}
|
||||
onChange={(e) => handleChange('rename', e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-700 dark:bg-gray-700 border-gray-600 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-100 dark:text-gray-100">
|
||||
Rename {arrType === 'radarr' ? 'Movies' : 'Episodes'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{arrType === 'radarr' ? (
|
||||
<>
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${!localData.rename ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
|
||||
Movie Format
|
||||
</label>
|
||||
<MonospaceInput
|
||||
value={localData.movieFormat || ''}
|
||||
onChange={(e) => handleChange('movieFormat', e.target.value)}
|
||||
disabled={!localData.rename}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 dark:text-gray-300 mb-2">
|
||||
Movie Folder Format
|
||||
</label>
|
||||
<MonospaceInput
|
||||
value={localData.movieFolderFormat || ''}
|
||||
onChange={(e) => handleChange('movieFolderFormat', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${!localData.rename ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
|
||||
Standard Episode Format
|
||||
</label>
|
||||
<MonospaceInput
|
||||
value={localData.standardEpisodeFormat || ''}
|
||||
onChange={(e) => handleChange('standardEpisodeFormat', e.target.value)}
|
||||
disabled={!localData.rename}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${!localData.rename ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
|
||||
Daily Episode Format
|
||||
</label>
|
||||
<MonospaceInput
|
||||
value={localData.dailyEpisodeFormat || ''}
|
||||
onChange={(e) => handleChange('dailyEpisodeFormat', e.target.value)}
|
||||
disabled={!localData.rename}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${!localData.rename ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
|
||||
Anime Episode Format
|
||||
</label>
|
||||
<MonospaceInput
|
||||
value={localData.animeEpisodeFormat || ''}
|
||||
onChange={(e) => handleChange('animeEpisodeFormat', e.target.value)}
|
||||
disabled={!localData.rename}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 dark:text-gray-300 mb-2">
|
||||
Series Folder Format
|
||||
</label>
|
||||
<MonospaceInput
|
||||
value={localData.seriesFolderFormat || ''}
|
||||
onChange={(e) => handleChange('seriesFolderFormat', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 dark:text-gray-300 mb-2">
|
||||
Season Folder Format
|
||||
</label>
|
||||
<MonospaceInput
|
||||
value={localData.seasonFolderFormat || ''}
|
||||
onChange={(e) => handleChange('seasonFolderFormat', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localData.replaceIllegalCharacters || false}
|
||||
onChange={(e) => handleChange('replaceIllegalCharacters', e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-700 dark:bg-gray-700 border-gray-600 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-100 dark:text-gray-100">
|
||||
Replace Illegal Characters
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${!localData.replaceIllegalCharacters ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
|
||||
Colon Replacement
|
||||
</label>
|
||||
<Dropdown
|
||||
value={localData.colonReplacementFormat ?? (arrType === 'radarr' ? 'smart' : 0)}
|
||||
onChange={(e) => handleChange('colonReplacementFormat', arrType === 'sonarr' ? parseInt(e.target.value) : e.target.value)}
|
||||
options={colonReplacementOptions}
|
||||
disabled={!localData.replaceIllegalCharacters}
|
||||
placeholder="Select replacement"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{arrType === 'sonarr' && localData.colonReplacementFormat === 5 && (
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${!localData.replaceIllegalCharacters ? 'text-gray-500' : 'text-gray-300 dark:text-gray-300'}`}>
|
||||
Custom Colon Replacement
|
||||
</label>
|
||||
<MonospaceInput
|
||||
value={localData.customColonReplacementFormat || ''}
|
||||
onChange={(e) => handleChange('customColonReplacementFormat', e.target.value)}
|
||||
placeholder="Enter custom replacement"
|
||||
disabled={!localData.replaceIllegalCharacters}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{arrType === 'sonarr' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 dark:text-gray-300 mb-2">
|
||||
Multi-Episode Style
|
||||
</label>
|
||||
<Dropdown
|
||||
value={localData.multiEpisodeStyle ?? 0}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="flex items-start space-x-2 mt-6 pt-4 border-t border-gray-700">
|
||||
<Info className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-gray-400">
|
||||
Please ensure all formats follow {arrType === 'radarr' ? 'Radarr' : 'Sonarr'}'s naming requirements.
|
||||
Profilarr does not validate formats before syncing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CategoryContainer>
|
||||
);
|
||||
};
|
||||
|
||||
NamingSettings.propTypes = {
|
||||
data: PropTypes.object,
|
||||
arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired,
|
||||
onSave: PropTypes.func,
|
||||
onSync: PropTypes.func,
|
||||
isSaving: PropTypes.bool
|
||||
};
|
||||
|
||||
export default NamingSettings;
|
||||
229
frontend/src/components/media-management/QualityDefinitions.jsx
Normal file
229
frontend/src/components/media-management/QualityDefinitions.jsx
Normal file
@@ -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 (
|
||||
<CategoryContainer
|
||||
title="Quality Definitions"
|
||||
onSync={handleSync}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
>
|
||||
{/* View Mode Selector and Disclaimer */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex items-start space-x-2 flex-1">
|
||||
<Info className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-gray-400">
|
||||
<p>Preferred size is rarely used when combining quality profiles with custom formats.</p>
|
||||
<p className="mt-1">Min/max values are useful for setting absolute limits on what can be grabbed.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full sm:w-56">
|
||||
<Dropdown
|
||||
value={viewMode}
|
||||
onChange={(e) => setViewMode(e.target.value)}
|
||||
options={viewModeOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(qualityGroups).map(([groupName, qualities]) => (
|
||||
<QualityGroup
|
||||
key={groupName}
|
||||
title={groupName}
|
||||
isExpanded={expandedGroups[groupName]}
|
||||
onToggle={() => toggleGroup(groupName)}
|
||||
unitLabel={getUnitLabel()}
|
||||
>
|
||||
{Object.entries(qualities).map(([qualityName, settings]) => (
|
||||
<QualityItem
|
||||
key={qualityName}
|
||||
name={qualityName}
|
||||
settings={settings}
|
||||
arrType={arrType}
|
||||
viewMode={viewMode}
|
||||
convertValue={convertValue}
|
||||
convertBack={convertBack}
|
||||
onChange={(newSettings) => handleQualityChange(qualityName, newSettings)}
|
||||
/>
|
||||
))}
|
||||
</QualityGroup>
|
||||
))}
|
||||
</div>
|
||||
</CategoryContainer>
|
||||
);
|
||||
};
|
||||
|
||||
QualityDefinitions.propTypes = {
|
||||
data: PropTypes.object,
|
||||
arrType: PropTypes.oneOf(['radarr', 'sonarr']).isRequired,
|
||||
onSave: PropTypes.func,
|
||||
onSync: PropTypes.func,
|
||||
isSaving: PropTypes.bool
|
||||
};
|
||||
|
||||
export default QualityDefinitions;
|
||||
55
frontend/src/components/media-management/QualityGroup.jsx
Normal file
55
frontend/src/components/media-management/QualityGroup.jsx
Normal file
@@ -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 (
|
||||
<div className="border border-gray-700 dark:border-gray-700 rounded-lg overflow-hidden mb-4">
|
||||
{/* Header Section */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`w-full bg-gray-800/30 px-4 py-2.5 hover:bg-gray-700/30 flex items-center justify-between transition-[background-color] ${
|
||||
isExpanded ? 'border-b-2 border-gray-700/50' : ''
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-sm font-medium text-gray-200">
|
||||
{title}
|
||||
</h3>
|
||||
{isExpanded ?
|
||||
<ChevronDown size={14} className="text-gray-400" /> :
|
||||
<ChevronRight size={14} className="text-gray-400" />
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Body Section */}
|
||||
{isExpanded && (
|
||||
<div className="overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="hidden sm:table-header-group">
|
||||
<tr>
|
||||
<th className="text-left pl-6 px-4 pt-4 pb-2 text-xs font-medium text-gray-300 w-40">Quality</th>
|
||||
<th className="text-left px-2 pt-4 pb-2 text-xs font-medium text-gray-300">Range <span className="text-gray-400 font-normal">({unitLabel})</span></th>
|
||||
<th className="text-center px-1 pt-4 pb-2 text-xs font-medium text-gray-300 w-16">Min</th>
|
||||
<th className="text-center px-1 pt-4 pb-2 text-xs font-medium text-gray-300 w-20">Preferred</th>
|
||||
<th className="text-center px-1 pt-4 pb-2 text-xs font-medium text-gray-300 w-16">Max</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
QualityGroup.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
isExpanded: PropTypes.bool,
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
unitLabel: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default QualityGroup;
|
||||
257
frontend/src/components/media-management/QualityItem.jsx
Normal file
257
frontend/src/components/media-management/QualityItem.jsx
Normal file
@@ -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 (
|
||||
<tr className="border-b border-gray-700/50 last:border-b-0">
|
||||
<td colSpan="5" className="px-4 py-3">
|
||||
<div className="space-y-2">
|
||||
{/* Quality name */}
|
||||
<div>
|
||||
<span
|
||||
className="inline-block px-2 py-1 bg-gray-800/60 border border-gray-700/50 rounded-full text-xs font-medium text-gray-200 font-mono"
|
||||
style={{ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace' }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div className="w-full">
|
||||
<Slider
|
||||
min={0}
|
||||
max={maxValue}
|
||||
minValue={settings.min || 0}
|
||||
preferredValue={settings.preferred || 0}
|
||||
maxValue={settings.max || 0}
|
||||
onChange={handleSliderChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Number inputs row */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Min</label>
|
||||
<NumberInput
|
||||
value={displayMin}
|
||||
onChange={(value) => handleInputChange('min', value)}
|
||||
min={0}
|
||||
max={displayMaxLimit}
|
||||
disabled={disabled}
|
||||
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Preferred</label>
|
||||
<NumberInput
|
||||
value={displayPreferred}
|
||||
onChange={(value) => handleInputChange('preferred', value)}
|
||||
min={0}
|
||||
max={displayMaxLimit}
|
||||
disabled={disabled}
|
||||
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Max</label>
|
||||
<NumberInput
|
||||
value={displayMax}
|
||||
onChange={(value) => handleInputChange('max', value)}
|
||||
min={0}
|
||||
max={displayMaxLimit}
|
||||
disabled={disabled}
|
||||
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout
|
||||
return (
|
||||
<tr className="border-b border-gray-700/50 last:border-b-0 hover:bg-gray-800/20 transition-colors">
|
||||
{/* Quality Name */}
|
||||
<td className="pl-4 pr-6 py-3">
|
||||
<span
|
||||
className="inline-block px-3 py-1.5 bg-gray-800/60 border border-gray-700/50 rounded-full text-xs font-medium text-gray-200 font-mono"
|
||||
style={{ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace' }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Slider Section */}
|
||||
<td className="px-4 py-3">
|
||||
<Slider
|
||||
min={0}
|
||||
max={maxValue}
|
||||
minValue={settings.min || 0}
|
||||
preferredValue={settings.preferred || 0}
|
||||
maxValue={settings.max || 0}
|
||||
onChange={handleSliderChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Min Input */}
|
||||
<td className="px-2 py-3">
|
||||
<div className="w-20">
|
||||
<NumberInput
|
||||
value={displayMin}
|
||||
onChange={(value) => handleInputChange('min', value)}
|
||||
min={0}
|
||||
max={displayMaxLimit}
|
||||
disabled={disabled}
|
||||
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Preferred Input */}
|
||||
<td className="px-2 py-3">
|
||||
<div className="w-20">
|
||||
<NumberInput
|
||||
value={displayPreferred}
|
||||
onChange={(value) => handleInputChange('preferred', value)}
|
||||
min={0}
|
||||
max={displayMaxLimit}
|
||||
disabled={disabled}
|
||||
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Max Input */}
|
||||
<td className="px-2 py-3">
|
||||
<div className="w-20">
|
||||
<NumberInput
|
||||
value={displayMax}
|
||||
onChange={(value) => handleInputChange('max', value)}
|
||||
min={0}
|
||||
max={displayMaxLimit}
|
||||
disabled={disabled}
|
||||
step={viewMode === 'mbps' ? 0.1 : (viewMode === 'mbPerMin' ? 1 : 0.01)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
212
frontend/src/components/media-management/SyncModal.jsx
Normal file
212
frontend/src/components/media-management/SyncModal.jsx
Normal file
@@ -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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={modalTitle}
|
||||
width="xl"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={selectedConfigs.length === 0 || syncing}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded bg-gray-700/50 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
{syncing ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 text-blue-500" />
|
||||
)}
|
||||
<span>Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader className="w-6 h-6 animate-spin text-blue-500" />
|
||||
<span className="ml-2">Loading...</span>
|
||||
</div>
|
||||
) : arrConfigs.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">No {arrType} instances configured</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-800/50 border-b border-gray-700">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-300">Name</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-300">Tags</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-300">Select</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{arrConfigs.map((config, index) => (
|
||||
<tr
|
||||
key={config.id}
|
||||
className={`cursor-pointer select-none transition-colors hover:bg-gray-700/50 ${index !== arrConfigs.length - 1 ? 'border-b border-gray-700/50' : ''}`}
|
||||
onClick={() => handleConfigToggle(config.id)}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<span className="font-medium text-gray-200">{config.name}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{config.tags && config.tags.length > 0 ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{config.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-block px-3 py-1.5 bg-gray-800/60 border border-gray-700/50 rounded-full text-xs font-medium text-gray-200"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex justify-end">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center transition-all duration-200 ${
|
||||
selectedConfigs.includes(config.id)
|
||||
? 'bg-blue-500'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}>
|
||||
{selectedConfigs.includes(config.id) && (
|
||||
<Check size={14} className="text-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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 <FileText className='text-green-400' size={16} />;
|
||||
case 'Quality Profile':
|
||||
return <Settings className='text-purple-400' size={16} />;
|
||||
case 'Media Management':
|
||||
return <Cog className='text-orange-400' size={16} />;
|
||||
default:
|
||||
return <File className='text-gray-400' size={16} />;
|
||||
}
|
||||
|
||||
@@ -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 <FileText className='text-green-400' size={16} />;
|
||||
case 'Quality Profile':
|
||||
return <Settings className='text-purple-400' size={16} />;
|
||||
case 'Media Management':
|
||||
return <Cog className='text-orange-400' size={16} />;
|
||||
default:
|
||||
return <File className='text-gray-400' size={16} />;
|
||||
}
|
||||
|
||||
@@ -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}) => {
|
||||
<DiffCommit commitMessage={change.commit_message} />
|
||||
)}
|
||||
|
||||
<div className='overflow-x-auto rounded-lg border border-gray-700'>
|
||||
<table className='min-w-full'>
|
||||
<thead className='bg-gray-800 border-b border-gray-700'>
|
||||
<tr>
|
||||
<th className='py-3 px-4 text-left text-gray-400 font-medium w-1/8'>
|
||||
Change
|
||||
</th>
|
||||
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/8'>
|
||||
Key
|
||||
</th>
|
||||
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/6'>
|
||||
Previous
|
||||
</th>
|
||||
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/6'>
|
||||
Current
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parsedChanges.map(item => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className='bg-gray-900 border-b border-gray-700'>
|
||||
<td className='py-4 px-4 text-gray-300'>
|
||||
{item.changeType}
|
||||
</td>
|
||||
<td className='py-4 px-4'>
|
||||
<span className='font-medium text-gray-100'>
|
||||
{item.key}
|
||||
</span>
|
||||
</td>
|
||||
<td className='py-4 px-4 font-mono text-sm text-gray-300'>
|
||||
<div className='whitespace-pre-wrap'>
|
||||
{item.from}
|
||||
</div>
|
||||
</td>
|
||||
<td className='py-4 px-4 font-mono text-sm text-gray-300'>
|
||||
<div className='whitespace-pre-wrap'>
|
||||
{item.to}
|
||||
</div>
|
||||
</td>
|
||||
{parsedChanges.length > 0 ? (
|
||||
<div className='overflow-x-auto rounded-lg border border-gray-700'>
|
||||
<table className='min-w-full'>
|
||||
<thead className='bg-gray-800 border-b border-gray-700'>
|
||||
<tr>
|
||||
<th className='py-3 px-4 text-left text-gray-400 font-medium w-1/8'>
|
||||
Change
|
||||
</th>
|
||||
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/8'>
|
||||
Key
|
||||
</th>
|
||||
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/6'>
|
||||
Previous
|
||||
</th>
|
||||
<th className='py-3 px-4 text-left text-gray-400 font-medium w-2/6'>
|
||||
Current
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parsedChanges.map(item => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className='bg-gray-900 border-b border-gray-700'>
|
||||
<td className='py-4 px-4 text-gray-300'>
|
||||
{item.changeType}
|
||||
</td>
|
||||
<td className='py-4 px-4'>
|
||||
<span className='font-medium text-gray-100'>
|
||||
{item.key}
|
||||
</span>
|
||||
</td>
|
||||
<td className='py-4 px-4 font-mono text-sm text-gray-300'>
|
||||
<div className='whitespace-pre-wrap'>
|
||||
{item.from}
|
||||
</div>
|
||||
</td>
|
||||
<td className='py-4 px-4 font-mono text-sm text-gray-300'>
|
||||
<div className='whitespace-pre-wrap'>
|
||||
{item.to}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className='bg-gray-900 rounded-lg border border-gray-700 p-6'>
|
||||
<div className='flex items-center space-x-3 text-blue-400'>
|
||||
<Info size={20} />
|
||||
<span className='font-medium'>Formatting Changes Only</span>
|
||||
<span className='text-gray-400'>—</span>
|
||||
<span className='text-sm text-gray-400'>
|
||||
Only whitespace, indentation, or syntax changes detected. No content values were changed.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ const AddButton = ({onClick, label = 'Add New'}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className='flex items-center gap-2 px-3 py-2 rounded-md
|
||||
className='flex items-center gap-2 px-3 py-2 min-h-10 rounded-md
|
||||
border border-gray-200 dark:border-gray-700
|
||||
bg-white text-gray-700 dark:bg-gray-800 dark:text-gray-300
|
||||
hover:bg-gray-50 dark:hover:bg-gray-750
|
||||
@@ -19,7 +19,7 @@ const AddButton = ({onClick, label = 'Add New'}) => {
|
||||
group-hover:rotate-90 group-hover:scale-110
|
||||
group-hover:text-blue-500 dark:group-hover:text-blue-400'
|
||||
/>
|
||||
<span className='text-sm font-medium'>{label}</span>
|
||||
<span className='text-sm font-medium hidden sm:inline'>{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ const FloatingBar = ({children}) => (
|
||||
<>
|
||||
<div className='fixed top-0 left-0 right-0 z-50 bg-gradient-to-br from-gray-800 to-gray-900 border-b border-gray-700 shadow-xl backdrop-blur-sm'>
|
||||
<div className='max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8'>
|
||||
<div className='flex items-center gap-4 h-16'>{children}</div>
|
||||
<div className='flex items-center gap-1 sm:gap-4 h-16'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-16' />
|
||||
@@ -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)}
|
||||
/>
|
||||
<div className='flex items-center gap-3'>
|
||||
{(!isMobile || !isSearchFocused) && (
|
||||
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-3'} ${isMobile && isSearchFocused ? 'opacity-0' : 'opacity-100'}`}>
|
||||
<SortDropdown
|
||||
options={[
|
||||
{key: 'name', label: 'Sort by Name'},
|
||||
@@ -86,6 +106,7 @@ const DataBar = ({
|
||||
onClick={toggleSelectionMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -95,7 +116,7 @@ const DataBar = ({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='flex items-center h-16 gap-4'>{controls}</div>
|
||||
<div className={`flex items-center h-16 ${isMobile ? 'gap-1' : 'gap-4'}`}>{controls}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ function FilterMenu({
|
||||
<button
|
||||
type='button'
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-md
|
||||
flex items-center gap-2 px-3 py-2 min-h-10 rounded-md
|
||||
border border-gray-200 dark:border-gray-700
|
||||
transition-all duration-150 ease-in-out
|
||||
group
|
||||
@@ -66,7 +66,7 @@ function FilterMenu({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className='text-sm font-medium'>
|
||||
<span className='text-sm font-medium hidden sm:inline'>
|
||||
{filterType === 'none'
|
||||
? 'Filter'
|
||||
: options.find(option => option.value === filterType)
|
||||
|
||||
@@ -13,6 +13,8 @@ const SearchBar = ({
|
||||
onAddTerm,
|
||||
onRemoveTerm,
|
||||
onClearTerms,
|
||||
onFocus,
|
||||
onBlur,
|
||||
textSize = 'text-sm', // Default text size
|
||||
badgeTextSize = 'text-sm', // Default badge text size
|
||||
iconSize = 'h-4 w-4', // Default icon size
|
||||
@@ -113,8 +115,14 @@ const SearchBar = ({
|
||||
<input
|
||||
type='text'
|
||||
value={currentInput}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
onFocus && onFocus();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
onBlur && onBlur();
|
||||
}}
|
||||
onChange={e => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
|
||||
@@ -22,7 +22,7 @@ export const SortDropdown = ({
|
||||
<div className='relative'>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className='flex items-center gap-2 px-3 py-2 rounded-md
|
||||
className='flex items-center gap-2 px-3 py-2 min-h-10 rounded-md
|
||||
border border-gray-200 dark:border-gray-700
|
||||
bg-white text-gray-700 dark:bg-gray-800 dark:text-gray-300
|
||||
hover:bg-gray-50 dark:hover:bg-gray-750
|
||||
@@ -36,7 +36,7 @@ export const SortDropdown = ({
|
||||
group-hover:[transform:rotateX(180deg)]
|
||||
group-hover:text-blue-500 dark:group-hover:text-blue-400'
|
||||
/>
|
||||
<span className='text-sm font-medium'>Sort</span>
|
||||
<span className='text-sm font-medium hidden sm:inline'>Sort</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
|
||||
@@ -6,7 +6,7 @@ const ToggleSelectButton = ({isSelectionMode, onClick, shortcutKey = 'A'}) => {
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-md
|
||||
flex items-center gap-2 px-3 py-2 min-h-10 rounded-md
|
||||
border border-gray-200 dark:border-gray-700
|
||||
transition-all duration-150 ease-in-out
|
||||
group
|
||||
@@ -26,7 +26,7 @@ const ToggleSelectButton = ({isSelectionMode, onClick, shortcutKey = 'A'}) => {
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<span className='text-sm font-medium'>Select</span>
|
||||
<span className='text-sm font-medium hidden sm:inline'>Select</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
110
frontend/src/components/ui/Dropdown.jsx
Normal file
110
frontend/src/components/ui/Dropdown.jsx
Normal file
@@ -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 (
|
||||
<div className="relative w-full" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={`${baseClasses} ${stateClasses} ${className}`}
|
||||
style={{ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace' }}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={!selectedOption ? 'text-gray-400' : ''}>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform duration-200 ${
|
||||
isOpen ? 'transform rotate-180' : ''
|
||||
} ${disabled ? 'text-gray-600' : 'text-gray-400'}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-gray-800 dark:bg-gray-800 border border-gray-600 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-auto">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`
|
||||
w-full px-3 py-2 text-left text-sm
|
||||
transition-colors duration-150
|
||||
${option.value === value
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'text-gray-100 dark:text-gray-100 hover:bg-gray-700 dark:hover:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -140,7 +140,7 @@ const Modal = ({
|
||||
{title}
|
||||
</h3>
|
||||
{tabs && (
|
||||
<div className='ml-3'>
|
||||
<div className='ml-auto mr-3'>
|
||||
<TabViewer
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
@@ -150,7 +150,7 @@ const Modal = ({
|
||||
)}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className='ml-auto text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors'>
|
||||
className={`${tabs ? '' : 'ml-auto'} text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors`}>
|
||||
<svg
|
||||
className='w-6 h-6'
|
||||
fill='none'
|
||||
|
||||
77
frontend/src/components/ui/MonospaceInput.jsx
Normal file
77
frontend/src/components/ui/MonospaceInput.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const MonospaceInput = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
rows = 1,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
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 (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
className={`${combinedClasses} resize-none whitespace-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide`}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
|
||||
height: `${rows * 1.5}em`,
|
||||
minHeight: 'unset',
|
||||
maxHeight: `${rows * 1.5}em`
|
||||
}}
|
||||
wrap="off"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
className={combinedClasses}
|
||||
style={{ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace' }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
MonospaceInput.propTypes = {
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
rows: PropTypes.number,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default MonospaceInput;
|
||||
@@ -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 (
|
||||
<nav className='bg-gradient-to-br from-gray-800 to-gray-900 border-b border-gray-700 shadow-xl backdrop-blur-sm'>
|
||||
<nav className='bg-gradient-to-br from-gray-800 to-gray-900 border-b border-gray-700 shadow-xl backdrop-blur-sm relative z-[60]'>
|
||||
<div className='max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 relative'>
|
||||
<div className='flex items-center justify-between h-16'>
|
||||
<div className='flex items-center space-x-8'>
|
||||
<div className='flex items-center flex-1'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Logo className='h-10 w-10' />
|
||||
<h1 className='text-2xl font-bold text-white'>
|
||||
profilarr
|
||||
</h1>
|
||||
</div>
|
||||
<div className='relative flex space-x-2'>
|
||||
<div className='hidden lg:flex relative space-x-2 ml-8'>
|
||||
{isInitialized && (
|
||||
<div
|
||||
className='absolute top-0 bottom-0 bg-gray-900 rounded-md transition-all duration-300'
|
||||
@@ -125,6 +131,16 @@ function Navbar({darkMode, setDarkMode}) {
|
||||
}`}>
|
||||
Quality Profiles
|
||||
</Link>
|
||||
<Link
|
||||
to='/media-management'
|
||||
ref={el => (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
|
||||
</Link>
|
||||
<Link
|
||||
to='/settings'
|
||||
ref={el => (tabsRef.current['settings'] = el)}
|
||||
@@ -137,11 +153,84 @@ function Navbar({darkMode, setDarkMode}) {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={darkMode}
|
||||
onChange={() => setDarkMode(!darkMode)}
|
||||
/>
|
||||
<div className='hidden lg:block'>
|
||||
<ToggleSwitch
|
||||
checked={darkMode}
|
||||
onChange={() => setDarkMode(!darkMode)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className='lg:hidden p-2 rounded-md text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white'
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}>
|
||||
<span className='sr-only'>Open main menu</span>
|
||||
{isMobileMenuOpen ? (
|
||||
<svg className='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M4 6h16M4 12h16M4 18h16' />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isMobileMenuOpen && (
|
||||
<div className='lg:hidden absolute top-16 left-0 right-0 bg-gray-800 border-b border-gray-700 shadow-lg z-[60]'>
|
||||
<div className='px-2 pt-2 pb-3 space-y-1'>
|
||||
<Link
|
||||
to='/regex'
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||
activeTab === 'regex'
|
||||
? 'text-white bg-gray-900'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}>
|
||||
Regex Patterns
|
||||
</Link>
|
||||
<Link
|
||||
to='/format'
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||
activeTab === 'format'
|
||||
? 'text-white bg-gray-900'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}>
|
||||
Custom Formats
|
||||
</Link>
|
||||
<Link
|
||||
to='/profile'
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||
activeTab === 'profile'
|
||||
? 'text-white bg-gray-900'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}>
|
||||
Quality Profiles
|
||||
</Link>
|
||||
<Link
|
||||
to='/media-management'
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||
activeTab === 'media-management'
|
||||
? 'text-white bg-gray-900'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}>
|
||||
Media Management
|
||||
</Link>
|
||||
<Link
|
||||
to='/settings'
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||
activeTab === 'settings'
|
||||
? 'text-white bg-gray-900'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}>
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
<div className='px-4 py-3 border-t border-gray-700'>
|
||||
<ToggleSwitch
|
||||
checked={darkMode}
|
||||
onChange={() => setDarkMode(!darkMode)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -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(' ');
|
||||
|
||||
71
frontend/src/components/ui/Slider.jsx
Normal file
71
frontend/src/components/ui/Slider.jsx
Normal file
@@ -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 (
|
||||
<div className={`w-full ${disabled ? 'opacity-60 pointer-events-none' : ''}`}>
|
||||
<RcSlider
|
||||
range
|
||||
min={min}
|
||||
max={max}
|
||||
value={[minValue || 0, preferredValue || 0, maxValue || 0]}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
allowCross={false}
|
||||
pushable={10}
|
||||
trackStyle={[
|
||||
{ backgroundColor: '#3b82f6', opacity: 0.3 },
|
||||
{ backgroundColor: '#3b82f6', opacity: 0.6 }
|
||||
]}
|
||||
handleStyle={[
|
||||
{ backgroundColor: '#3b82f6', border: 'none', opacity: disabled ? 0.5 : 1 },
|
||||
{ backgroundColor: '#10b981', border: 'none', opacity: disabled ? 0.5 : 1 },
|
||||
{ backgroundColor: '#ef4444', border: 'none', opacity: disabled ? 0.5 : 1 }
|
||||
]}
|
||||
railStyle={{ backgroundColor: 'oklch(13% 0.028 261.692 / 0.5)' }}
|
||||
dotStyle={{ backgroundColor: '#6b7280', borderColor: '#9ca3af' }}
|
||||
activeDotStyle={{ backgroundColor: '#3b82f6', borderColor: '#60a5fa' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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 (
|
||||
<div className='relative'>
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className='flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors'>
|
||||
{activeTabLabel}
|
||||
<svg className={`w-4 h-4 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`} fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M19 9l-7 7-7-7' />
|
||||
</svg>
|
||||
</button>
|
||||
{isDropdownOpen && (
|
||||
<div className='absolute top-full right-0 mt-1 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50'>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
onTabChange(tab.id);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className={`block w-full text-left px-4 py-2 text-sm font-medium transition-colors
|
||||
${
|
||||
activeTab === tab.id
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative flex items-center'>
|
||||
{isInitialized && (
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user