- Added NFOConfig model with TMDB API key, auto-create, media downloads, image size settings - Created NFO settings section in UI with form fields and validation - Implemented nfo-config.js module for loading, saving, and testing TMDB connection - Added TMDB API key validation endpoint (POST /api/config/tmdb/validate) - Integrated NFO config into AppConfig and ConfigUpdate models - Added 5 unit tests for NFO config model validation - Added API test for TMDB validation endpoint - All 16 config model tests passing, all 10 config API tests passing - Documented in docs/task7_status.md (100% complete)
430 lines
13 KiB
Python
430 lines
13 KiB
Python
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
|
|
from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult
|
|
from src.server.services.config_service import (
|
|
ConfigBackupError,
|
|
ConfigServiceError,
|
|
ConfigValidationError,
|
|
get_config_service,
|
|
)
|
|
from src.server.utils.dependencies import require_auth
|
|
|
|
router = APIRouter(prefix="/api/config", tags=["config"])
|
|
|
|
|
|
@router.get("", response_model=AppConfig)
|
|
def get_config(auth: Optional[dict] = Depends(require_auth)) -> AppConfig:
|
|
"""Return current application configuration."""
|
|
try:
|
|
config_service = get_config_service()
|
|
return config_service.load_config()
|
|
except ConfigServiceError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to load config: {e}"
|
|
) from e
|
|
|
|
|
|
@router.put("", response_model=AppConfig)
|
|
def update_config(
|
|
update: ConfigUpdate, auth: dict = Depends(require_auth)
|
|
) -> AppConfig:
|
|
"""Apply an update to the configuration and persist it.
|
|
|
|
Creates automatic backup before applying changes.
|
|
"""
|
|
try:
|
|
config_service = get_config_service()
|
|
return config_service.update_config(update)
|
|
except ConfigValidationError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid configuration: {e}"
|
|
) from e
|
|
except ConfigServiceError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to update config: {e}"
|
|
) from e
|
|
|
|
|
|
@router.post("/validate", response_model=ValidationResult)
|
|
def validate_config(
|
|
cfg: AppConfig, auth: dict = Depends(require_auth) # noqa: ARG001
|
|
) -> ValidationResult:
|
|
"""Validate a provided AppConfig without applying it.
|
|
|
|
Returns ValidationResult with any validation errors.
|
|
"""
|
|
try:
|
|
config_service = get_config_service()
|
|
return config_service.validate_config(cfg)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
) from e
|
|
|
|
|
|
@router.get("/backups", response_model=List[Dict[str, object]])
|
|
def list_backups(
|
|
auth: dict = Depends(require_auth)
|
|
) -> List[Dict[str, object]]:
|
|
"""List all available configuration backups.
|
|
|
|
Returns list of backup metadata including name, size, and created time.
|
|
"""
|
|
try:
|
|
config_service = get_config_service()
|
|
return config_service.list_backups()
|
|
except ConfigServiceError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to list backups: {e}"
|
|
) from e
|
|
|
|
|
|
@router.post("/backups", response_model=Dict[str, str])
|
|
def create_backup(
|
|
name: Optional[str] = None, auth: dict = Depends(require_auth)
|
|
) -> Dict[str, str]:
|
|
"""Create a backup of the current configuration.
|
|
|
|
Args:
|
|
name: Optional custom backup name (timestamp used if not provided)
|
|
|
|
Returns:
|
|
Dictionary with backup name and message
|
|
"""
|
|
try:
|
|
config_service = get_config_service()
|
|
backup_path = config_service.create_backup(name)
|
|
return {
|
|
"name": backup_path.name,
|
|
"message": "Backup created successfully"
|
|
}
|
|
except ConfigBackupError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Failed to create backup: {e}"
|
|
) from e
|
|
|
|
|
|
@router.post("/backups/{backup_name}/restore", response_model=AppConfig)
|
|
def restore_backup(
|
|
backup_name: str, auth: dict = Depends(require_auth)
|
|
) -> AppConfig:
|
|
"""Restore configuration from a backup.
|
|
|
|
Creates backup of current config before restoring.
|
|
|
|
Args:
|
|
backup_name: Name of backup file to restore
|
|
|
|
Returns:
|
|
Restored configuration
|
|
"""
|
|
try:
|
|
config_service = get_config_service()
|
|
return config_service.restore_backup(backup_name)
|
|
except ConfigBackupError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Failed to restore backup: {e}"
|
|
) from e
|
|
|
|
|
|
@router.delete("/backups/{backup_name}")
|
|
def delete_backup(
|
|
backup_name: str, auth: dict = Depends(require_auth)
|
|
) -> Dict[str, str]:
|
|
"""Delete a configuration backup.
|
|
|
|
Args:
|
|
backup_name: Name of backup file to delete
|
|
|
|
Returns:
|
|
Success message
|
|
"""
|
|
try:
|
|
config_service = get_config_service()
|
|
config_service.delete_backup(backup_name)
|
|
return {"message": f"Backup '{backup_name}' deleted successfully"}
|
|
except ConfigBackupError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Failed to delete backup: {e}"
|
|
) from e
|
|
|
|
|
|
@router.get("/section/advanced", response_model=Dict[str, object])
|
|
def get_advanced_config(
|
|
auth: Optional[dict] = Depends(require_auth)
|
|
) -> Dict[str, object]:
|
|
"""Get advanced configuration section.
|
|
|
|
Returns:
|
|
Dictionary with advanced configuration settings
|
|
"""
|
|
try:
|
|
config_service = get_config_service()
|
|
app_config = config_service.load_config()
|
|
return app_config.other.get("advanced", {})
|
|
except ConfigServiceError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to load advanced config: {e}"
|
|
) from e
|
|
|
|
|
|
@router.post("/section/advanced", response_model=Dict[str, str])
|
|
def update_advanced_config(
|
|
config: Dict[str, object], auth: dict = Depends(require_auth)
|
|
) -> Dict[str, str]:
|
|
"""Update advanced configuration section.
|
|
|
|
Args:
|
|
config: Advanced configuration settings
|
|
auth: Authentication token (required)
|
|
|
|
Returns:
|
|
Success message
|
|
"""
|
|
try:
|
|
config_service = get_config_service()
|
|
app_config = config_service.load_config()
|
|
|
|
# Update advanced section in other
|
|
if "advanced" not in app_config.other:
|
|
app_config.other["advanced"] = {}
|
|
app_config.other["advanced"].update(config)
|
|
|
|
config_service.save_config(app_config)
|
|
return {"message": "Advanced configuration updated successfully"}
|
|
except ConfigServiceError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to update advanced config: {e}"
|
|
) from e
|
|
|
|
|
|
@router.post("/directory", response_model=Dict[str, Any])
|
|
async def update_directory(
|
|
directory_config: Dict[str, str], auth: dict = Depends(require_auth)
|
|
) -> Dict[str, Any]:
|
|
"""Update anime directory configuration.
|
|
|
|
Args:
|
|
directory_config: Dictionary with 'directory' key
|
|
auth: Authentication token (required)
|
|
|
|
Returns:
|
|
Success message
|
|
"""
|
|
try:
|
|
directory = directory_config.get("directory")
|
|
if not directory:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Directory path is required"
|
|
)
|
|
|
|
config_service = get_config_service()
|
|
app_config = config_service.load_config()
|
|
|
|
# Store directory in other section
|
|
app_config.other["anime_directory"] = directory
|
|
|
|
config_service.save_config(app_config)
|
|
|
|
# Sync series from data files to database
|
|
sync_count = 0
|
|
try:
|
|
import structlog
|
|
|
|
from src.server.services.anime_service import sync_series_from_data_files
|
|
logger = structlog.get_logger(__name__)
|
|
sync_count = await sync_series_from_data_files(directory, logger)
|
|
logger.info(
|
|
"Directory updated: synced series from data files",
|
|
directory=directory,
|
|
count=sync_count
|
|
)
|
|
except Exception as e:
|
|
# Log but don't fail the directory update if sync fails
|
|
import structlog
|
|
structlog.get_logger(__name__).warning(
|
|
"Failed to sync series after directory update",
|
|
error=str(e)
|
|
)
|
|
|
|
response: Dict[str, Any] = {
|
|
"message": "Anime directory updated successfully",
|
|
"synced_series": sync_count
|
|
}
|
|
|
|
return response
|
|
except ConfigServiceError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to update directory: {e}"
|
|
) from e
|
|
|
|
|
|
@router.post("/export")
|
|
async def export_config(
|
|
export_options: Dict[str, bool], auth: dict = Depends(require_auth)
|
|
):
|
|
"""Export configuration to JSON file.
|
|
|
|
Args:
|
|
export_options: Options for export (include_sensitive, etc.)
|
|
auth: Authentication token (required)
|
|
|
|
Returns:
|
|
JSON file download response
|
|
"""
|
|
try:
|
|
import json
|
|
|
|
from fastapi.responses import Response
|
|
|
|
config_service = get_config_service()
|
|
app_config = config_service.load_config()
|
|
|
|
# Convert to dict
|
|
config_dict = app_config.model_dump()
|
|
|
|
# Optionally remove sensitive data
|
|
if not export_options.get("include_sensitive", False):
|
|
# Remove sensitive fields if present
|
|
config_dict.pop("password_salt", None)
|
|
config_dict.pop("password_hash", None)
|
|
|
|
# Create filename with timestamp
|
|
from datetime import datetime
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = f"aniworld_config_{timestamp}.json"
|
|
|
|
# Return as downloadable JSON
|
|
content = json.dumps(config_dict, indent=2)
|
|
return Response(
|
|
content=content,
|
|
media_type="application/json",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="{filename}"'
|
|
}
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to export config: {e}"
|
|
) from e
|
|
|
|
|
|
@router.post("/reset", response_model=Dict[str, str])
|
|
def reset_config(
|
|
reset_options: Dict[str, bool], auth: dict = Depends(require_auth)
|
|
) -> Dict[str, str]:
|
|
"""Reset configuration to defaults.
|
|
|
|
Args:
|
|
reset_options: Options for reset (preserve_security, etc.)
|
|
auth: Authentication token (required)
|
|
|
|
Returns:
|
|
Success message
|
|
"""
|
|
try:
|
|
config_service = get_config_service()
|
|
|
|
# Create backup before resetting
|
|
config_service.create_backup("pre_reset")
|
|
|
|
# Load default config
|
|
default_config = AppConfig()
|
|
|
|
# If preserve_security is True, keep authentication settings
|
|
if reset_options.get("preserve_security", True):
|
|
current_config = config_service.load_config()
|
|
# Preserve security-related fields from other
|
|
if "password_salt" in current_config.other:
|
|
default_config.other["password_salt"] = (
|
|
current_config.other["password_salt"]
|
|
)
|
|
if "password_hash" in current_config.other:
|
|
default_config.other["password_hash"] = (
|
|
current_config.other["password_hash"]
|
|
)
|
|
|
|
# Save default config
|
|
config_service.save_config(default_config)
|
|
|
|
return {
|
|
"message": "Configuration reset to defaults successfully"
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to reset config: {e}"
|
|
) from e
|
|
|
|
|
|
@router.post("/tmdb/validate", response_model=Dict[str, Any])
|
|
async def validate_tmdb_key(
|
|
api_key_data: Dict[str, str], auth: dict = Depends(require_auth)
|
|
) -> Dict[str, Any]:
|
|
"""Validate TMDB API key by making a test request.
|
|
|
|
Args:
|
|
api_key_data: Dictionary with 'api_key' field
|
|
auth: Authentication token (required)
|
|
|
|
Returns:
|
|
Validation result with success status and message
|
|
"""
|
|
import aiohttp
|
|
|
|
api_key = api_key_data.get("api_key", "").strip()
|
|
|
|
if not api_key:
|
|
return {
|
|
"valid": False,
|
|
"message": "API key is required"
|
|
}
|
|
|
|
try:
|
|
# Test the API key with a simple configuration request
|
|
url = f"https://api.themoviedb.org/3/configuration?api_key={api_key}"
|
|
|
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(url, timeout=timeout) as response:
|
|
if response.status == 200:
|
|
return {
|
|
"valid": True,
|
|
"message": "TMDB API key is valid"
|
|
}
|
|
elif response.status == 401:
|
|
return {
|
|
"valid": False,
|
|
"message": "Invalid API key"
|
|
}
|
|
else:
|
|
return {
|
|
"valid": False,
|
|
"message": f"TMDB API error: {response.status}"
|
|
}
|
|
except aiohttp.ClientError as e:
|
|
return {
|
|
"valid": False,
|
|
"message": f"Connection error: {str(e)}"
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"valid": False,
|
|
"message": f"Validation error: {str(e)}"
|
|
}
|