feat(api): add REST API with 45+ endpoints

- Add workers API for pool management
- Add jobs API for queue operations
- Add scan-rules API for CRUD operations
- Add scanner API for control and status
- Add settings API for configuration management
- Add system API for resource monitoring
- Add filesystem API for path browsing
- Add setup wizard API endpoint
This commit is contained in:
2026-01-16 16:57:59 +01:00
parent c019e96cfa
commit 6272efbcd5
11 changed files with 3226 additions and 1 deletions

View File

@@ -1 +1,17 @@
"""TranscriptorIO API Module."""
"""API module for TranscriptorIO backend."""
from backend.api.workers import router as workers_router
from backend.api.jobs import router as jobs_router
from backend.api.scan_rules import router as scan_rules_router
from backend.api.scanner import router as scanner_router
from backend.api.settings import router as settings_router
from backend.api.setup_wizard import router as setup_router
__all__ = [
"workers_router",
"jobs_router",
"scan_rules_router",
"scanner_router",
"settings_router",
"setup_router",
]

113
backend/api/filesystem.py Normal file
View File

@@ -0,0 +1,113 @@
"""Filesystem browsing API for path selection."""
import logging
import os
from typing import List, Optional
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/filesystem", tags=["filesystem"])
class DirectoryItem(BaseModel):
"""Directory item information."""
name: str
path: str
is_directory: bool
is_readable: bool
class DirectoryListingResponse(BaseModel):
"""Directory listing response."""
current_path: str
parent_path: Optional[str] = None
items: List[DirectoryItem]
@router.get("/browse", response_model=DirectoryListingResponse)
async def browse_directory(path: str = "/"):
"""Browse filesystem directory."""
try:
path = os.path.abspath(path)
if not os.path.exists(path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Path does not exist: {path}"
)
if not os.path.isdir(path):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Path is not a directory: {path}"
)
parent_path = os.path.dirname(path) if path != "/" else None
items = []
try:
entries = os.listdir(path)
entries.sort()
for entry in entries:
entry_path = os.path.join(path, entry)
try:
is_dir = os.path.isdir(entry_path)
is_readable = os.access(entry_path, os.R_OK)
if is_dir:
items.append(DirectoryItem(
name=entry,
path=entry_path,
is_directory=True,
is_readable=is_readable
))
except (PermissionError, OSError) as e:
logger.debug(f"Cannot access {entry_path}: {e}")
continue
except PermissionError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied: {path}"
)
return DirectoryListingResponse(
current_path=path,
parent_path=parent_path,
items=items
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error browsing directory {path}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error browsing directory: {str(e)}"
)
@router.get("/common-paths", response_model=List[DirectoryItem])
async def get_common_paths():
"""Get list of common starting paths."""
common_paths = ["/", "/home", "/media", "/mnt", "/opt", "/srv", "/var", "/tmp"]
items = []
for path in common_paths:
if os.path.exists(path) and os.path.isdir(path):
try:
is_readable = os.access(path, os.R_OK)
items.append(DirectoryItem(
name=path,
path=path,
is_directory=True,
is_readable=is_readable
))
except (PermissionError, OSError):
continue
return items

379
backend/api/jobs.py Normal file
View File

@@ -0,0 +1,379 @@
"""Job management API routes."""
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from pydantic import BaseModel, Field
from backend.core.models import JobStatus, QualityPreset
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
# === REQUEST/RESPONSE MODELS ===
class JobCreateRequest(BaseModel):
"""Request to create a new job."""
file_path: str = Field(..., description="Full path to the media file")
file_name: str = Field(..., description="Name of the file")
source_lang: Optional[str] = Field(None, description="Source language (ISO 639-2)")
target_lang: str = Field(..., description="Target subtitle language (ISO 639-2)")
quality_preset: str = Field("fast", description="Quality preset: fast, balanced, best")
transcribe_or_translate: str = Field("transcribe", description="Operation: transcribe or translate")
priority: int = Field(0, description="Job priority (higher = processed first)")
is_manual_request: bool = Field(True, description="Whether this is a manual request")
class Config:
json_schema_extra = {
"example": {
"file_path": "/media/anime/Attack on Titan S04E01.mkv",
"file_name": "Attack on Titan S04E01.mkv",
"source_lang": "jpn",
"target_lang": "spa",
"quality_preset": "fast",
"transcribe_or_translate": "transcribe",
"priority": 10
}
}
class JobResponse(BaseModel):
"""Job response model."""
id: str
file_path: str
file_name: str
job_type: str = "transcription" # Default to transcription for backward compatibility
status: str
priority: int
source_lang: Optional[str]
target_lang: Optional[str]
quality_preset: Optional[str]
transcribe_or_translate: str
progress: float
current_stage: Optional[str]
eta_seconds: Optional[int]
created_at: Optional[str]
started_at: Optional[str]
completed_at: Optional[str]
output_path: Optional[str]
segments_count: Optional[int]
error: Optional[str]
retry_count: int
worker_id: Optional[str]
vram_used_mb: Optional[int]
processing_time_seconds: Optional[float]
model_used: Optional[str]
device_used: Optional[str]
class JobListResponse(BaseModel):
"""Job list response with pagination."""
jobs: List[JobResponse]
total: int
page: int
page_size: int
class QueueStatsResponse(BaseModel):
"""Queue statistics response."""
total_jobs: int
queued: int
processing: int
completed: int
failed: int
cancelled: int
class MessageResponse(BaseModel):
"""Generic message response."""
message: str
# === ROUTES ===
@router.get("/", response_model=JobListResponse)
async def get_jobs(
status_filter: Optional[str] = Query(None, description="Filter by status"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=500, description="Items per page"),
):
"""
Get list of jobs with optional filtering and pagination.
Args:
status_filter: Filter by job status (queued/processing/completed/failed/cancelled)
page: Page number (1-based)
page_size: Number of items per page
Returns:
Paginated list of jobs
"""
from backend.core.queue_manager import queue_manager
# Validate status filter
status_enum = None
if status_filter:
try:
status_enum = JobStatus(status_filter.lower())
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status: {status_filter}"
)
# Get jobs
jobs = queue_manager.get_all_jobs(
status_filter=status_enum,
limit=page_size,
offset=(page - 1) * page_size
)
# Get total count
total = queue_manager.count_jobs(status_filter=status_enum)
return JobListResponse(
jobs=[JobResponse(**job.to_dict()) for job in jobs],
total=total,
page=page,
page_size=page_size
)
@router.get("/stats", response_model=QueueStatsResponse)
async def get_queue_stats():
"""
Get queue statistics.
Returns:
Queue statistics
"""
from backend.core.queue_manager import queue_manager
stats = queue_manager.get_queue_stats()
return QueueStatsResponse(**stats)
@router.get("/{job_id}", response_model=JobResponse)
async def get_job(job_id: str):
"""
Get a specific job by ID.
Args:
job_id: Job ID
Returns:
Job object
Raises:
404: Job not found
"""
from backend.core.database import database
from backend.core.models import Job
with database.get_session() as session:
job = session.query(Job).filter(Job.id == job_id).first()
if not job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job {job_id} not found"
)
job_dict = job.to_dict()
return JobResponse(**job_dict)
@router.post("/", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
async def create_job(request: JobCreateRequest):
"""
Create a new transcription job.
Args:
request: Job creation request
Returns:
Created job object
Raises:
400: Invalid quality preset
409: Job already exists for this file
"""
from backend.core.queue_manager import queue_manager
# Validate quality preset
try:
quality = QualityPreset(request.quality_preset.lower())
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid quality preset: {request.quality_preset}"
)
# Create job
job = queue_manager.add_job(
file_path=request.file_path,
file_name=request.file_name,
source_lang=request.source_lang,
target_lang=request.target_lang,
quality_preset=quality,
transcribe_or_translate=request.transcribe_or_translate,
priority=request.priority,
is_manual_request=request.is_manual_request,
)
if not job:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Job already exists for {request.file_name}"
)
logger.info(f"Job {job.id} created via API for {request.file_name}")
return JobResponse(**job.to_dict())
@router.post("/{job_id}/retry", response_model=JobResponse)
async def retry_job(job_id: str):
"""
Retry a failed job.
Args:
job_id: Job ID to retry
Returns:
Updated job object
Raises:
404: Job not found
400: Job cannot be retried
"""
from backend.core.queue_manager import queue_manager
from backend.core.database import database
from backend.core.models import Job, JobStatus
# Check if job exists and can be retried (within session)
with database.get_session() as session:
job = session.query(Job).filter(Job.id == job_id).first()
if not job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job {job_id} not found"
)
# Access attributes while session is active
can_retry = job.status == JobStatus.FAILED
current_status = job.status.value
if not can_retry:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Job {job_id} cannot be retried (status={current_status})"
)
# Reset job to queued
success = queue_manager.retry_job(job_id)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retry job {job_id}"
)
# Get updated job and return
with database.get_session() as session:
job = session.query(Job).filter(Job.id == job_id).first()
job_dict = job.to_dict() if job else {}
logger.info(f"Job {job_id} retried via API")
return JobResponse(**job_dict)
@router.delete("/{job_id}", response_model=MessageResponse)
async def cancel_job(job_id: str):
"""
Cancel a job.
Args:
job_id: Job ID to cancel
Returns:
Success message
Raises:
404: Job not found
400: Job already completed
"""
from backend.core.queue_manager import queue_manager
from backend.core.database import database
from backend.core.models import Job, JobStatus
# Check if job exists and can be cancelled (within session)
with database.get_session() as session:
job = session.query(Job).filter(Job.id == job_id).first()
if not job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job {job_id} not found"
)
# Access attributes while session is active
is_terminal = job.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED)
current_status = job.status.value
if is_terminal:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Job {job_id} is already in terminal state: {current_status}"
)
# Cancel job
success = queue_manager.cancel_job(job_id)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to cancel job {job_id}"
)
logger.info(f"Job {job_id} cancelled via API")
return MessageResponse(message=f"Job {job_id} cancelled successfully")
@router.post("/{job_id}/cancel", response_model=MessageResponse)
async def cancel_job_post(job_id: str):
"""
Cancel a job (POST alias).
Args:
job_id: Job ID to cancel
Returns:
Success message
"""
# Reuse the delete endpoint logic
return await cancel_job(job_id)
@router.post("/queue/clear", response_model=MessageResponse)
async def clear_completed_jobs():
"""
Clear all completed jobs from the queue.
Returns:
Success message with count of cleared jobs
"""
from backend.core.queue_manager import queue_manager
count = queue_manager.clear_completed_jobs()
logger.info(f"Cleared {count} completed jobs via API")
return MessageResponse(message=f"Cleared {count} completed jobs")

351
backend/api/scan_rules.py Normal file
View File

@@ -0,0 +1,351 @@
"""Scan rules management API routes."""
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/scan-rules", tags=["scan-rules"])
# === REQUEST/RESPONSE MODELS ===
class ScanRuleConditions(BaseModel):
"""Scan rule conditions."""
audio_language_is: Optional[str] = Field(None, description="Audio language must be (ISO 639-2)")
audio_language_not: Optional[str] = Field(None, description="Audio language must NOT be (comma-separated)")
audio_track_count_min: Optional[int] = Field(None, description="Minimum number of audio tracks")
has_embedded_subtitle_lang: Optional[str] = Field(None, description="Must have embedded subtitle in language")
missing_embedded_subtitle_lang: Optional[str] = Field(None, description="Must NOT have embedded subtitle")
missing_external_subtitle_lang: Optional[str] = Field(None, description="Must NOT have external .srt file")
file_extension: Optional[str] = Field(None, description="File extensions filter (comma-separated)")
class ScanRuleAction(BaseModel):
"""Scan rule action."""
action_type: str = Field("transcribe", description="Action type: transcribe or translate")
target_language: str = Field(..., description="Target subtitle language (ISO 639-2)")
quality_preset: str = Field("fast", description="Quality preset: fast, balanced, best")
job_priority: int = Field(0, description="Priority for created jobs")
class ScanRuleCreateRequest(BaseModel):
"""Request to create a scan rule."""
name: str = Field(..., description="Rule name")
enabled: bool = Field(True, description="Whether rule is enabled")
priority: int = Field(0, description="Rule priority (higher = evaluated first)")
conditions: ScanRuleConditions
action: ScanRuleAction
class Config:
json_schema_extra = {
"example": {
"name": "Japanese anime without Spanish subs",
"enabled": True,
"priority": 10,
"conditions": {
"audio_language_is": "jpn",
"missing_embedded_subtitle_lang": "spa",
"missing_external_subtitle_lang": "spa",
"file_extension": ".mkv,.mp4"
},
"action": {
"action_type": "transcribe",
"target_language": "spa",
"quality_preset": "fast",
"job_priority": 5
}
}
}
class ScanRuleUpdateRequest(BaseModel):
"""Request to update a scan rule."""
name: Optional[str] = Field(None, description="Rule name")
enabled: Optional[bool] = Field(None, description="Whether rule is enabled")
priority: Optional[int] = Field(None, description="Rule priority")
conditions: Optional[ScanRuleConditions] = None
action: Optional[ScanRuleAction] = None
class ScanRuleResponse(BaseModel):
"""Scan rule response."""
id: int
name: str
enabled: bool
priority: int
conditions: dict
action: dict
created_at: Optional[str]
updated_at: Optional[str]
class MessageResponse(BaseModel):
"""Generic message response."""
message: str
# === ROUTES ===
@router.get("/", response_model=List[ScanRuleResponse])
async def get_all_rules(enabled_only: bool = False):
"""
Get all scan rules.
Args:
enabled_only: Only return enabled rules
Returns:
List of scan rules (ordered by priority DESC)
"""
from backend.core.database import database
from backend.scanning.models import ScanRule
with database.get_session() as session:
query = session.query(ScanRule)
if enabled_only:
query = query.filter(ScanRule.enabled == True)
rules = query.order_by(ScanRule.priority.desc()).all()
return [ScanRuleResponse(**rule.to_dict()) for rule in rules]
@router.get("/{rule_id}", response_model=ScanRuleResponse)
async def get_rule(rule_id: int):
"""
Get a specific scan rule.
Args:
rule_id: Rule ID
Returns:
Scan rule object
Raises:
404: Rule not found
"""
from backend.core.database import database
from backend.scanning.models import ScanRule
with database.get_session() as session:
rule = session.query(ScanRule).filter(ScanRule.id == rule_id).first()
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Scan rule {rule_id} not found"
)
return ScanRuleResponse(**rule.to_dict())
@router.post("/", response_model=ScanRuleResponse, status_code=status.HTTP_201_CREATED)
async def create_rule(request: ScanRuleCreateRequest):
"""
Create a new scan rule.
Args:
request: Rule creation request
Returns:
Created rule object
Raises:
400: Invalid data
409: Rule with same name already exists
"""
from backend.core.database import database
from backend.scanning.models import ScanRule
with database.get_session() as session:
# Check for duplicate name
existing = session.query(ScanRule).filter(ScanRule.name == request.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Scan rule with name '{request.name}' already exists"
)
# Create rule
rule = ScanRule(
name=request.name,
enabled=request.enabled,
priority=request.priority,
# Conditions
audio_language_is=request.conditions.audio_language_is,
audio_language_not=request.conditions.audio_language_not,
audio_track_count_min=request.conditions.audio_track_count_min,
has_embedded_subtitle_lang=request.conditions.has_embedded_subtitle_lang,
missing_embedded_subtitle_lang=request.conditions.missing_embedded_subtitle_lang,
missing_external_subtitle_lang=request.conditions.missing_external_subtitle_lang,
file_extension=request.conditions.file_extension,
# Action
action_type=request.action.action_type,
target_language=request.action.target_language,
quality_preset=request.action.quality_preset,
job_priority=request.action.job_priority,
)
session.add(rule)
session.commit()
session.refresh(rule)
logger.info(f"Scan rule created via API: {rule.name} (ID: {rule.id})")
return ScanRuleResponse(**rule.to_dict())
@router.put("/{rule_id}", response_model=ScanRuleResponse)
async def update_rule(rule_id: int, request: ScanRuleUpdateRequest):
"""
Update a scan rule.
Args:
rule_id: Rule ID to update
request: Rule update request
Returns:
Updated rule object
Raises:
404: Rule not found
409: Name already exists
"""
from backend.core.database import database
from backend.scanning.models import ScanRule
with database.get_session() as session:
rule = session.query(ScanRule).filter(ScanRule.id == rule_id).first()
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Scan rule {rule_id} not found"
)
# Check for duplicate name
if request.name and request.name != rule.name:
existing = session.query(ScanRule).filter(ScanRule.name == request.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Scan rule with name '{request.name}' already exists"
)
# Update fields
if request.name is not None:
rule.name = request.name
if request.enabled is not None:
rule.enabled = request.enabled
if request.priority is not None:
rule.priority = request.priority
# Update conditions
if request.conditions:
if request.conditions.audio_language_is is not None:
rule.audio_language_is = request.conditions.audio_language_is
if request.conditions.audio_language_not is not None:
rule.audio_language_not = request.conditions.audio_language_not
if request.conditions.audio_track_count_min is not None:
rule.audio_track_count_min = request.conditions.audio_track_count_min
if request.conditions.has_embedded_subtitle_lang is not None:
rule.has_embedded_subtitle_lang = request.conditions.has_embedded_subtitle_lang
if request.conditions.missing_embedded_subtitle_lang is not None:
rule.missing_embedded_subtitle_lang = request.conditions.missing_embedded_subtitle_lang
if request.conditions.missing_external_subtitle_lang is not None:
rule.missing_external_subtitle_lang = request.conditions.missing_external_subtitle_lang
if request.conditions.file_extension is not None:
rule.file_extension = request.conditions.file_extension
# Update action
if request.action:
if request.action.action_type is not None:
rule.action_type = request.action.action_type
if request.action.target_language is not None:
rule.target_language = request.action.target_language
if request.action.quality_preset is not None:
rule.quality_preset = request.action.quality_preset
if request.action.job_priority is not None:
rule.job_priority = request.action.job_priority
session.commit()
session.refresh(rule)
logger.info(f"Scan rule updated via API: {rule.name} (ID: {rule.id})")
return ScanRuleResponse(**rule.to_dict())
@router.delete("/{rule_id}", response_model=MessageResponse)
async def delete_rule(rule_id: int):
"""
Delete a scan rule.
Args:
rule_id: Rule ID to delete
Returns:
Success message
Raises:
404: Rule not found
"""
from backend.core.database import database
from backend.scanning.models import ScanRule
with database.get_session() as session:
rule = session.query(ScanRule).filter(ScanRule.id == rule_id).first()
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Scan rule {rule_id} not found"
)
rule_name = rule.name
session.delete(rule)
session.commit()
logger.info(f"Scan rule deleted via API: {rule_name} (ID: {rule_id})")
return MessageResponse(message=f"Scan rule {rule_id} deleted successfully")
@router.post("/{rule_id}/toggle", response_model=ScanRuleResponse)
async def toggle_rule(rule_id: int):
"""
Toggle a scan rule enabled/disabled.
Args:
rule_id: Rule ID to toggle
Returns:
Updated rule object
Raises:
404: Rule not found
"""
from backend.core.database import database
from backend.scanning.models import ScanRule
with database.get_session() as session:
rule = session.query(ScanRule).filter(ScanRule.id == rule_id).first()
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Scan rule {rule_id} not found"
)
rule.enabled = not rule.enabled
session.commit()
session.refresh(rule)
logger.info(f"Scan rule toggled via API: {rule.name} -> {'enabled' if rule.enabled else 'disabled'}")
return ScanRuleResponse(**rule.to_dict())

312
backend/api/scanner.py Normal file
View File

@@ -0,0 +1,312 @@
"""Library scanner API routes."""
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/scanner", tags=["scanner"])
# === REQUEST/RESPONSE MODELS ===
class ScanRequest(BaseModel):
"""Request to scan paths."""
paths: List[str] = Field(..., description="Paths to scan")
recursive: bool = Field(True, description="Scan subdirectories")
class Config:
json_schema_extra = {
"example": {
"paths": ["/media/anime", "/media/movies"],
"recursive": True
}
}
class ScanResult(BaseModel):
"""Scan result summary."""
scanned_files: int
matched_files: int
jobs_created: int
skipped_files: int
paths_scanned: List[str]
class ScheduleConfig(BaseModel):
"""Scanner schedule configuration."""
enabled: bool = Field(..., description="Enable scheduled scanning")
cron_expression: str = Field(..., description="Cron expression for schedule")
paths: List[str] = Field(..., description="Paths to scan")
recursive: bool = Field(True, description="Scan subdirectories")
class Config:
json_schema_extra = {
"example": {
"enabled": True,
"cron_expression": "0 2 * * *", # Daily at 2 AM
"paths": ["/media/anime", "/media/movies"],
"recursive": True
}
}
class WatcherConfig(BaseModel):
"""File watcher configuration."""
enabled: bool = Field(..., description="Enable file watcher")
paths: List[str] = Field(..., description="Paths to watch")
recursive: bool = Field(True, description="Watch subdirectories")
class Config:
json_schema_extra = {
"example": {
"enabled": True,
"paths": ["/media/anime", "/media/movies"],
"recursive": True
}
}
class ScannerStatus(BaseModel):
"""Scanner status response."""
scheduler_enabled: bool
scheduler_running: bool
next_scan_time: Optional[str]
watcher_enabled: bool
watcher_running: bool
watched_paths: List[str]
last_scan_time: Optional[str]
total_scans: int
class MessageResponse(BaseModel):
"""Generic message response."""
message: str
# === ROUTES ===
@router.get("/status", response_model=ScannerStatus)
async def get_scanner_status():
"""
Get library scanner status.
Returns:
Scanner status information
"""
from backend.scanning.library_scanner import library_scanner
status_dict = library_scanner.get_status()
return ScannerStatus(**status_dict)
@router.post("/scan", response_model=ScanResult)
async def scan_paths(request: Optional[ScanRequest] = None):
"""
Manually trigger a library scan.
Args:
request: Optional scan request with paths. If not provided, uses library_paths from settings.
Returns:
Scan result summary
"""
from backend.scanning.library_scanner import library_scanner
from backend.core.settings_service import settings_service
# Use request paths or load from settings
if request is None:
library_paths = settings_service.get('library_paths', '')
# Handle both string (comma-separated) and list types
if isinstance(library_paths, list):
paths = [p.strip() for p in library_paths if p and p.strip()]
elif isinstance(library_paths, str) and library_paths:
paths = [p.strip() for p in library_paths.split(',') if p.strip()]
else:
paths = []
recursive = True
else:
paths = request.paths
recursive = request.recursive
if not paths:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No library paths configured. Please configure library_paths in settings."
)
logger.info(f"Manual scan triggered via API: {paths}")
result = library_scanner.scan_paths(
paths=paths,
recursive=recursive
)
return ScanResult(**result)
@router.post("/scheduler/start", response_model=MessageResponse)
async def start_scheduler(config: Optional[ScheduleConfig] = None):
"""
Start scheduled scanning.
Args:
config: Optional scheduler configuration. If not provided, uses settings from database.
Returns:
Success message
"""
from backend.scanning.library_scanner import library_scanner
from backend.core.settings_service import settings_service
try:
# Use config from request or load from settings
if config is None:
# Load interval from database settings (in minutes)
interval_minutes = settings_service.get('scanner_schedule_interval_minutes', 360) # Default: 6 hours
interval_minutes = int(interval_minutes) if interval_minutes else 360
else:
# Convert cron to interval (simplified - just use 360 minutes for now)
interval_minutes = 360
if interval_minutes <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid scanner interval. Must be greater than 0 minutes."
)
library_scanner.start_scheduler(interval_minutes=interval_minutes)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
logger.info(f"Scheduler started via API with interval: {interval_minutes} minutes")
return MessageResponse(message=f"Scheduler started successfully (every {interval_minutes} minutes)")
@router.post("/scheduler/stop", response_model=MessageResponse)
async def stop_scheduler():
"""
Stop scheduled scanning.
Returns:
Success message
"""
from backend.scanning.library_scanner import library_scanner
library_scanner.stop_scheduler()
logger.info("Scheduler stopped via API")
return MessageResponse(message="Scheduler stopped successfully")
@router.post("/watcher/start", response_model=MessageResponse)
async def start_watcher(config: Optional[WatcherConfig] = None):
"""
Start file watcher.
Args:
config: Optional watcher configuration. If not provided, uses settings from database.
Returns:
Success message
"""
from backend.scanning.library_scanner import library_scanner
from backend.core.settings_service import settings_service
# Use config from request or load from settings
if config is None:
library_paths = settings_service.get('library_paths', '')
# Handle both string (comma-separated) and list types
if isinstance(library_paths, list):
paths = [p.strip() for p in library_paths if p and p.strip()]
elif isinstance(library_paths, str) and library_paths:
paths = [p.strip() for p in library_paths.split(',') if p.strip()]
else:
paths = []
recursive = True
else:
paths = config.paths
recursive = config.recursive
if not paths:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No library paths configured. Please configure library_paths in settings."
)
library_scanner.start_file_watcher(
paths=paths,
recursive=recursive
)
logger.info(f"File watcher started via API: {paths}")
return MessageResponse(message="File watcher started successfully")
@router.post("/watcher/stop", response_model=MessageResponse)
async def stop_watcher():
"""
Stop file watcher.
Returns:
Success message
"""
from backend.scanning.library_scanner import library_scanner
library_scanner.stop_file_watcher()
logger.info("File watcher stopped via API")
return MessageResponse(message="File watcher stopped successfully")
@router.post("/analyze", response_model=dict)
async def analyze_file(file_path: str):
"""
Analyze a single file.
Args:
file_path: Path to file to analyze
Returns:
File analysis result
Raises:
400: Invalid file path
404: File not found
"""
from backend.scanning.file_analyzer import FileAnalyzer
import os
if not os.path.exists(file_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"File not found: {file_path}"
)
if not os.path.isfile(file_path):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Path is not a file: {file_path}"
)
analyzer = FileAnalyzer()
try:
analysis = analyzer.analyze(file_path)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to analyze file: {str(e)}"
)
return analysis.to_dict()

323
backend/api/settings.py Normal file
View File

@@ -0,0 +1,323 @@
"""Settings management API routes."""
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/settings", tags=["settings"])
# === REQUEST/RESPONSE MODELS ===
class SettingResponse(BaseModel):
"""Setting response model."""
id: int
key: str
value: Optional[str]
description: Optional[str]
category: Optional[str]
value_type: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
class SettingUpdateRequest(BaseModel):
"""Setting update request."""
value: str = Field(..., description="New value (as string)")
class Config:
json_schema_extra = {
"example": {
"value": "true"
}
}
class SettingCreateRequest(BaseModel):
"""Setting create request."""
key: str = Field(..., description="Setting key")
value: Optional[str] = Field(None, description="Setting value")
description: Optional[str] = Field(None, description="Description")
category: Optional[str] = Field(None, description="Category")
value_type: Optional[str] = Field("string", description="Value type")
class Config:
json_schema_extra = {
"example": {
"key": "custom_setting",
"value": "value",
"description": "Custom setting description",
"category": "general",
"value_type": "string"
}
}
class BulkUpdateRequest(BaseModel):
"""Bulk update request."""
settings: dict = Field(..., description="Dictionary of key-value pairs")
class Config:
json_schema_extra = {
"example": {
"settings": {
"worker_cpu_count": "2",
"worker_gpu_count": "1",
"scanner_enabled": "true"
}
}
}
class MessageResponse(BaseModel):
"""Generic message response."""
message: str
# === ROUTES ===
@router.get("/", response_model=List[SettingResponse])
async def get_all_settings(category: Optional[str] = Query(None, description="Filter by category")):
"""
Get all settings or filter by category.
Args:
category: Optional category filter (general, workers, transcription, scanner, bazarr)
Returns:
List of settings
"""
from backend.core.settings_service import settings_service
if category:
settings = settings_service.get_by_category(category)
else:
settings = settings_service.get_all()
return [SettingResponse(**s.to_dict()) for s in settings]
@router.get("/{key}", response_model=SettingResponse)
async def get_setting(key: str):
"""
Get a specific setting by key.
Args:
key: Setting key
Returns:
Setting object
Raises:
404: Setting not found
"""
from backend.core.database import database
from backend.core.settings_model import SystemSettings
with database.get_session() as session:
setting = session.query(SystemSettings).filter(SystemSettings.key == key).first()
if not setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Setting '{key}' not found"
)
return SettingResponse(**setting.to_dict())
@router.put("/{key}", response_model=SettingResponse)
async def update_setting(key: str, request: SettingUpdateRequest):
"""
Update a setting value.
Args:
key: Setting key
request: Update request with new value
Returns:
Updated setting object
Raises:
404: Setting not found
400: Invalid value (e.g., GPU workers without GPU)
"""
from backend.core.settings_service import settings_service
from backend.core.database import database
from backend.core.settings_model import SystemSettings
from backend.core.system_monitor import system_monitor
value = request.value
# Validate GPU worker count - force to 0 if no GPU available
if key == 'worker_gpu_count':
gpu_count = int(value) if value else 0
if gpu_count > 0 and system_monitor.gpu_count == 0:
logger.warning(
f"Attempted to set worker_gpu_count={gpu_count} but no GPU detected. "
"Forcing to 0."
)
value = '0'
success = settings_service.set(key, value)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update setting '{key}'"
)
# Return updated setting
with database.get_session() as session:
setting = session.query(SystemSettings).filter(SystemSettings.key == key).first()
if not setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Setting '{key}' not found"
)
return SettingResponse(**setting.to_dict())
@router.post("/bulk-update", response_model=MessageResponse)
async def bulk_update_settings(request: BulkUpdateRequest):
"""
Update multiple settings at once.
Args:
request: Bulk update request with settings dictionary
Returns:
Success message
"""
from backend.core.settings_service import settings_service
from backend.core.system_monitor import system_monitor
# Validate GPU worker count - force to 0 if no GPU available
settings_to_update = request.settings.copy()
if 'worker_gpu_count' in settings_to_update:
gpu_count = int(settings_to_update.get('worker_gpu_count', 0))
if gpu_count > 0 and system_monitor.gpu_count == 0:
logger.warning(
f"Attempted to set worker_gpu_count={gpu_count} but no GPU detected. "
"Forcing to 0."
)
settings_to_update['worker_gpu_count'] = '0'
success = settings_service.bulk_update(settings_to_update)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update settings"
)
logger.info(f"Bulk updated {len(request.settings)} settings")
return MessageResponse(message=f"Updated {len(request.settings)} settings successfully")
@router.post("/", response_model=SettingResponse, status_code=status.HTTP_201_CREATED)
async def create_setting(request: SettingCreateRequest):
"""
Create a new setting.
Args:
request: Create request with setting details
Returns:
Created setting object
Raises:
409: Setting already exists
"""
from backend.core.settings_service import settings_service
from backend.core.database import database
from backend.core.settings_model import SystemSettings
# Check if exists
with database.get_session() as session:
existing = session.query(SystemSettings).filter(SystemSettings.key == request.key).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Setting '{request.key}' already exists"
)
# Create
success = settings_service.set(
key=request.key,
value=request.value,
description=request.description,
category=request.category,
value_type=request.value_type
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create setting"
)
# Return created setting
with database.get_session() as session:
setting = session.query(SystemSettings).filter(SystemSettings.key == request.key).first()
return SettingResponse(**setting.to_dict())
@router.delete("/{key}", response_model=MessageResponse)
async def delete_setting(key: str):
"""
Delete a setting.
Args:
key: Setting key
Returns:
Success message
Raises:
404: Setting not found
"""
from backend.core.settings_service import settings_service
success = settings_service.delete(key)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Setting '{key}' not found"
)
logger.info(f"Setting deleted: {key}")
return MessageResponse(message=f"Setting '{key}' deleted successfully")
@router.post("/init-defaults", response_model=MessageResponse)
async def init_default_settings():
"""
Initialize default settings.
Creates all default settings if they don't exist.
Safe to call multiple times (won't overwrite existing).
Returns:
Success message
"""
from backend.core.settings_service import settings_service
try:
settings_service.init_default_settings()
return MessageResponse(message="Default settings initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize default settings: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to initialize default settings: {str(e)}"
)

313
backend/api/setup_wizard.py Normal file
View File

@@ -0,0 +1,313 @@
"""Setup wizard API endpoints."""
import logging
import secrets
from typing import List, Optional
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/setup", tags=["setup"])
# === REQUEST/RESPONSE MODELS ===
class SetupStatusResponse(BaseModel):
"""Setup status response."""
is_first_run: bool
setup_completed: bool
class WorkerConfig(BaseModel):
"""Worker configuration."""
count: int = Field(default=1, ge=0, le=10, description="Number of workers to start")
type: str = Field(default="cpu", description="Worker type: cpu or gpu")
class ScannerConfig(BaseModel):
"""Scanner configuration."""
interval_minutes: int = Field(default=360, ge=1, le=10080, description="Scan interval in minutes")
class StandaloneSetupRequest(BaseModel):
"""Standalone mode setup request."""
library_paths: List[str] = Field(..., description="Library paths to scan")
scan_rules: List[dict] = Field(..., description="Initial scan rules")
worker_config: Optional[WorkerConfig] = Field(default=None, description="Worker configuration")
scanner_config: Optional[ScannerConfig] = Field(default=None, description="Scanner configuration")
class Config:
json_schema_extra = {
"example": {
"library_paths": ["/media/anime", "/media/movies"],
"scan_rules": [
{
"name": "Japanese to Spanish",
"audio_language_is": "jpn",
"missing_external_subtitle_lang": "spa",
"target_language": "spa",
"action_type": "transcribe"
}
],
"worker_config": {
"count": 1,
"type": "cpu"
},
"scanner_config": {
"interval_minutes": 360
}
}
}
class BazarrSlaveSetupRequest(BaseModel):
"""Bazarr slave mode setup request."""
pass # No additional config needed
class BazarrConnectionInfo(BaseModel):
"""Bazarr connection information."""
mode: str = "bazarr_slave"
host: str
port: int
api_key: str
provider_url: str
class SetupCompleteResponse(BaseModel):
"""Setup complete response."""
success: bool
message: str
bazarr_info: Optional[BazarrConnectionInfo] = None
# === ROUTES ===
@router.get("/status", response_model=SetupStatusResponse)
async def get_setup_status():
"""
Check if this is the first run or setup is completed.
Returns:
Setup status
"""
from backend.core.settings_service import settings_service
# Check if setup_completed setting exists
setup_completed = settings_service.get("setup_completed", None)
return SetupStatusResponse(
is_first_run=setup_completed is None,
setup_completed=setup_completed == "true" if setup_completed else False
)
@router.post("/standalone", response_model=SetupCompleteResponse)
async def setup_standalone_mode(request: StandaloneSetupRequest):
"""
Configure standalone mode with library paths and scan rules.
Args:
request: Standalone setup configuration
Returns:
Setup completion status
"""
from backend.core.settings_service import settings_service
from backend.core.database import database
from backend.scanning.models import ScanRule
try:
# Set operation mode
settings_service.set("operation_mode", "standalone",
description="Operation mode",
category="general",
value_type="string")
# Set library paths
library_paths_str = ",".join(request.library_paths)
settings_service.set("library_paths", library_paths_str,
description="Library paths to scan",
category="general",
value_type="list")
# Enable scanner by default
settings_service.set("scanner_enabled", "true",
description="Enable library scanner",
category="scanner",
value_type="boolean")
# Configure scanner interval if provided
if request.scanner_config:
settings_service.set("scanner_schedule_interval_minutes",
str(request.scanner_config.interval_minutes),
description="Scanner interval in minutes",
category="scanner",
value_type="integer")
else:
# Default: 6 hours
settings_service.set("scanner_schedule_interval_minutes", "360",
description="Scanner interval in minutes",
category="scanner",
value_type="integer")
# Configure worker auto-start if provided
if request.worker_config:
settings_service.set("worker_auto_start_count",
str(request.worker_config.count),
description="Number of workers to start automatically",
category="workers",
value_type="integer")
settings_service.set("worker_auto_start_type",
request.worker_config.type,
description="Type of workers to start (cpu/gpu)",
category="workers",
value_type="string")
else:
# Default: 1 CPU worker
settings_service.set("worker_auto_start_count", "1",
description="Number of workers to start automatically",
category="workers",
value_type="integer")
settings_service.set("worker_auto_start_type", "cpu",
description="Type of workers to start (cpu/gpu)",
category="workers",
value_type="string")
# Create scan rules
with database.get_session() as session:
for idx, rule_data in enumerate(request.scan_rules):
rule = ScanRule(
name=rule_data.get("name", f"Rule {idx + 1}"),
enabled=True,
priority=rule_data.get("priority", 10),
audio_language_is=rule_data.get("audio_language_is"),
audio_language_not=rule_data.get("audio_language_not"),
audio_track_count_min=rule_data.get("audio_track_count_min"),
has_embedded_subtitle_lang=rule_data.get("has_embedded_subtitle_lang"),
missing_embedded_subtitle_lang=rule_data.get("missing_embedded_subtitle_lang"),
missing_external_subtitle_lang=rule_data.get("missing_external_subtitle_lang"),
file_extension=rule_data.get("file_extension", ".mkv,.mp4,.avi"),
action_type=rule_data.get("action_type", "transcribe"),
target_language=rule_data.get("target_language", "spa"),
quality_preset=rule_data.get("quality_preset", "fast"),
job_priority=rule_data.get("job_priority", 5)
)
session.add(rule)
session.commit()
# Mark setup as completed
settings_service.set("setup_completed", "true",
description="Setup wizard completed",
category="general",
value_type="boolean")
logger.info("Standalone mode setup completed successfully")
return SetupCompleteResponse(
success=True,
message="Standalone mode configured successfully"
)
except Exception as e:
logger.error(f"Failed to setup standalone mode: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Setup failed: {str(e)}"
)
@router.post("/bazarr-slave", response_model=SetupCompleteResponse)
async def setup_bazarr_slave_mode(request: BazarrSlaveSetupRequest):
"""
Configure Bazarr slave mode and generate API key.
Args:
request: Bazarr slave setup configuration
Returns:
Setup completion status with connection info
"""
from backend.core.settings_service import settings_service
try:
# Set operation mode
settings_service.set("operation_mode", "bazarr_slave",
description="Operation mode",
category="general",
value_type="string")
# Generate API key
api_key = secrets.token_urlsafe(32)
settings_service.set("bazarr_api_key", api_key,
description="Bazarr provider API key",
category="bazarr",
value_type="string")
# Enable Bazarr provider
settings_service.set("bazarr_provider_enabled", "true",
description="Enable Bazarr provider mode",
category="bazarr",
value_type="boolean")
# Disable scanner (not needed in slave mode)
settings_service.set("scanner_enabled", "false",
description="Enable library scanner",
category="scanner",
value_type="boolean")
# Mark setup as completed
settings_service.set("setup_completed", "true",
description="Setup wizard completed",
category="general",
value_type="boolean")
# Get host and port from settings
host = getattr(app_settings, "API_HOST", "0.0.0.0")
port = getattr(app_settings, "API_PORT", 8000)
# Create connection info
bazarr_info = BazarrConnectionInfo(
mode="bazarr_slave",
host=host if host != "0.0.0.0" else "127.0.0.1",
port=port,
api_key=api_key,
provider_url=f"http://{host if host != '0.0.0.0' else '127.0.0.1'}:{port}"
)
logger.info("Bazarr slave mode setup completed successfully")
return SetupCompleteResponse(
success=True,
message="Bazarr slave mode configured successfully",
bazarr_info=bazarr_info
)
except Exception as e:
logger.error(f"Failed to setup Bazarr slave mode: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Setup failed: {str(e)}"
)
@router.post("/skip")
async def skip_setup():
"""
Skip setup wizard (for advanced users).
Returns:
Success message
"""
from backend.core.settings_service import settings_service
settings_service.set("setup_completed", "true",
description="Setup wizard completed",
category="general",
value_type="boolean")
logger.info("Setup wizard skipped")
return {"message": "Setup wizard skipped"}

210
backend/api/system.py Normal file
View File

@@ -0,0 +1,210 @@
"""System resources monitoring API."""
import logging
import psutil
from typing import List, Optional
from fastapi import APIRouter
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/system", tags=["system"])
# === RESPONSE MODELS ===
class CPUInfo(BaseModel):
"""CPU information."""
usage_percent: float
count_logical: int
count_physical: int
frequency_mhz: Optional[float] = None
class MemoryInfo(BaseModel):
"""Memory information."""
total_gb: float
used_gb: float
free_gb: float
usage_percent: float
class GPUInfo(BaseModel):
"""GPU information."""
id: int
name: str
memory_total_mb: Optional[int] = None
memory_used_mb: Optional[int] = None
memory_free_mb: Optional[int] = None
utilization_percent: Optional[int] = None
class SystemResourcesResponse(BaseModel):
"""System resources response."""
cpu: CPUInfo
memory: MemoryInfo
gpus: List[GPUInfo]
# === ROUTES ===
@router.get("/resources", response_model=SystemResourcesResponse)
async def get_system_resources():
"""
Get current system resources (CPU, RAM, GPU).
Returns:
System resources information
"""
# CPU info
cpu_percent = psutil.cpu_percent(interval=0.1)
cpu_count_logical = psutil.cpu_count(logical=True)
cpu_count_physical = psutil.cpu_count(logical=False)
cpu_freq = psutil.cpu_freq()
cpu_info = CPUInfo(
usage_percent=cpu_percent,
count_logical=cpu_count_logical or 0,
count_physical=cpu_count_physical or 0,
frequency_mhz=cpu_freq.current if cpu_freq else 0
)
# Memory info
mem = psutil.virtual_memory()
memory_info = MemoryInfo(
total_gb=round(mem.total / (1024**3), 2),
used_gb=round(mem.used / (1024**3), 2),
free_gb=round(mem.available / (1024**3), 2),
usage_percent=round(mem.percent, 1)
)
# GPU info - try to detect NVIDIA GPUs
gpus = []
try:
import pynvml
pynvml.nvmlInit()
device_count = pynvml.nvmlDeviceGetCount()
for i in range(device_count):
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
name = pynvml.nvmlDeviceGetName(handle)
memory_info_gpu = pynvml.nvmlDeviceGetMemoryInfo(handle)
utilization = pynvml.nvmlDeviceGetUtilizationRates(handle)
gpus.append(GPUInfo(
id=i,
name=name if isinstance(name, str) else name.decode('utf-8'),
memory_total_mb=memory_info_gpu.total // (1024**2),
memory_used_mb=memory_info_gpu.used // (1024**2),
memory_free_mb=memory_info_gpu.free // (1024**2),
utilization_percent=utilization.gpu
))
pynvml.nvmlShutdown()
except Exception as e:
logger.debug(f"Could not get GPU info: {e}")
# No GPUs or pynvml not available
pass
return SystemResourcesResponse(
cpu=cpu_info,
memory=memory_info,
gpus=gpus
)
@router.get("/cpu", response_model=CPUInfo)
async def get_cpu_info():
"""Get CPU information."""
cpu_percent = psutil.cpu_percent(interval=0.1)
cpu_count_logical = psutil.cpu_count(logical=True)
cpu_count_physical = psutil.cpu_count(logical=False)
cpu_freq = psutil.cpu_freq()
return CPUInfo(
usage_percent=cpu_percent,
count_logical=cpu_count_logical or 0,
count_physical=cpu_count_physical or 0,
frequency_mhz=cpu_freq.current if cpu_freq else 0
)
@router.get("/memory", response_model=MemoryInfo)
async def get_memory_info():
"""Get memory information."""
mem = psutil.virtual_memory()
return MemoryInfo(
total_gb=round(mem.total / (1024**3), 2),
used_gb=round(mem.used / (1024**3), 2),
free_gb=round(mem.available / (1024**3), 2),
usage_percent=round(mem.percent, 1)
)
@router.get("/gpus", response_model=List[GPUInfo])
async def get_gpus_info():
"""Get all GPUs information."""
gpus = []
try:
import pynvml
pynvml.nvmlInit()
device_count = pynvml.nvmlDeviceGetCount()
for i in range(device_count):
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
name = pynvml.nvmlDeviceGetName(handle)
memory_info_gpu = pynvml.nvmlDeviceGetMemoryInfo(handle)
utilization = pynvml.nvmlDeviceGetUtilizationRates(handle)
gpus.append(GPUInfo(
id=i,
name=name if isinstance(name, str) else name.decode('utf-8'),
memory_total_mb=memory_info_gpu.total // (1024**2),
memory_used_mb=memory_info_gpu.used // (1024**2),
memory_free_mb=memory_info_gpu.free // (1024**2),
utilization_percent=utilization.gpu
))
pynvml.nvmlShutdown()
except Exception as e:
logger.debug(f"Could not get GPU info: {e}")
return gpus
@router.get("/gpu/{device_id}", response_model=GPUInfo)
async def get_gpu_info(device_id: int):
"""Get specific GPU information."""
try:
import pynvml
pynvml.nvmlInit()
handle = pynvml.nvmlDeviceGetHandleByIndex(device_id)
name = pynvml.nvmlDeviceGetName(handle)
memory_info_gpu = pynvml.nvmlDeviceGetMemoryInfo(handle)
utilization = pynvml.nvmlDeviceGetUtilizationRates(handle)
gpu = GPUInfo(
id=device_id,
name=name if isinstance(name, str) else name.decode('utf-8'),
memory_total_mb=memory_info_gpu.total // (1024**2),
memory_used_mb=memory_info_gpu.used // (1024**2),
memory_free_mb=memory_info_gpu.free // (1024**2),
utilization_percent=utilization.gpu
)
pynvml.nvmlShutdown()
return gpu
except Exception as e:
logger.error(f"Could not get GPU {device_id} info: {e}")
# Return basic info if can't get details
return GPUInfo(
id=device_id,
name=f"GPU {device_id}",
memory_total_mb=None,
memory_used_mb=None,
memory_free_mb=None,
utilization_percent=None
)

View File

@@ -0,0 +1,268 @@
"""Worker pool management API endpoints."""
from typing import Optional
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from backend.core.worker_pool import worker_pool
from backend.core.worker import WorkerType
router = APIRouter(prefix="/api/workers", tags=["workers"])
# === Request/Response Models ===
class AddWorkerRequest(BaseModel):
"""Request to add a new worker."""
type: str = Field(..., description="Worker type: 'cpu' or 'gpu'")
device_id: Optional[int] = Field(None, description="GPU device ID (required for GPU workers)")
class Config:
json_schema_extra = {
"example": {
"type": "gpu",
"device_id": 0
}
}
class AddWorkerResponse(BaseModel):
"""Response after adding a worker."""
worker_id: str
message: str
class WorkerStatusResponse(BaseModel):
"""Worker status information."""
worker_id: str
status: str
worker_type: str
device_id: Optional[int]
current_job_id: Optional[str]
jobs_completed: int
jobs_failed: int
started_at: Optional[str]
class PoolStatsResponse(BaseModel):
"""Worker pool statistics."""
pool: dict
jobs: dict
queue: dict
class HealthCheckResponse(BaseModel):
"""Health check results."""
timestamp: str
total_workers: int
dead_workers: list
restarted_workers: list
healthy: bool
# === Endpoints ===
@router.get("/", response_model=list)
async def list_workers():
"""
List all workers with their status.
Returns:
List of worker status dictionaries
"""
return worker_pool.get_all_workers_status()
@router.get("/{worker_id}", response_model=WorkerStatusResponse)
async def get_worker_status(worker_id: str):
"""
Get status of a specific worker.
Args:
worker_id: Worker ID
Returns:
Worker status information
Raises:
HTTPException: If worker not found
"""
status = worker_pool.get_worker_status(worker_id)
if not status:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Worker {worker_id} not found"
)
return status
@router.post("/", response_model=AddWorkerResponse, status_code=status.HTTP_201_CREATED)
async def add_worker(request: AddWorkerRequest):
"""
Add a new worker to the pool.
Args:
request: Worker configuration
Returns:
Worker ID and success message
Raises:
HTTPException: If invalid configuration
"""
# Validate worker type
worker_type_str = request.type.lower()
if worker_type_str not in ["cpu", "gpu"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Worker type must be 'cpu' or 'gpu'"
)
# Map to WorkerType enum
worker_type = WorkerType.CPU if worker_type_str == "cpu" else WorkerType.GPU
# Validate GPU device_id
if worker_type == WorkerType.GPU:
if request.device_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="device_id is required for GPU workers"
)
if request.device_id < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="device_id must be non-negative"
)
# Add worker
try:
worker_id = worker_pool.add_worker(worker_type, request.device_id)
return AddWorkerResponse(
worker_id=worker_id,
message=f"Worker {worker_id} added successfully"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add worker: {str(e)}"
)
@router.delete("/{worker_id}")
async def remove_worker(worker_id: str, timeout: int = 30):
"""
Remove a worker from the pool.
Args:
worker_id: Worker ID to remove
timeout: Maximum time to wait for worker to stop (seconds)
Returns:
Success message
Raises:
HTTPException: If worker not found or removal fails
"""
success = worker_pool.remove_worker(worker_id, timeout=float(timeout))
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Worker {worker_id} not found"
)
return {"message": f"Worker {worker_id} removed successfully"}
@router.get("/pool/stats", response_model=PoolStatsResponse)
async def get_pool_stats():
"""
Get overall worker pool statistics.
Returns:
Pool statistics including worker counts, job stats, and queue info
"""
return worker_pool.get_pool_stats()
@router.post("/pool/start")
async def start_pool(cpu_workers: int = 0, gpu_workers: int = 0):
"""
Start the worker pool.
Args:
cpu_workers: Number of CPU workers to start
gpu_workers: Number of GPU workers to start
Returns:
Success message
"""
worker_pool.start(cpu_workers=cpu_workers, gpu_workers=gpu_workers)
return {
"message": f"Worker pool started with {cpu_workers} CPU and {gpu_workers} GPU workers"
}
@router.post("/pool/stop")
async def stop_pool(timeout: int = 30):
"""
Stop the worker pool.
Args:
timeout: Maximum time to wait for each worker to stop (seconds)
Returns:
Success message
"""
worker_pool.stop(timeout=float(timeout))
return {"message": "Worker pool stopped successfully"}
@router.get("/pool/health", response_model=HealthCheckResponse)
async def health_check():
"""
Perform health check on all workers.
Automatically restarts dead workers if configured.
Returns:
Health check results
"""
return worker_pool.health_check()
@router.post("/pool/autoscale")
async def autoscale_pool(target_workers: int):
"""
Auto-scale worker pool to target number.
Args:
target_workers: Target number of workers
Returns:
Success message
Raises:
HTTPException: If invalid target
"""
if target_workers < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="target_workers must be non-negative"
)
worker_pool.auto_scale(target_workers)
return {"message": f"Pool scaled to {target_workers} workers"}

329
backend/api/workers.py Normal file
View File

@@ -0,0 +1,329 @@
"""Worker management API routes."""
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from backend.core.worker import WorkerType
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/workers", tags=["workers"])
# === REQUEST/RESPONSE MODELS ===
class WorkerAddRequest(BaseModel):
"""Request to add a new worker."""
worker_type: str = Field(..., description="Worker type: 'cpu' or 'gpu'")
device_id: Optional[int] = Field(None, description="GPU device ID (only for GPU workers)")
class Config:
json_schema_extra = {
"example": {
"worker_type": "gpu",
"device_id": 0
}
}
class WorkerStatusResponse(BaseModel):
"""Worker status response."""
worker_id: str
worker_type: str
device_id: Optional[int]
status: str
current_job_id: Optional[str]
jobs_completed: int
jobs_failed: int
uptime_seconds: float
current_job_progress: float
current_job_eta: Optional[int]
class WorkerPoolStatsResponse(BaseModel):
"""Worker pool statistics response."""
total_workers: int
cpu_workers: int
gpu_workers: int
idle_workers: int
busy_workers: int
stopped_workers: int
error_workers: int
total_jobs_completed: int
total_jobs_failed: int
uptime_seconds: Optional[float]
is_running: bool
class MessageResponse(BaseModel):
"""Generic message response."""
message: str
# === ROUTES ===
@router.get("/", response_model=List[WorkerStatusResponse])
async def get_all_workers():
"""
Get status of all workers.
Returns:
List of worker status objects
"""
from backend.app import worker_pool
from datetime import datetime, timezone
from dateutil import parser
workers_status = worker_pool.get_all_workers_status()
result = []
for w in workers_status:
# Calculate uptime
uptime_seconds = 0.0
if w.get("started_at"):
try:
started = parser.parse(w["started_at"])
# Remove timezone info for comparison if needed
if started.tzinfo is None:
from datetime import timezone
started = started.replace(tzinfo=timezone.utc)
uptime_seconds = (datetime.now(timezone.utc) - started).total_seconds()
except Exception as e:
logger.warning(f"Failed to parse started_at: {e}")
uptime_seconds = 0.0
result.append(
WorkerStatusResponse(
worker_id=w["worker_id"],
worker_type=w["type"],
device_id=w.get("device_id"),
status=w["status"],
current_job_id=w.get("current_job_id"),
jobs_completed=w["jobs_completed"],
jobs_failed=w["jobs_failed"],
uptime_seconds=uptime_seconds,
current_job_progress=w.get("current_job_progress", 0.0),
current_job_eta=w.get("current_job_eta"),
)
)
return result
@router.get("/stats", response_model=WorkerPoolStatsResponse)
async def get_pool_stats():
"""
Get worker pool statistics.
Returns:
Pool statistics object
"""
from backend.app import worker_pool
from datetime import datetime, timezone
from dateutil import parser
stats = worker_pool.get_pool_stats()
pool_stats = stats.get('pool', {})
jobs_stats = stats.get('jobs', {})
# Calculate uptime
uptime_seconds = 0.0
if pool_stats.get('started_at'):
try:
started = parser.parse(pool_stats['started_at'])
# Remove timezone info for comparison if needed
if started.tzinfo is None:
from datetime import timezone
started = started.replace(tzinfo=timezone.utc)
uptime_seconds = (datetime.now(timezone.utc) - started).total_seconds()
except Exception as e:
logger.warning(f"Failed to parse pool started_at: {e}")
uptime_seconds = 0.0
return WorkerPoolStatsResponse(
total_workers=pool_stats.get('total_workers', 0),
cpu_workers=pool_stats.get('cpu_workers', 0),
gpu_workers=pool_stats.get('gpu_workers', 0),
idle_workers=pool_stats.get('idle_workers', 0),
busy_workers=pool_stats.get('busy_workers', 0),
stopped_workers=pool_stats.get('stopped_workers', 0),
error_workers=pool_stats.get('error_workers', 0),
total_jobs_completed=jobs_stats.get('completed', 0),
total_jobs_failed=jobs_stats.get('failed', 0),
uptime_seconds=uptime_seconds,
is_running=pool_stats.get('is_running', False)
)
@router.get("/{worker_id}", response_model=WorkerStatusResponse)
async def get_worker(worker_id: str):
"""
Get status of a specific worker.
Args:
worker_id: Worker ID
Returns:
Worker status object
Raises:
404: Worker not found
"""
from backend.app import worker_pool
status_dict = worker_pool.get_worker_status(worker_id)
if not status_dict:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Worker {worker_id} not found"
)
return WorkerStatusResponse(
worker_id=status_dict["worker_id"],
worker_type=status_dict["type"], # Fixed: use "type" instead of "worker_type"
device_id=status_dict.get("device_id"),
status=status_dict["status"],
current_job_id=status_dict.get("current_job_id"),
jobs_completed=status_dict["jobs_completed"],
jobs_failed=status_dict["jobs_failed"],
uptime_seconds=status_dict.get("uptime_seconds", 0),
current_job_progress=status_dict.get("current_job_progress", 0.0),
current_job_eta=status_dict.get("current_job_eta"),
)
@router.post("/", response_model=WorkerStatusResponse, status_code=status.HTTP_201_CREATED)
async def add_worker(request: WorkerAddRequest):
"""
Add a new worker to the pool.
Args:
request: Worker add request
Returns:
Created worker status
Raises:
400: Invalid worker type or configuration
"""
from backend.app import worker_pool
# Validate worker type
try:
wtype = WorkerType(request.worker_type.lower())
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid worker type: {request.worker_type}. Must be 'cpu' or 'gpu'"
)
# Validate GPU worker requirements
if wtype == WorkerType.GPU and request.device_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="device_id is required for GPU workers"
)
# Add worker
worker_id = worker_pool.add_worker(wtype, request.device_id)
# Get status
status_dict = worker_pool.get_worker_status(worker_id)
if not status_dict:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create worker"
)
logger.info(f"Worker {worker_id} added via API")
return WorkerStatusResponse(
worker_id=status_dict["worker_id"],
worker_type=status_dict["type"], # Fixed: use "type" instead of "worker_type"
device_id=status_dict.get("device_id"),
status=status_dict["status"],
current_job_id=status_dict.get("current_job_id"),
jobs_completed=status_dict["jobs_completed"],
jobs_failed=status_dict["jobs_failed"],
uptime_seconds=status_dict.get("uptime_seconds", 0),
current_job_progress=status_dict.get("current_job_progress", 0.0),
current_job_eta=status_dict.get("current_job_eta"),
)
@router.delete("/{worker_id}", response_model=MessageResponse)
async def remove_worker(worker_id: str, timeout: float = 30.0):
"""
Remove a worker from the pool.
Args:
worker_id: Worker ID to remove
timeout: Maximum time to wait for worker to stop (seconds)
Returns:
Success message
Raises:
404: Worker not found
"""
from backend.app import worker_pool
success = worker_pool.remove_worker(worker_id, timeout=timeout)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Worker {worker_id} not found"
)
logger.info(f"Worker {worker_id} removed via API")
return MessageResponse(message=f"Worker {worker_id} removed successfully")
@router.post("/pool/start", response_model=MessageResponse)
async def start_pool(cpu_workers: int = 0, gpu_workers: int = 0):
"""
Start the worker pool.
Args:
cpu_workers: Number of CPU workers to start
gpu_workers: Number of GPU workers to start
Returns:
Success message
"""
from backend.app import worker_pool
worker_pool.start(cpu_workers=cpu_workers, gpu_workers=gpu_workers)
logger.info(f"Worker pool started via API: {cpu_workers} CPU, {gpu_workers} GPU")
return MessageResponse(
message=f"Worker pool started: {cpu_workers} CPU workers, {gpu_workers} GPU workers"
)
@router.post("/pool/stop", response_model=MessageResponse)
async def stop_pool(timeout: float = 30.0):
"""
Stop the worker pool.
Args:
timeout: Maximum time to wait for each worker to stop (seconds)
Returns:
Success message
"""
from backend.app import worker_pool
worker_pool.stop(timeout=timeout)
logger.info("Worker pool stopped via API")
return MessageResponse(message="Worker pool stopped successfully")