Files
Transcriptarr/backend/app.py

291 lines
10 KiB
Python

"""Main FastAPI application for Transcriptarr backend."""
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from backend.core.database import database
from backend.core.worker_pool import WorkerPool
from backend.core.queue_manager import queue_manager
from backend.scanning.library_scanner import library_scanner
# Import API routers
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
from backend.api.system import router as system_router
from backend.api.filesystem import router as filesystem_router
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# Global worker pool instance
worker_pool = WorkerPool()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Application lifespan manager.
Handles startup and shutdown tasks:
- Database initialization
- Worker pool startup (if configured)
- Library scanner startup (if configured)
- Graceful shutdown
"""
# === STARTUP ===
logger.info("=== Transcriptarr Backend Starting ===")
# Initialize database
logger.info("Initializing database...")
database.init_db()
logger.info("Database initialized")
# Clean up orphaned jobs from previous session
from backend.core.queue_manager import queue_manager
try:
cleaned = queue_manager.cleanup_orphaned_jobs()
if cleaned > 0:
logger.info(f"Cleaned up {cleaned} orphaned job(s) from previous session")
except Exception as e:
logger.error(f"Failed to cleanup orphaned jobs: {e}")
# Initialize default settings if needed
from backend.core.settings_service import settings_service
try:
settings_service.init_default_settings()
logger.info("Settings initialized")
except Exception as e:
logger.warning(f"Could not initialize settings: {e}")
# Initialize scanner stats from existing jobs if not already set
try:
from backend.core.models import Job, JobStatus
scan_count = settings_service.get('scanner_scan_count')
if scan_count is None or scan_count == 0:
# Count completed jobs as an approximation of files scanned
with database.get_session() as session:
completed_count = session.query(Job).filter(
Job.status == JobStatus.COMPLETED
).count()
if completed_count > 0:
settings_service.set('scanner_total_files_scanned', str(completed_count), category='scanner')
settings_service.set('scanner_scan_count', '1', category='scanner') # At least 1 scan happened
logger.info(f"Initialized scanner stats from existing jobs: {completed_count} files")
except Exception as e:
logger.warning(f"Could not initialize scanner stats: {e}")
# Start worker pool if configured (and Whisper is available)
from backend.transcription.transcriber import WHISPER_AVAILABLE
from backend.core.system_monitor import system_monitor
cpu_workers = int(settings_service.get("worker_cpu_count", 0))
gpu_workers = int(settings_service.get("worker_gpu_count", 0))
# Validate GPU workers - force to 0 if no GPU available
if gpu_workers > 0 and system_monitor.gpu_count == 0:
logger.warning(
f"GPU workers configured ({gpu_workers}) but no GPU detected. "
"GPU workers will NOT be started. Setting gpu_workers=0."
)
gpu_workers = 0
# Also update the setting to prevent confusion
settings_service.set("worker_gpu_count", "0")
if not WHISPER_AVAILABLE:
if cpu_workers > 0 or gpu_workers > 0:
logger.warning(
"Whisper is not installed but workers are configured. "
"Workers will NOT be started. Install stable-ts or faster-whisper to enable transcription."
)
elif cpu_workers > 0 or gpu_workers > 0:
logger.info(f"Starting worker pool: {cpu_workers} CPU, {gpu_workers} GPU")
worker_pool.start(cpu_workers=cpu_workers, gpu_workers=gpu_workers)
else:
logger.info("No workers configured to start automatically")
# Start library scanner scheduler (enabled by default)
scanner_enabled = settings_service.get("scanner_enabled", True)
if scanner_enabled in (True, "true", "True", "1", 1):
# Get library paths from settings
library_paths = settings_service.get("library_paths", "")
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 = []
if paths:
interval_minutes = int(settings_service.get("scanner_schedule_interval_minutes", 360))
logger.info(f"Starting library scanner scheduler (every {interval_minutes} minutes)")
library_scanner.start_scheduler(interval_minutes=interval_minutes)
else:
logger.info("Scanner enabled but no library paths configured - scheduler not started")
else:
logger.info("Library scanner scheduler disabled in settings")
# Start file watcher if configured
watcher_enabled = settings_service.get("watcher_enabled", False)
if watcher_enabled in (True, "true", "True", "1", 1):
library_paths = settings_service.get("library_paths", "")
if isinstance(library_paths, list):
watcher_paths = [p.strip() for p in library_paths if p and p.strip()]
elif isinstance(library_paths, str) and library_paths:
watcher_paths = [p.strip() for p in library_paths.split(",") if p.strip()]
else:
watcher_paths = []
if watcher_paths:
logger.info(f"Starting file watcher: {watcher_paths}")
library_scanner.start_file_watcher(
paths=watcher_paths,
recursive=True
)
else:
logger.info("File watcher enabled but no library paths configured")
logger.info("=== Transcriptarr Backend Started ===")
yield
# === SHUTDOWN ===
logger.info("=== Transcriptarr Backend Shutting Down ===")
# Stop library scanner first (quick operations)
logger.info("Stopping library scanner...")
try:
library_scanner.stop_scheduler()
library_scanner.stop_file_watcher()
except Exception as e:
logger.warning(f"Error stopping scanner: {e}")
# Stop worker pool with shorter timeout
logger.info("Stopping worker pool...")
try:
worker_pool.stop(timeout=5.0)
except Exception as e:
logger.warning(f"Error stopping worker pool: {e}")
logger.info("=== Transcriptarr Backend Stopped ===")
# Create FastAPI app
app = FastAPI(
title="Transcriptarr API",
description="AI-powered subtitle transcription service",
version="1.0.0",
lifespan=lifespan
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: Configure this properly
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount API routers
app.include_router(workers_router)
app.include_router(jobs_router)
app.include_router(scan_rules_router)
app.include_router(scanner_router)
app.include_router(settings_router)
app.include_router(setup_router)
app.include_router(system_router)
app.include_router(filesystem_router)
# === ROOT ENDPOINTS ===
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {
"status": "healthy",
"database": "connected",
"workers": len(worker_pool.workers),
"queue_size": len(queue_manager.get_queued_jobs())
}
@app.get("/api/status")
async def get_status():
"""
Get overall system status.
Returns comprehensive system status including:
- Worker pool status
- Queue statistics
- Scanner status
"""
pool_stats = worker_pool.get_pool_stats()
queue_stats = queue_manager.get_queue_stats()
scanner_status = library_scanner.get_status()
return {
"system": {
"status": "running",
"uptime_seconds": pool_stats.get("uptime_seconds"),
},
"workers": pool_stats,
"queue": queue_stats,
"scanner": scanner_status,
}
# === FRONTEND STATIC FILES ===
# Check if frontend build exists
frontend_path = Path(__file__).parent.parent / "frontend" / "dist"
if frontend_path.exists() and frontend_path.is_dir():
# Mount static assets
app.mount("/assets", StaticFiles(directory=str(frontend_path / "assets")), name="assets")
# Serve index.html for all frontend routes
@app.get("/")
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str = ""):
"""Serve frontend application."""
# Don't serve frontend for API routes
if full_path.startswith("api/") or full_path.startswith("health") or full_path.startswith("docs") or full_path.startswith("redoc") or full_path.startswith("openapi.json"):
return {"error": "Not found"}
index_file = frontend_path / "index.html"
if index_file.exists():
return FileResponse(str(index_file))
return {"error": "Frontend not built. Run: cd frontend && npm run build"}
else:
# No frontend build - serve API info
@app.get("/")
async def root():
"""Root endpoint - API info."""
return {
"name": "Transcriptarr API",
"version": "1.0.0",
"status": "running",
"message": "Frontend not built. Access API docs at /docs"
}
# Export worker_pool for API access
__all__ = ["app", "worker_pool"]