mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
Merge pull request #224 from Dictionarry-Hub/dev
fix(backend): perms env, mm import refactor, deserialize error
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,6 +20,7 @@ backend/app/static/
|
|||||||
|
|
||||||
# Config data
|
# Config data
|
||||||
config/
|
config/
|
||||||
|
config-test/
|
||||||
radarr-config/
|
radarr-config/
|
||||||
sonarr-config/
|
sonarr-config/
|
||||||
test-data/
|
test-data/
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
# Dockerfile
|
# Dockerfile
|
||||||
FROM python:3.9-slim
|
FROM python:3.9-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Install git (since we're still using slim)
|
# Install git and gosu for user switching
|
||||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y git gosu && rm -rf /var/lib/apt/lists/*
|
||||||
# Copy pre-built files from dist directory
|
# Copy pre-built files from dist directory
|
||||||
COPY dist/backend/app ./app
|
COPY dist/backend/app ./app
|
||||||
COPY dist/static ./app/static
|
COPY dist/static ./app/static
|
||||||
COPY dist/requirements.txt .
|
COPY dist/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
# Copy and setup entrypoint script
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
LABEL org.opencontainers.image.authors="Dictionarry dictionarry@pm.me"
|
LABEL org.opencontainers.image.authors="Dictionarry dictionarry@pm.me"
|
||||||
LABEL org.opencontainers.image.description="Profilarr - Profile manager for *arr apps"
|
LABEL org.opencontainers.image.description="Profilarr - Profile manager for *arr apps"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/Dictionarry-Hub/profilarr"
|
LABEL org.opencontainers.image.source="https://github.com/Dictionarry-Hub/profilarr"
|
||||||
LABEL org.opencontainers.image.title="Profilarr"
|
LABEL org.opencontainers.image.title="Profilarr"
|
||||||
LABEL org.opencontainers.image.version="beta"
|
LABEL org.opencontainers.image.version="beta"
|
||||||
EXPOSE 6868
|
EXPOSE 6868
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:6868", "--timeout", "600", "app.main:create_app()"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:6868", "--timeout", "600", "app.main:create_app()"]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# backend/app/db/migrations/versions/004_update_language_score_default.py
|
||||||
|
from ...connection import get_db
|
||||||
|
|
||||||
|
version = 4
|
||||||
|
name = "update_language_score_default"
|
||||||
|
|
||||||
|
|
||||||
|
def up():
|
||||||
|
"""Update default language import score to -999999."""
|
||||||
|
with get_db() as conn:
|
||||||
|
# Update existing record to new default value
|
||||||
|
conn.execute('''
|
||||||
|
UPDATE language_import_config
|
||||||
|
SET score = -999999,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def down():
|
||||||
|
"""Revert language import score to previous default."""
|
||||||
|
with get_db() as conn:
|
||||||
|
# Revert to previous default value
|
||||||
|
conn.execute('''
|
||||||
|
UPDATE language_import_config
|
||||||
|
SET score = -99999,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
@@ -38,8 +38,8 @@ class ProfileStrategy(ImportStrategy):
|
|||||||
# Load profile YAML
|
# Load profile YAML
|
||||||
profile_yaml = load_yaml(f"profile/{filename}.yml")
|
profile_yaml = load_yaml(f"profile/{filename}.yml")
|
||||||
|
|
||||||
# Extract referenced custom formats
|
# Extract referenced custom formats (only for the target arr type)
|
||||||
format_names = extract_format_names(profile_yaml)
|
format_names = extract_format_names(profile_yaml, self.arr_type)
|
||||||
|
|
||||||
for format_name in format_names:
|
for format_name in format_names:
|
||||||
# Skip if already processed
|
# Skip if already processed
|
||||||
|
|||||||
@@ -46,12 +46,14 @@ def load_yaml(file_path: str) -> Dict[str, Any]:
|
|||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
def extract_format_names(profile_data: Dict[str, Any]) -> Set[str]:
|
def extract_format_names(profile_data: Dict[str, Any], arr_type: str = None) -> Set[str]:
|
||||||
"""
|
"""
|
||||||
Extract all custom format names referenced in a profile.
|
Extract all custom format names referenced in a profile.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
profile_data: Profile YAML data
|
profile_data: Profile YAML data
|
||||||
|
arr_type: Target arr type ('radarr' or 'sonarr'). If provided, only extracts
|
||||||
|
formats for that specific arr type.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Set of unique format names
|
Set of unique format names
|
||||||
@@ -64,10 +66,18 @@ def extract_format_names(profile_data: Dict[str, Any]) -> Set[str]:
|
|||||||
format_names.add(cf['name'])
|
format_names.add(cf['name'])
|
||||||
|
|
||||||
# Extract from app-specific custom_formats
|
# Extract from app-specific custom_formats
|
||||||
for key in ['custom_formats_radarr', 'custom_formats_sonarr']:
|
if arr_type:
|
||||||
for cf in profile_data.get(key, []):
|
# Only extract formats for the specific arr type
|
||||||
|
app_key = f'custom_formats_{arr_type.lower()}'
|
||||||
|
for cf in profile_data.get(app_key, []):
|
||||||
if isinstance(cf, dict) and 'name' in cf:
|
if isinstance(cf, dict) and 'name' in cf:
|
||||||
format_names.add(cf['name'])
|
format_names.add(cf['name'])
|
||||||
|
else:
|
||||||
|
# Extract from all app-specific sections (backwards compatibility)
|
||||||
|
for key in ['custom_formats_radarr', 'custom_formats_sonarr']:
|
||||||
|
for cf in profile_data.get(key, []):
|
||||||
|
if isinstance(cf, dict) and 'name' in cf:
|
||||||
|
format_names.add(cf['name'])
|
||||||
|
|
||||||
return format_names
|
return format_names
|
||||||
|
|
||||||
|
|||||||
@@ -124,11 +124,14 @@ def setup_logging():
|
|||||||
|
|
||||||
|
|
||||||
def init_git_user():
|
def init_git_user():
|
||||||
"""Initialize Git user configuration globally and update PAT status."""
|
"""Initialize Git user configuration for the repository and update PAT status."""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.info("Starting Git user configuration")
|
logger.info("Starting Git user configuration")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from .config import config
|
||||||
|
repo_path = config.DB_DIR
|
||||||
|
|
||||||
git_name = os.environ.get('GIT_USER_NAME', 'Profilarr')
|
git_name = os.environ.get('GIT_USER_NAME', 'Profilarr')
|
||||||
git_email = os.environ.get('GIT_USER_EMAIL',
|
git_email = os.environ.get('GIT_USER_EMAIL',
|
||||||
'profilarr@dictionarry.com')
|
'profilarr@dictionarry.com')
|
||||||
@@ -139,30 +142,38 @@ def init_git_user():
|
|||||||
if git_name == 'Profilarr' or git_email == 'profilarr@dictionarry.com':
|
if git_name == 'Profilarr' or git_email == 'profilarr@dictionarry.com':
|
||||||
logger.info("Using default Git user configuration")
|
logger.info("Using default Git user configuration")
|
||||||
|
|
||||||
# Set global Git configuration
|
# Set repository-level Git configuration if repo exists
|
||||||
subprocess.run(['git', 'config', '--global', 'user.name', git_name],
|
if os.path.exists(os.path.join(repo_path, '.git')):
|
||||||
check=True)
|
logger.info(f"Setting git config for repository at {repo_path}")
|
||||||
subprocess.run(['git', 'config', '--global', 'user.email', git_email],
|
subprocess.run(['git', '-C', repo_path, 'config', '--local', 'user.name', git_name],
|
||||||
check=True)
|
check=True)
|
||||||
|
subprocess.run(['git', '-C', repo_path, 'config', '--local', 'user.email', git_email],
|
||||||
|
check=True)
|
||||||
|
# Add safe.directory to prevent ownership issues
|
||||||
|
subprocess.run(['git', '-C', repo_path, 'config', '--local', '--add', 'safe.directory', repo_path],
|
||||||
|
check=True)
|
||||||
|
else:
|
||||||
|
logger.warning(f"No git repository found at {repo_path}, skipping git config")
|
||||||
|
|
||||||
# Update PAT status in database
|
# Update PAT status in database
|
||||||
update_pat_status()
|
update_pat_status()
|
||||||
|
|
||||||
# Verify configuration
|
# Verify configuration if repository exists
|
||||||
configured_name = subprocess.run(
|
if os.path.exists(os.path.join(repo_path, '.git')):
|
||||||
['git', 'config', '--global', 'user.name'],
|
configured_name = subprocess.run(
|
||||||
capture_output=True,
|
['git', '-C', repo_path, 'config', '--local', 'user.name'],
|
||||||
text=True,
|
capture_output=True,
|
||||||
check=True).stdout.strip()
|
text=True,
|
||||||
configured_email = subprocess.run(
|
check=True).stdout.strip()
|
||||||
['git', 'config', '--global', 'user.email'],
|
configured_email = subprocess.run(
|
||||||
capture_output=True,
|
['git', '-C', repo_path, 'config', '--local', 'user.email'],
|
||||||
text=True,
|
capture_output=True,
|
||||||
check=True).stdout.strip()
|
text=True,
|
||||||
|
check=True).stdout.strip()
|
||||||
|
|
||||||
if configured_name != git_name or configured_email != git_email:
|
if configured_name != git_name or configured_email != git_email:
|
||||||
logger.error("Git configuration verification failed")
|
logger.error("Git configuration verification failed")
|
||||||
return False, "Git configuration verification failed"
|
return False, "Git configuration verification failed"
|
||||||
|
|
||||||
logger.info("Git user configuration completed successfully")
|
logger.info("Git user configuration completed successfully")
|
||||||
return True, "Git configuration successful"
|
return True, "Git configuration successful"
|
||||||
|
|||||||
@@ -101,13 +101,12 @@ def sync_media_management():
|
|||||||
try:
|
try:
|
||||||
# Get the current media management data for this category
|
# Get the current media management data for this category
|
||||||
category_data = get_media_management_data(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':
|
if category == 'naming':
|
||||||
|
arr_type_data = category_data.get(arr_type, {})
|
||||||
success, message = sync_naming_config(base_url, api_key, arr_type, arr_type_data)
|
success, message = sync_naming_config(base_url, api_key, arr_type, arr_type_data)
|
||||||
elif category == 'misc':
|
elif category == 'misc':
|
||||||
|
arr_type_data = category_data.get(arr_type, {})
|
||||||
success, message = sync_media_management_config(base_url, api_key, arr_type, arr_type_data)
|
success, message = sync_media_management_config(base_url, api_key, arr_type, arr_type_data)
|
||||||
elif category == 'quality_definitions':
|
elif category == 'quality_definitions':
|
||||||
# Quality definitions has a nested structure: qualityDefinitions -> arr_type -> qualities
|
# Quality definitions has a nested structure: qualityDefinitions -> arr_type -> qualities
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import requests
|
|
||||||
from typing import Dict, Any, Tuple
|
from typing import Dict, Any, Tuple
|
||||||
|
from ..importer.arr_handler import ArrHandler, ArrApiError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -18,22 +18,14 @@ def sync_naming_config(base_url: str, api_key: str, arr_type: str, naming_data:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
"""
|
"""
|
||||||
|
arr = None
|
||||||
try:
|
try:
|
||||||
# Construct the endpoint URL
|
# Initialize ArrHandler
|
||||||
endpoint = f"{base_url}/api/v3/config/naming"
|
arr = ArrHandler(base_url, api_key)
|
||||||
headers = {
|
logger.info(f"Syncing naming config to {arr_type}")
|
||||||
"X-Api-Key": api_key,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
# GET current naming config
|
# GET current naming config using ArrHandler
|
||||||
logger.info(f"Fetching current naming config from {arr_type} at {base_url}")
|
current_config = arr.get("/api/v3/config/naming")
|
||||||
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
|
# Update current_config with fields from naming_data
|
||||||
if arr_type == 'radarr':
|
if arr_type == 'radarr':
|
||||||
@@ -73,24 +65,22 @@ def sync_naming_config(base_url: str, api_key: str, arr_type: str, naming_data:
|
|||||||
if 'specialsFolderFormat' in naming_data:
|
if 'specialsFolderFormat' in naming_data:
|
||||||
current_config['specialsFolderFormat'] = naming_data['specialsFolderFormat']
|
current_config['specialsFolderFormat'] = naming_data['specialsFolderFormat']
|
||||||
|
|
||||||
# PUT the updated config back
|
# PUT the updated config back using ArrHandler
|
||||||
logger.info(f"Updating naming config for {arr_type}")
|
arr.put("/api/v3/config/naming", current_config)
|
||||||
logger.info(f"Request body for naming sync:")
|
logger.info(f"Successfully synced naming config to {arr_type}")
|
||||||
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"
|
return True, "Naming config sync successful"
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
except ArrApiError as e:
|
||||||
error_msg = f"Failed to sync naming config: {str(e)}"
|
error_msg = f"Failed to sync naming config: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Unexpected error syncing naming config: {str(e)}"
|
error_msg = f"Failed to sync naming config: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
finally:
|
||||||
|
if arr:
|
||||||
|
arr.close()
|
||||||
|
|
||||||
|
|
||||||
def sync_media_management_config(base_url: str, api_key: str, arr_type: str, misc_data: Dict[str, Any]) -> Tuple[bool, str]:
|
def sync_media_management_config(base_url: str, api_key: str, arr_type: str, misc_data: Dict[str, Any]) -> Tuple[bool, str]:
|
||||||
@@ -107,48 +97,37 @@ def sync_media_management_config(base_url: str, api_key: str, arr_type: str, mis
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
"""
|
"""
|
||||||
|
arr = None
|
||||||
try:
|
try:
|
||||||
# Construct the endpoint URL
|
# Initialize ArrHandler
|
||||||
endpoint = f"{base_url}/api/v3/config/mediamanagement"
|
arr = ArrHandler(base_url, api_key)
|
||||||
headers = {
|
logger.info(f"Syncing media management config to {arr_type}")
|
||||||
"X-Api-Key": api_key,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
# GET current media management config
|
# GET current media management config using ArrHandler
|
||||||
logger.info(f"Fetching current media management config from {arr_type} at {base_url}")
|
current_config = arr.get("/api/v3/config/mediamanagement")
|
||||||
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
|
# Update current_config with fields from misc_data
|
||||||
# We only manage two fields: propersRepacks and enableMediaInfo
|
|
||||||
if 'propersRepacks' in misc_data:
|
if 'propersRepacks' in misc_data:
|
||||||
current_config['downloadPropersAndRepacks'] = misc_data['propersRepacks']
|
current_config['downloadPropersAndRepacks'] = misc_data['propersRepacks']
|
||||||
if 'enableMediaInfo' in misc_data:
|
if 'enableMediaInfo' in misc_data:
|
||||||
current_config['enableMediaInfo'] = misc_data['enableMediaInfo']
|
current_config['enableMediaInfo'] = misc_data['enableMediaInfo']
|
||||||
|
|
||||||
# PUT the updated config back
|
# PUT the updated config back using ArrHandler
|
||||||
logger.info(f"Updating media management config for {arr_type}")
|
arr.put("/api/v3/config/mediamanagement", current_config)
|
||||||
logger.info(f"Request body for media management sync:")
|
logger.info(f"Successfully synced media management config to {arr_type}")
|
||||||
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"
|
return True, "Media management config sync successful"
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
except ArrApiError as e:
|
||||||
error_msg = f"Failed to sync media management config: {str(e)}"
|
error_msg = f"Failed to sync media management config: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Unexpected error syncing media management config: {str(e)}"
|
error_msg = f"Failed to sync media management config: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
finally:
|
||||||
|
if arr:
|
||||||
|
arr.close()
|
||||||
|
|
||||||
|
|
||||||
def sync_quality_definitions(base_url: str, api_key: str, arr_type: str, quality_data: Dict[str, Any]) -> Tuple[bool, str]:
|
def sync_quality_definitions(base_url: str, api_key: str, arr_type: str, quality_data: Dict[str, Any]) -> Tuple[bool, str]:
|
||||||
@@ -165,94 +144,43 @@ def sync_quality_definitions(base_url: str, api_key: str, arr_type: str, quality
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
"""
|
"""
|
||||||
|
arr = None
|
||||||
try:
|
try:
|
||||||
# Construct the endpoint URL
|
# Initialize ArrHandler
|
||||||
endpoint = f"{base_url}/api/v3/qualitydefinition"
|
arr = ArrHandler(base_url, api_key)
|
||||||
headers = {
|
logger.info(f"Syncing quality definitions to {arr_type}")
|
||||||
"X-Api-Key": api_key,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
# GET current quality definitions (for logging/comparison)
|
# GET current quality definitions using ArrHandler
|
||||||
logger.info(f"Fetching current quality definitions from {arr_type} at {base_url}")
|
current_definitions = arr.get("/api/v3/qualitydefinition")
|
||||||
response = requests.get(endpoint, headers=headers, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
current_definitions = response.json()
|
# Create a mapping of quality names to current definitions for easier lookup
|
||||||
logger.info(f"Current quality definitions for {arr_type}:")
|
quality_map = {def_['quality']['name']: def_ for def_ in current_definitions}
|
||||||
logger.info(current_definitions)
|
|
||||||
|
|
||||||
if arr_type == 'sonarr':
|
# Update each quality definition with our values
|
||||||
# Log the quality data we received from YML
|
for quality_name, settings in quality_data.items():
|
||||||
logger.info(f"Quality data from YML:")
|
if quality_name in quality_map:
|
||||||
logger.info(quality_data)
|
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']
|
||||||
|
|
||||||
# Create a mapping of quality names to current definitions for easier lookup
|
# PUT the updated definitions back using ArrHandler
|
||||||
quality_map = {def_['quality']['name']: def_ for def_ in current_definitions}
|
arr.put("/api/v3/qualitydefinition/update", current_definitions)
|
||||||
|
logger.info(f"Successfully synced quality definitions to {arr_type}")
|
||||||
|
return True, "Quality definitions sync successful"
|
||||||
|
|
||||||
# Update each quality definition with our values
|
except ArrApiError as e:
|
||||||
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)}"
|
error_msg = f"Failed to sync quality definitions: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Unexpected error syncing quality definitions: {str(e)}"
|
error_msg = f"Failed to sync quality definitions: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
finally:
|
||||||
|
if arr:
|
||||||
|
arr.close()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# app/task/__init__.py
|
# app/task/__init__.py
|
||||||
from flask import Blueprint, jsonify
|
from flask import Blueprint, jsonify, request
|
||||||
import logging
|
import logging
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from .tasks import TaskScheduler
|
from .tasks import TaskScheduler
|
||||||
@@ -78,6 +78,63 @@ def get_task(task_id):
|
|||||||
return jsonify({"error": "An unexpected error occurred"}), 500
|
return jsonify({"error": "An unexpected error occurred"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:task_id>', methods=['PUT'])
|
||||||
|
def update_task(task_id):
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "No data provided"}), 400
|
||||||
|
|
||||||
|
interval_minutes = data.get('interval_minutes')
|
||||||
|
if interval_minutes is None:
|
||||||
|
return jsonify({"error": "interval_minutes is required"}), 400
|
||||||
|
|
||||||
|
if not isinstance(interval_minutes, int) or interval_minutes < 1:
|
||||||
|
return jsonify({"error": "interval_minutes must be a positive integer"}), 400
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
# Check if task exists
|
||||||
|
task = conn.execute('SELECT * FROM scheduled_tasks WHERE id = ?',
|
||||||
|
(task_id, )).fetchone()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
return jsonify({"error": "Task not found"}), 404
|
||||||
|
|
||||||
|
# Update the interval in database
|
||||||
|
conn.execute(
|
||||||
|
'UPDATE scheduled_tasks SET interval_minutes = ? WHERE id = ?',
|
||||||
|
(interval_minutes, task_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Update the scheduler
|
||||||
|
scheduler_instance = TaskScheduler.get_instance()
|
||||||
|
if scheduler_instance and interval_minutes > 0:
|
||||||
|
# Remove old job
|
||||||
|
scheduler_instance.scheduler.remove_job(str(task_id))
|
||||||
|
|
||||||
|
# Create new task instance with updated interval
|
||||||
|
task_class = TaskScheduler.get_task_class(task['type'])
|
||||||
|
if task_class:
|
||||||
|
new_task = task_class(
|
||||||
|
id=task_id,
|
||||||
|
name=task['name'],
|
||||||
|
interval_minutes=interval_minutes
|
||||||
|
)
|
||||||
|
scheduler_instance.schedule_task(new_task)
|
||||||
|
|
||||||
|
logger.info(f"Updated task {task_id} interval to {interval_minutes} minutes")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Task interval updated to {interval_minutes} minutes"
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to update task {task_id}")
|
||||||
|
return jsonify({"error": f"Failed to update task: {str(e)}"}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:task_id>/run', methods=['POST'])
|
@bp.route('/<int:task_id>/run', methods=['POST'])
|
||||||
def trigger_task(task_id):
|
def trigger_task(task_id):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
# docker-compose.yml
|
|
||||||
version: '3.8'
|
|
||||||
services:
|
services:
|
||||||
profilarr:
|
profilarr:
|
||||||
image: santiagosayshey/profilarr:beta
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
container_name: profilarr
|
container_name: profilarr
|
||||||
ports:
|
ports:
|
||||||
- 6868:6868
|
- 6870:6868
|
||||||
volumes:
|
volumes:
|
||||||
- profilarr_data:/config
|
- ./config-test:/config
|
||||||
environment:
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
|
- UMASK=002
|
||||||
- TZ=Australia/Adelaide
|
- TZ=Australia/Adelaide
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
profilarr_data:
|
|
||||||
name: profilarr_data
|
|
||||||
|
|||||||
@@ -17,5 +17,7 @@ services:
|
|||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./config:/config
|
- ./config:/config
|
||||||
environment:
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
- TZ=Australia/Adelaide
|
- TZ=Australia/Adelaide
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
34
entrypoint.sh
Normal file
34
entrypoint.sh
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Default to UID/GID 1000 if not provided
|
||||||
|
PUID=${PUID:-1000}
|
||||||
|
PGID=${PGID:-1000}
|
||||||
|
# Default umask to 022 if not provided
|
||||||
|
UMASK=${UMASK:-022}
|
||||||
|
|
||||||
|
echo "Starting with UID: $PUID, GID: $PGID, UMASK: $UMASK"
|
||||||
|
|
||||||
|
umask "$UMASK"
|
||||||
|
|
||||||
|
# Create group with specified GID
|
||||||
|
groupadd -g "$PGID" appgroup 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create user with specified UID and GID
|
||||||
|
useradd -u "$PUID" -g "$PGID" -d /home/appuser -s /bin/bash appuser 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create home directory if it doesn't exist
|
||||||
|
mkdir -p /home/appuser
|
||||||
|
chown "$PUID:$PGID" /home/appuser
|
||||||
|
|
||||||
|
# Fix permissions on /config if it exists
|
||||||
|
if [ -d "/config" ]; then
|
||||||
|
echo "Setting up /config directory permissions"
|
||||||
|
# Change ownership of /config and all its contents to PUID:PGID
|
||||||
|
# This ensures files created by different UIDs are accessible
|
||||||
|
chown -R "$PUID:$PGID" /config
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Execute the main command as the specified user
|
||||||
|
echo "Starting application as user $PUID:$PGID"
|
||||||
|
exec gosu "$PUID:$PGID" "$@"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import Alert from '@ui/Alert';
|
||||||
|
|
||||||
export const getAllTasks = async () => {
|
export const getAllTasks = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -37,3 +38,23 @@ export const triggerTask = async taskId => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateTaskInterval = async (taskId, intervalMinutes) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`/api/tasks/${taskId}`, {
|
||||||
|
interval_minutes: intervalMinutes
|
||||||
|
});
|
||||||
|
Alert.success(response.data.message || 'Task interval updated successfully');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error.response?.data?.error || 'Failed to update task interval';
|
||||||
|
Alert.error(errorMessage);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
// components/settings/TaskCard.jsx
|
// components/settings/TaskCard.jsx
|
||||||
import React from 'react';
|
import React, {useState, useEffect} from 'react';
|
||||||
import {Play, Loader} from 'lucide-react';
|
import {Play, Loader, Edit2, Check, X} from 'lucide-react';
|
||||||
|
import NumberInput from '@ui/NumberInput';
|
||||||
|
import {updateTaskInterval} from '@/api/task';
|
||||||
|
|
||||||
const TaskCard = ({task, onTrigger, isTriggering}) => {
|
const TaskCard = ({task, onTrigger, isTriggering, isLast, onIntervalUpdate}) => {
|
||||||
|
const [intervalValue, setIntervalValue] = useState(task.interval_minutes);
|
||||||
|
const [originalValue, setOriginalValue] = useState(task.interval_minutes);
|
||||||
|
|
||||||
|
// Only allow editing for Repository Sync and Backup tasks
|
||||||
|
const isEditable = task.type === 'Sync' || task.type === 'Backup';
|
||||||
const formatDateTime = dateString => {
|
const formatDateTime = dateString => {
|
||||||
if (!dateString) return 'Never';
|
if (!dateString) return 'Never';
|
||||||
return new Date(dateString).toLocaleString();
|
return new Date(dateString).toLocaleString();
|
||||||
@@ -13,8 +20,32 @@ const TaskCard = ({task, onTrigger, isTriggering}) => {
|
|||||||
return `${duration}s`;
|
return `${duration}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIntervalValue(task.interval_minutes);
|
||||||
|
setOriginalValue(task.interval_minutes);
|
||||||
|
}, [task.interval_minutes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (intervalValue !== originalValue && intervalValue > 0) {
|
||||||
|
const updateInterval = async () => {
|
||||||
|
const result = await updateTaskInterval(task.id, intervalValue);
|
||||||
|
if (result.success) {
|
||||||
|
setOriginalValue(intervalValue);
|
||||||
|
// Refresh task data to get new next_run time
|
||||||
|
if (onIntervalUpdate) {
|
||||||
|
onIntervalUpdate();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset to original value if update failed
|
||||||
|
setIntervalValue(originalValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateInterval();
|
||||||
|
}
|
||||||
|
}, [intervalValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className='bg-gray-900 border-b border-gray-700'>
|
<tr className={`bg-gray-900 ${!isLast ? 'border-b border-gray-700' : ''}`}>
|
||||||
<td className='py-4 px-4'>
|
<td className='py-4 px-4'>
|
||||||
<div className='flex items-center space-x-3'>
|
<div className='flex items-center space-x-3'>
|
||||||
<span className='font-medium text-gray-100'>
|
<span className='font-medium text-gray-100'>
|
||||||
@@ -23,7 +54,21 @@ const TaskCard = ({task, onTrigger, isTriggering}) => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='py-4 px-4 text-gray-300'>
|
<td className='py-4 px-4 text-gray-300'>
|
||||||
{task.interval_minutes} minutes
|
{isEditable ? (
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<NumberInput
|
||||||
|
value={intervalValue}
|
||||||
|
onChange={setIntervalValue}
|
||||||
|
min={1}
|
||||||
|
max={43200}
|
||||||
|
step={1}
|
||||||
|
className='w-24'
|
||||||
|
/>
|
||||||
|
<span className='text-gray-400 text-sm'>minutes</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{task.interval_minutes} minutes</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className='py-4 px-4 text-gray-300'>
|
<td className='py-4 px-4 text-gray-300'>
|
||||||
{formatDateTime(task.last_run)}
|
{formatDateTime(task.last_run)}
|
||||||
|
|||||||
@@ -77,12 +77,14 @@ const TaskContainer = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tasks.map(task => (
|
{tasks.map((task, index) => (
|
||||||
<TaskCard
|
<TaskCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
onTrigger={handleTriggerTask}
|
onTrigger={handleTriggerTask}
|
||||||
isTriggering={triggeringTask === task.id}
|
isTriggering={triggeringTask === task.id}
|
||||||
|
isLast={index === tasks.length - 1}
|
||||||
|
onIntervalUpdate={fetchTasks}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {ChevronUp, ChevronDown} from 'lucide-react';
|
|||||||
const NumberInput = ({
|
const NumberInput = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onBlur = () => {},
|
||||||
|
onFocus = () => {},
|
||||||
className = '',
|
className = '',
|
||||||
step = 1,
|
step = 1,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -24,26 +26,26 @@ const NumberInput = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = (e) => {
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
const numValue =
|
const numValue =
|
||||||
localValue === '' || localValue === '-' ? 0 : parseInt(localValue);
|
localValue === '' || localValue === '-' ? 0 : parseInt(localValue);
|
||||||
|
|
||||||
if (min !== undefined && numValue < min) {
|
if (min !== undefined && numValue < min) {
|
||||||
onChange(min);
|
onChange(min);
|
||||||
return;
|
} else if (max !== undefined && numValue > max) {
|
||||||
}
|
|
||||||
if (max !== undefined && numValue > max) {
|
|
||||||
onChange(max);
|
onChange(max);
|
||||||
return;
|
} else {
|
||||||
|
onChange(numValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(numValue);
|
onBlur(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = (e) => {
|
||||||
setIsFocused(true);
|
setIsFocused(true);
|
||||||
setLocalValue(value.toString());
|
setLocalValue(value.toString());
|
||||||
|
onFocus(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const increment = () => {
|
const increment = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user