mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a0deb16fa | ||
|
|
bb514b20cc | ||
|
|
99925be174 | ||
|
|
21e44d592f | ||
|
|
212dd695b6 | ||
|
|
6c40d352c9 | ||
|
|
7270bbfedb | ||
|
|
2e2abb93be | ||
|
|
7f5f44cd77 | ||
|
|
c30dc33828 | ||
|
|
eb9733807e |
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()"]
|
||||||
26
README.md
26
README.md
@@ -54,26 +54,7 @@ After deployment, access the web UI at `http://[address]:6868` to begin setup.
|
|||||||
|
|
||||||
### Complete Documentation
|
### Complete Documentation
|
||||||
|
|
||||||
Visit our comprehensive documentation at [dictionarry.dev/wiki/profilarr-setup](https://dictionarry.dev/wiki/profilarr-setup) for detailed installation instructions and usage guides.
|
Visit our comprehensive documentation at [dictionarry.dev](https://dictionarry.dev/profilarr-setup/installation) for detailed installation instructions and usage guides.
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
### Need Help?
|
|
||||||
|
|
||||||
- **Bug Reports & Issues**: Submit technical issues via our [GitHub Issues tracker](https://github.com/Dictionarry-Hub/profilarr/issues)
|
|
||||||
- **Community Support**: Join our [Discord community](https://discord.com/invite/Y9TYP6jeYZ) for help from developers and other users
|
|
||||||
- **Database Issues**: Please direct database-specific issues to their respective repositories, as this repository focuses exclusively on Profilarr development
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
We welcome contributions from the community! Here's how you can help improve Profilarr:
|
|
||||||
|
|
||||||
- **Pull Requests**: Feel free to submit PRs for bug fixes or new features
|
|
||||||
- **Feature Suggestions**: Share your ideas through GitHub issues
|
|
||||||
- **Documentation**: Help improve our guides and documentation
|
|
||||||
- **Testing**: Try new features and report any issues
|
|
||||||
|
|
||||||
Detailed contributing guidelines will be available soon. Join our Discord to discuss potential contributions with the development team.
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -83,11 +64,6 @@ Currently in beta. Part of the [Dictionarry](https://github.com/Dictionarry-Hub)
|
|||||||
|
|
||||||
- https://github.com/Dictionarry-Hub/profilarr/issues
|
- https://github.com/Dictionarry-Hub/profilarr/issues
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
- Currently focused on fixing bugs found in open beta
|
|
||||||
- 1.1 will focus on improving the 'setup' side of profilarr - adding media management / quality settings syncs
|
|
||||||
|
|
||||||
### Personal Note
|
### Personal Note
|
||||||
|
|
||||||
Profilarr is maintained by a single CS student with no formal development experience, in their spare time. Development happens when time allows, which may affect response times for fixes and new features. The project is continuously improving, and your patience, understanding, and contributions are greatly appreciated as Profilarr grows and matures.
|
Profilarr is maintained by a single CS student with no formal development experience, in their spare time. Development happens when time allows, which may affect response times for fixes and new features. The project is continuously improving, and your patience, understanding, and contributions are greatly appreciated as Profilarr grows and matures.
|
||||||
|
|||||||
@@ -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
|
||||||
# Create a mapping of quality names to current definitions for easier lookup
|
if 'min' in settings:
|
||||||
quality_map = {def_['quality']['name']: def_ for def_ in current_definitions}
|
definition['minSize'] = settings['min']
|
||||||
|
if 'preferred' in settings:
|
||||||
# Update each quality definition with our values
|
definition['preferredSize'] = settings['preferred']
|
||||||
for quality_name, settings in quality_data.items():
|
if 'max' in settings:
|
||||||
if quality_name in quality_map:
|
definition['maxSize'] = settings['max']
|
||||||
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
|
# PUT the updated definitions back using ArrHandler
|
||||||
# Log the quality data we received from YML
|
arr.put("/api/v3/qualitydefinition/update", current_definitions)
|
||||||
logger.info(f"Quality data from YML:")
|
logger.info(f"Successfully synced quality definitions to {arr_type}")
|
||||||
logger.info(quality_data)
|
return True, "Quality definitions sync successful"
|
||||||
|
|
||||||
# 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:
|
except ArrApiError 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