feat(cli): add CLI interface and setup wizard
- Add CLI with server, db, worker, scan, setup commands - Add interactive setup wizard for first-run configuration - Add FastAPI application with lifespan management - Update requirements.txt with all dependencies
This commit is contained in:
290
backend/app.py
Normal file
290
backend/app.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Main FastAPI application for TranscriptorIO 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("=== TranscriptorIO 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("=== TranscriptorIO Backend Started ===")
|
||||
|
||||
yield
|
||||
|
||||
# === SHUTDOWN ===
|
||||
logger.info("=== TranscriptorIO 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("=== TranscriptorIO Backend Stopped ===")
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="TranscriptorIO 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": "TranscriptorIO 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"]
|
||||
|
||||
222
backend/cli.py
Executable file
222
backend/cli.py
Executable file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI entry point for TranscriptorIO backend."""
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path to allow imports
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="TranscriptorIO - AI-powered subtitle transcription service"
|
||||
)
|
||||
|
||||
# Subcommands
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
|
||||
# Server command
|
||||
server_parser = subparsers.add_parser("server", help="Run FastAPI server")
|
||||
server_parser.add_argument(
|
||||
"--host",
|
||||
default="0.0.0.0",
|
||||
help="Host to bind to (default: 0.0.0.0)"
|
||||
)
|
||||
server_parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8000,
|
||||
help="Port to bind to (default: 8000)"
|
||||
)
|
||||
server_parser.add_argument(
|
||||
"--reload",
|
||||
action="store_true",
|
||||
help="Enable auto-reload for development"
|
||||
)
|
||||
server_parser.add_argument(
|
||||
"--workers",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of worker processes (default: 1)"
|
||||
)
|
||||
server_parser.add_argument(
|
||||
"--log-level",
|
||||
choices=["debug", "info", "warning", "error", "critical"],
|
||||
default="info",
|
||||
help="Log level (default: info)"
|
||||
)
|
||||
|
||||
# Database command
|
||||
db_parser = subparsers.add_parser("db", help="Database operations")
|
||||
db_parser.add_argument(
|
||||
"action",
|
||||
choices=["init", "migrate", "reset", "backup"],
|
||||
help="Database action"
|
||||
)
|
||||
|
||||
# Worker command
|
||||
worker_parser = subparsers.add_parser("worker", help="Start standalone worker")
|
||||
worker_parser.add_argument(
|
||||
"--type",
|
||||
choices=["cpu", "gpu"],
|
||||
default="cpu",
|
||||
help="Worker type (default: cpu)"
|
||||
)
|
||||
worker_parser.add_argument(
|
||||
"--device-id",
|
||||
type=int,
|
||||
default=0,
|
||||
help="GPU device ID (default: 0)"
|
||||
)
|
||||
|
||||
# Scanner command
|
||||
scan_parser = subparsers.add_parser("scan", help="Run library scan")
|
||||
scan_parser.add_argument(
|
||||
"paths",
|
||||
nargs="+",
|
||||
help="Paths to scan"
|
||||
)
|
||||
scan_parser.add_argument(
|
||||
"--no-recursive",
|
||||
action="store_true",
|
||||
help="Don't scan subdirectories"
|
||||
)
|
||||
|
||||
# Setup command
|
||||
subparsers.add_parser("setup", help="Run setup wizard")
|
||||
|
||||
# Parse arguments
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
# Execute command
|
||||
if args.command == "server":
|
||||
run_server(args)
|
||||
elif args.command == "db":
|
||||
run_db_command(args)
|
||||
elif args.command == "worker":
|
||||
run_worker(args)
|
||||
elif args.command == "scan":
|
||||
run_scan(args)
|
||||
elif args.command == "setup":
|
||||
run_setup()
|
||||
|
||||
|
||||
def run_server(args):
|
||||
"""Run FastAPI server."""
|
||||
print(f"🚀 Starting TranscriptorIO server on {args.host}:{args.port}")
|
||||
print(f"📖 API docs available at: http://{args.host}:{args.port}/docs")
|
||||
|
||||
uvicorn.run(
|
||||
"backend.app:app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=args.reload,
|
||||
workers=args.workers if not args.reload else 1,
|
||||
log_level=args.log_level,
|
||||
)
|
||||
|
||||
|
||||
def run_db_command(args):
|
||||
"""Run database command."""
|
||||
from backend.core.database import database
|
||||
|
||||
if args.action == "init":
|
||||
print("Initializing database...")
|
||||
database.init_db()
|
||||
print("✅ Database initialized")
|
||||
|
||||
elif args.action == "reset":
|
||||
print("⚠️ WARNING: This will delete all data!")
|
||||
confirm = input("Type 'yes' to confirm: ")
|
||||
|
||||
if confirm.lower() == "yes":
|
||||
print("Resetting database...")
|
||||
database.reset_db()
|
||||
print("✅ Database reset")
|
||||
else:
|
||||
print("❌ Cancelled")
|
||||
|
||||
elif args.action == "migrate":
|
||||
print("❌ Migrations not yet implemented")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.action == "backup":
|
||||
print("❌ Backup not yet implemented")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_worker(args):
|
||||
"""Run standalone worker."""
|
||||
from backend.core.worker import Worker, WorkerType
|
||||
import signal
|
||||
|
||||
worker_type = WorkerType.CPU if args.type == "cpu" else WorkerType.GPU
|
||||
device_id = args.device_id if worker_type == WorkerType.GPU else None
|
||||
|
||||
worker_id = f"standalone-{args.type}"
|
||||
if device_id is not None:
|
||||
worker_id += f"-{device_id}"
|
||||
|
||||
print(f"🔧 Starting standalone worker: {worker_id}")
|
||||
|
||||
worker = Worker(worker_id, worker_type, device_id)
|
||||
|
||||
# Handle shutdown
|
||||
def signal_handler(sig, frame):
|
||||
print("\n⏹️ Stopping worker...")
|
||||
worker.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
worker.start()
|
||||
|
||||
# Keep alive
|
||||
try:
|
||||
while True:
|
||||
import time
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
worker.stop()
|
||||
|
||||
|
||||
def run_scan(args):
|
||||
"""Run library scan."""
|
||||
from backend.scanning.library_scanner import library_scanner
|
||||
|
||||
print(f"🔍 Scanning {len(args.paths)} path(s)...")
|
||||
|
||||
result = library_scanner.scan_paths(
|
||||
paths=args.paths,
|
||||
recursive=not args.no_recursive
|
||||
)
|
||||
|
||||
print(f"\n✅ Scan complete:")
|
||||
print(f" 📁 Files scanned: {result['scanned_files']}")
|
||||
print(f" ✅ Matched: {result['matched_files']}")
|
||||
print(f" 📋 Jobs created: {result['jobs_created']}")
|
||||
print(f" ⏭️ Skipped: {result['skipped_files']}")
|
||||
|
||||
|
||||
def run_setup():
|
||||
"""Run setup wizard."""
|
||||
from backend.setup_wizard import SetupWizard
|
||||
|
||||
wizard = SetupWizard()
|
||||
wizard.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
568
backend/setup_wizard.py
Normal file
568
backend/setup_wizard.py
Normal file
@@ -0,0 +1,568 @@
|
||||
"""Setup wizard for first-time configuration."""
|
||||
import os
|
||||
import sys
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
|
||||
class SetupWizard:
|
||||
"""Interactive setup wizard for first run."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize setup wizard."""
|
||||
self.config_file = Path(".env")
|
||||
|
||||
def is_first_run(self) -> bool:
|
||||
"""
|
||||
Check if this is the first run.
|
||||
|
||||
Returns:
|
||||
True if first run (setup_completed setting is not true)
|
||||
"""
|
||||
try:
|
||||
from backend.core.settings_service import settings_service
|
||||
setup_completed = settings_service.get("setup_completed", None)
|
||||
return setup_completed != "true"
|
||||
except Exception:
|
||||
# Database not initialized yet, assume first run
|
||||
return True
|
||||
|
||||
def run(self) -> bool:
|
||||
"""
|
||||
Run the setup wizard.
|
||||
|
||||
Returns:
|
||||
True if setup completed successfully
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print(" 🎬 TranscriptorIO - First Run Setup Wizard")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
# Step 1: Select mode
|
||||
mode = self._select_mode()
|
||||
if not mode:
|
||||
return False
|
||||
|
||||
# Step 2: Configure based on mode
|
||||
if mode == "standalone":
|
||||
config = self._configure_standalone_mode()
|
||||
else: # bazarr
|
||||
config = self._configure_bazarr_mode()
|
||||
|
||||
if not config:
|
||||
return False
|
||||
|
||||
# Step 3: Save configuration to database
|
||||
return self._save_to_database(config)
|
||||
|
||||
def _select_mode(self) -> Optional[str]:
|
||||
"""
|
||||
Prompt user to select operation mode.
|
||||
|
||||
Returns:
|
||||
'standalone' or 'bazarr', or None if cancelled
|
||||
"""
|
||||
print("Select operation mode:\n")
|
||||
print(" 1. Standalone Mode")
|
||||
print(" - Automatic library scanning")
|
||||
print(" - Rule-based subtitle generation")
|
||||
print(" - Scheduled/real-time file watching")
|
||||
print()
|
||||
print(" 2. Bazarr Slave Mode")
|
||||
print(" - Receives tasks from Bazarr")
|
||||
print(" - Custom provider integration")
|
||||
print(" - On-demand transcription only")
|
||||
print()
|
||||
|
||||
while True:
|
||||
choice = input("Enter mode (1 or 2): ").strip()
|
||||
|
||||
if choice == "1":
|
||||
return "standalone"
|
||||
elif choice == "2":
|
||||
return "bazarr"
|
||||
elif choice.lower() in ["q", "quit", "exit"]:
|
||||
print("\nSetup cancelled.")
|
||||
return None
|
||||
else:
|
||||
print("Invalid choice. Please enter 1 or 2 (or 'q' to quit).\n")
|
||||
|
||||
def _configure_standalone_mode(self) -> Optional[dict]:
|
||||
"""
|
||||
Configure standalone mode settings.
|
||||
|
||||
Returns:
|
||||
Configuration dict or None if cancelled
|
||||
"""
|
||||
print("\n" + "-" * 70)
|
||||
print(" 📁 Standalone Mode Configuration")
|
||||
print("-" * 70 + "\n")
|
||||
|
||||
config = {
|
||||
"transcriptarr_mode": "standalone",
|
||||
"scanner_enabled": True,
|
||||
"scanner_schedule_enabled": True,
|
||||
"scanner_file_watcher_enabled": False,
|
||||
"bazarr_provider_enabled": False,
|
||||
}
|
||||
|
||||
# Step 1: Library paths
|
||||
print("Step 1: Library Paths")
|
||||
print("-" * 40)
|
||||
library_paths = self._configure_library_paths()
|
||||
if not library_paths:
|
||||
return None
|
||||
config["library_paths"] = library_paths
|
||||
|
||||
# Step 2: Scanner settings
|
||||
print("\nStep 2: Scanner Configuration")
|
||||
print("-" * 40)
|
||||
scanner_config = self._configure_scanner()
|
||||
config.update(scanner_config)
|
||||
|
||||
# Step 3: Worker configuration
|
||||
print("\nStep 3: Worker Configuration")
|
||||
print("-" * 40)
|
||||
worker_config = self._configure_workers()
|
||||
config.update(worker_config)
|
||||
|
||||
# Step 4: Scan rules (at least one)
|
||||
print("\nStep 4: Scan Rules")
|
||||
print("-" * 40)
|
||||
print("You need at least one scan rule to determine which files to process.\n")
|
||||
|
||||
rules = []
|
||||
while True:
|
||||
rule = self._create_scan_rule(len(rules) + 1)
|
||||
if rule:
|
||||
rules.append(rule)
|
||||
print(f"\n✅ Rule {len(rules)} created successfully!\n")
|
||||
|
||||
if len(rules) >= 1:
|
||||
add_more = input("Add another rule? (y/n) [n]: ").strip().lower()
|
||||
if add_more != "y":
|
||||
break
|
||||
else:
|
||||
if len(rules) == 0:
|
||||
print("\n⚠️ You need at least one rule. Let's try again.\n")
|
||||
else:
|
||||
break
|
||||
|
||||
config["scan_rules"] = rules
|
||||
|
||||
return config
|
||||
|
||||
def _configure_library_paths(self) -> Optional[List[str]]:
|
||||
"""
|
||||
Configure library paths to scan.
|
||||
|
||||
Returns:
|
||||
List of paths or None if cancelled
|
||||
"""
|
||||
print("Enter the folders where your media files are stored.")
|
||||
print("You can add multiple paths (one per line). Enter empty line when done.\n")
|
||||
print("Examples:")
|
||||
print(" /media/anime")
|
||||
print(" /mnt/movies")
|
||||
print(" /data/series\n")
|
||||
|
||||
paths = []
|
||||
while True:
|
||||
if len(paths) == 0:
|
||||
prompt = "Enter first path: "
|
||||
else:
|
||||
prompt = f"Enter path {len(paths) + 1} (or press Enter to finish): "
|
||||
|
||||
path = input(prompt).strip()
|
||||
|
||||
# Empty input
|
||||
if not path:
|
||||
if len(paths) == 0:
|
||||
print("❌ You need at least one path.\n")
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
# Validate path
|
||||
if not os.path.isabs(path):
|
||||
print("❌ Path must be absolute (start with /).\n")
|
||||
continue
|
||||
|
||||
if not os.path.isdir(path):
|
||||
print(f"⚠️ Warning: Path '{path}' does not exist.")
|
||||
confirm = input("Add it anyway? (y/n): ").strip().lower()
|
||||
if confirm != "y":
|
||||
continue
|
||||
|
||||
paths.append(path)
|
||||
print(f"✅ Added: {path}\n")
|
||||
|
||||
print(f"\n📁 Total paths configured: {len(paths)}")
|
||||
for i, p in enumerate(paths, 1):
|
||||
print(f" {i}. {p}")
|
||||
|
||||
return paths
|
||||
|
||||
def _configure_scanner(self) -> dict:
|
||||
"""
|
||||
Configure scanner settings.
|
||||
|
||||
Returns:
|
||||
Scanner configuration dict
|
||||
"""
|
||||
config = {}
|
||||
|
||||
# Scheduled scanning
|
||||
print("\n🕒 Scheduled Scanning")
|
||||
print("Scan your library periodically (e.g., every 60 minutes).\n")
|
||||
enable_schedule = input("Enable scheduled scanning? (y/n) [y]: ").strip().lower()
|
||||
config["scanner_schedule_enabled"] = enable_schedule != "n"
|
||||
|
||||
if config["scanner_schedule_enabled"]:
|
||||
while True:
|
||||
interval = input("Scan interval in minutes [60]: ").strip()
|
||||
if not interval:
|
||||
interval = "60"
|
||||
try:
|
||||
interval_int = int(interval)
|
||||
if interval_int < 1:
|
||||
print("❌ Interval must be at least 1 minute.\n")
|
||||
continue
|
||||
config["scanner_schedule_interval_minutes"] = interval_int
|
||||
break
|
||||
except ValueError:
|
||||
print("❌ Please enter a valid number.\n")
|
||||
|
||||
# File watcher
|
||||
print("\n👁️ Real-time File Watching")
|
||||
print("Detect new files immediately as they are added (more CPU intensive).\n")
|
||||
enable_watcher = input("Enable real-time file watching? (y/n) [n]: ").strip().lower()
|
||||
config["scanner_file_watcher_enabled"] = enable_watcher == "y"
|
||||
|
||||
return config
|
||||
|
||||
def _configure_workers(self) -> dict:
|
||||
"""
|
||||
Configure worker auto-start settings.
|
||||
|
||||
Returns:
|
||||
Worker configuration dict
|
||||
"""
|
||||
config = {}
|
||||
|
||||
print("\n⚙️ Worker Auto-Start Configuration")
|
||||
print("Workers process transcription jobs. Configure how many should start automatically.\n")
|
||||
|
||||
# Check if Whisper is available
|
||||
try:
|
||||
from backend.transcription.transcriber import WHISPER_AVAILABLE
|
||||
if not WHISPER_AVAILABLE:
|
||||
print("⚠️ WARNING: Whisper is not installed!")
|
||||
print(" Workers will not start until you install stable-ts or faster-whisper.")
|
||||
print(" You can configure workers now and install Whisper later.\n")
|
||||
except ImportError:
|
||||
print("⚠️ WARNING: Could not check Whisper availability.\n")
|
||||
|
||||
# CPU workers
|
||||
print("🖥️ CPU Workers")
|
||||
print("CPU workers use your processor. Recommended: 1-2 workers.\n")
|
||||
while True:
|
||||
cpu_input = input("Number of CPU workers to start on boot [1]: ").strip()
|
||||
if not cpu_input:
|
||||
cpu_input = "1"
|
||||
try:
|
||||
cpu_count = int(cpu_input)
|
||||
if cpu_count < 0:
|
||||
print("❌ Must be 0 or greater.\n")
|
||||
continue
|
||||
config["worker_cpu_count"] = cpu_count
|
||||
break
|
||||
except ValueError:
|
||||
print("❌ Please enter a valid number.\n")
|
||||
|
||||
# GPU workers
|
||||
print("\n🎮 GPU Workers")
|
||||
print("GPU workers use your graphics card (much faster if available).")
|
||||
print("Only configure if you have CUDA-compatible GPU.\n")
|
||||
while True:
|
||||
gpu_input = input("Number of GPU workers to start on boot [0]: ").strip()
|
||||
if not gpu_input:
|
||||
gpu_input = "0"
|
||||
try:
|
||||
gpu_count = int(gpu_input)
|
||||
if gpu_count < 0:
|
||||
print("❌ Must be 0 or greater.\n")
|
||||
continue
|
||||
config["worker_gpu_count"] = gpu_count
|
||||
break
|
||||
except ValueError:
|
||||
print("❌ Please enter a valid number.\n")
|
||||
|
||||
if config["worker_cpu_count"] == 0 and config["worker_gpu_count"] == 0:
|
||||
print("\n⚠️ No workers configured. You can add them later in Settings.")
|
||||
else:
|
||||
total = config["worker_cpu_count"] + config["worker_gpu_count"]
|
||||
print(f"\n✅ Configured {total} worker(s) to start automatically:")
|
||||
if config["worker_cpu_count"] > 0:
|
||||
print(f" • {config['worker_cpu_count']} CPU worker(s)")
|
||||
if config["worker_gpu_count"] > 0:
|
||||
print(f" • {config['worker_gpu_count']} GPU worker(s)")
|
||||
|
||||
return config
|
||||
|
||||
def _create_scan_rule(self, rule_number: int) -> Optional[dict]:
|
||||
"""
|
||||
Create a single scan rule interactively.
|
||||
|
||||
Args:
|
||||
rule_number: Rule number for display
|
||||
|
||||
Returns:
|
||||
Rule dict or None if cancelled
|
||||
"""
|
||||
print(f"\nCreating Rule #{rule_number}")
|
||||
print("=" * 40)
|
||||
|
||||
# Rule name
|
||||
name = input(f"Rule name (e.g., 'Japanese anime to Spanish'): ").strip()
|
||||
if not name:
|
||||
name = f"Rule {rule_number}"
|
||||
|
||||
# Source audio language
|
||||
print("\nSource audio language (ISO 639-2 code):")
|
||||
print(" jpn = Japanese")
|
||||
print(" eng = English")
|
||||
print(" ron = Romanian")
|
||||
print(" spa = Spanish")
|
||||
print(" (or leave empty for any language)")
|
||||
audio_lang = input("Audio language [any]: ").strip().lower() or None
|
||||
|
||||
# Task type
|
||||
print("\nAction type:")
|
||||
print(" 1. Transcribe (audio → English subtitles)")
|
||||
print(" 2. Translate (audio → English → target language)")
|
||||
print("\n📝 Note:")
|
||||
print(" • Transcribe: Always creates English subtitles (.eng.srt)")
|
||||
print(" • Translate: Creates English + target language subtitles (.eng.srt + .spa.srt)")
|
||||
while True:
|
||||
task_choice = input("Choose action (1 or 2) [1]: ").strip()
|
||||
if not task_choice or task_choice == "1":
|
||||
action_type = "transcribe"
|
||||
target_lang = "eng" # Transcribe always targets English
|
||||
print("✓ Target language set to: eng (English)")
|
||||
break
|
||||
elif task_choice == "2":
|
||||
action_type = "translate"
|
||||
print("\nTarget subtitle language (ISO 639-2 code):")
|
||||
print("Examples: spa (Spanish), fra (French), deu (German), ita (Italian)")
|
||||
target_lang = input("Target language: ").strip().lower()
|
||||
if not target_lang:
|
||||
print("❌ Target language is required for translate mode.")
|
||||
continue
|
||||
if target_lang == "eng":
|
||||
print("⚠️ Note: Target is English. Consider using 'transcribe' instead.")
|
||||
print(f"✓ Will create: .eng.srt + .{target_lang}.srt")
|
||||
break
|
||||
else:
|
||||
print("❌ Invalid choice. Please enter 1 or 2.\n")
|
||||
|
||||
# Check for missing subtitles
|
||||
print("\nOnly process files that are missing subtitles?")
|
||||
check_missing = input("Check for missing subtitle (y/n) [y]: ").strip().lower()
|
||||
missing_subtitle_lang = target_lang if check_missing != "n" else None
|
||||
|
||||
# Priority
|
||||
print("\nRule priority (higher = evaluated first):")
|
||||
while True:
|
||||
priority_input = input("Priority [10]: ").strip()
|
||||
if not priority_input:
|
||||
priority = 10
|
||||
break
|
||||
try:
|
||||
priority = int(priority_input)
|
||||
break
|
||||
except ValueError:
|
||||
print("❌ Please enter a valid number.\n")
|
||||
|
||||
rule = {
|
||||
"name": name,
|
||||
"enabled": True,
|
||||
"priority": priority,
|
||||
"audio_language_is": audio_lang,
|
||||
"missing_external_subtitle_lang": missing_subtitle_lang,
|
||||
"action_type": action_type,
|
||||
"target_language": target_lang,
|
||||
"quality_preset": "fast",
|
||||
"job_priority": 0,
|
||||
}
|
||||
|
||||
# Show summary
|
||||
print("\n📋 Rule Summary:")
|
||||
print(f" Name: {name}")
|
||||
print(f" Audio: {audio_lang or 'any'}")
|
||||
print(f" Action: {action_type}")
|
||||
if action_type == "transcribe":
|
||||
print(f" Output: .eng.srt (English subtitles)")
|
||||
else:
|
||||
print(f" Output: .eng.srt + .{target_lang}.srt")
|
||||
print(f" Check missing: {'yes' if missing_subtitle_lang else 'no'}")
|
||||
print(f" Priority: {priority}")
|
||||
|
||||
return rule
|
||||
|
||||
def _configure_bazarr_mode(self) -> Optional[dict]:
|
||||
"""
|
||||
Configure Bazarr slave mode settings.
|
||||
|
||||
Returns:
|
||||
Configuration dict or None if cancelled
|
||||
"""
|
||||
print("\n" + "-" * 70)
|
||||
print(" 🔌 Bazarr Slave Mode Configuration")
|
||||
print("-" * 70 + "\n")
|
||||
|
||||
config = {
|
||||
"transcriptarr_mode": "bazarr",
|
||||
"scanner_enabled": False,
|
||||
"scanner_schedule_enabled": False,
|
||||
"scanner_file_watcher_enabled": False,
|
||||
"bazarr_provider_enabled": True,
|
||||
}
|
||||
|
||||
# Get network info
|
||||
hostname = socket.gethostname()
|
||||
|
||||
# Try to get local IP
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception:
|
||||
local_ip = "127.0.0.1"
|
||||
|
||||
print("Bazarr will send transcription requests to this service.\n")
|
||||
print("📡 Connection Information:")
|
||||
print("=" * 70)
|
||||
print(f"\n Hostname: {hostname}")
|
||||
print(f" Local IP: {local_ip}")
|
||||
print(f" Port: 8000 (default)\n")
|
||||
|
||||
print("Configure Bazarr custom provider with these URLs:")
|
||||
print("-" * 70)
|
||||
print(f"\n Localhost (same machine):")
|
||||
print(f" http://localhost:8000/asr")
|
||||
print(f" http://127.0.0.1:8000/asr\n")
|
||||
|
||||
print(f" Local Network (other machines):")
|
||||
print(f" http://{local_ip}:8000/asr\n")
|
||||
|
||||
print("=" * 70)
|
||||
print("\nPress Enter to continue...")
|
||||
input()
|
||||
|
||||
return config
|
||||
|
||||
def _save_to_database(self, config: dict) -> bool:
|
||||
"""
|
||||
Save configuration to database instead of .env.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
print("\n" + "-" * 70)
|
||||
print(" 💾 Saving Configuration")
|
||||
print("-" * 70 + "\n")
|
||||
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from backend.core.database import database
|
||||
from backend.core.settings_service import settings_service
|
||||
|
||||
# Initialize database if needed
|
||||
print("Initializing database...")
|
||||
database.init_db()
|
||||
|
||||
# Initialize default settings
|
||||
print("Initializing settings...")
|
||||
settings_service.init_default_settings()
|
||||
|
||||
# Extract scan rules if present
|
||||
scan_rules = config.pop("scan_rules", [])
|
||||
|
||||
# Update settings in database
|
||||
settings_dict = {}
|
||||
for key, value in config.items():
|
||||
# Convert library_paths list to JSON string if needed
|
||||
if key == "library_paths" and isinstance(value, list):
|
||||
import json
|
||||
value = json.dumps(value)
|
||||
# Convert integers to strings (settings are stored as strings)
|
||||
elif isinstance(value, int):
|
||||
value = str(value)
|
||||
# Convert booleans to strings
|
||||
elif isinstance(value, bool):
|
||||
value = str(value).lower()
|
||||
settings_dict[key] = value
|
||||
|
||||
print(f"Saving {len(settings_dict)} settings...")
|
||||
settings_service.update_multiple(settings_dict)
|
||||
|
||||
# Create scan rules if in standalone mode
|
||||
if scan_rules:
|
||||
from backend.core.database import get_session
|
||||
from backend.scanning.models import ScanRule
|
||||
|
||||
print(f"Creating {len(scan_rules)} scan rules...")
|
||||
with get_session() as session:
|
||||
for rule_data in scan_rules:
|
||||
rule = ScanRule(**rule_data)
|
||||
session.add(rule)
|
||||
session.commit()
|
||||
|
||||
print("\n✅ Configuration saved successfully!")
|
||||
print("\n" + "=" * 70)
|
||||
print(" 🚀 Setup Complete!")
|
||||
print("=" * 70)
|
||||
print("\nYou can now start the server with:")
|
||||
print(" python backend/cli.py server\n")
|
||||
print("Or with auto-reload for development:")
|
||||
print(" python backend/cli.py server --reload\n")
|
||||
|
||||
if config.get("transcriptarr_mode") == "standalone":
|
||||
print("Access the Web UI at:")
|
||||
print(" http://localhost:8000\n")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error saving configuration: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def run_setup_wizard() -> bool:
|
||||
"""
|
||||
Run setup wizard if needed.
|
||||
|
||||
Returns:
|
||||
True if setup completed or not needed
|
||||
"""
|
||||
wizard = SetupWizard()
|
||||
|
||||
if not wizard.is_first_run():
|
||||
return True
|
||||
|
||||
print("\n⚠️ First run detected - configuration needed\n")
|
||||
|
||||
return wizard.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_setup_wizard()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -4,16 +4,28 @@ uvicorn[standard]
|
||||
python-multipart
|
||||
requests
|
||||
python-dotenv>=1.0.0
|
||||
psutil>=5.9.0
|
||||
python-dateutil>=2.8.0
|
||||
|
||||
# Database & ORM (SQLite is built-in)
|
||||
sqlalchemy>=2.0.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
|
||||
# Media processing (CPU-only by default)
|
||||
# Media processing
|
||||
numpy
|
||||
ffmpeg-python
|
||||
watchdog
|
||||
apscheduler>=3.10.0
|
||||
av>=10.0.0
|
||||
|
||||
# Whisper transcription (required)
|
||||
openai-whisper
|
||||
faster-whisper
|
||||
stable-ts
|
||||
|
||||
# Translation (required for translate mode)
|
||||
deep-translator>=1.11.0
|
||||
|
||||
# Optional dependencies (install based on configuration):
|
||||
#
|
||||
@@ -23,11 +35,6 @@ watchdog
|
||||
# For MariaDB/MySQL database:
|
||||
# pip install pymysql
|
||||
#
|
||||
# For Whisper transcription:
|
||||
# pip install openai-whisper faster-whisper stable-ts
|
||||
#
|
||||
# For GPU support (NVIDIA):
|
||||
# pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
|
||||
#
|
||||
# For media file handling:
|
||||
# pip install av>=10.0.0
|
||||
# pip install nvidia-ml-py3
|
||||
|
||||
Reference in New Issue
Block a user