From a14d13c9d0dfaee46803d82895091e4ea954957b Mon Sep 17 00:00:00 2001 From: Dasemu Date: Fri, 16 Jan 2026 16:58:20 +0100 Subject: [PATCH] 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 --- backend/app.py | 290 ++++++++++++++++++++ backend/cli.py | 222 ++++++++++++++++ backend/setup_wizard.py | 568 ++++++++++++++++++++++++++++++++++++++++ requirements.txt | 21 +- 4 files changed, 1094 insertions(+), 7 deletions(-) create mode 100644 backend/app.py create mode 100755 backend/cli.py create mode 100644 backend/setup_wizard.py diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..4f94248 --- /dev/null +++ b/backend/app.py @@ -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"] + diff --git a/backend/cli.py b/backend/cli.py new file mode 100755 index 0000000..b427b76 --- /dev/null +++ b/backend/cli.py @@ -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() + diff --git a/backend/setup_wizard.py b/backend/setup_wizard.py new file mode 100644 index 0000000..0a715b4 --- /dev/null +++ b/backend/setup_wizard.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 2ef443c..d60d5be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +# pip install nvidia-ml-py3