#!/usr/bin/env python3 """Headless entry point for the Local Transcription backend. Runs the transcription engine + API server without any GUI (no PySide6). Designed to be launched as a Tauri sidecar or run standalone for development. Usage: python -m backend.main_headless [--port PORT] [--host HOST] The backend prints the actual port to stdout as JSON on startup: {"event": "ready", "port": 8080} This allows the Tauri shell to discover which port the backend bound to. """ import argparse import json import multiprocessing import os import signal import sys from pathlib import Path # Must be called before anything else for PyInstaller compatibility multiprocessing.freeze_support() if __name__ == "__main__": try: multiprocessing.set_start_method('spawn', force=True) except RuntimeError: pass # Add project root to path project_root = Path(__file__).resolve().parent.parent sys.path.insert(0, str(project_root)) os.chdir(project_root) from client.instance_lock import InstanceLock def main(): parser = argparse.ArgumentParser(description="Local Transcription headless backend") parser.add_argument("--host", default="127.0.0.1", help="API server host (default: 127.0.0.1)") parser.add_argument("--port", type=int, default=8080, help="API server port (default: 8080)") args = parser.parse_args() instance_lock = InstanceLock() if not instance_lock.acquire(): print(json.dumps({"event": "error", "message": "Another instance is already running"}), flush=True) sys.exit(1) def handle_shutdown(signum, frame): print(json.dumps({"event": "shutdown"}), flush=True) if controller: controller.shutdown() instance_lock.release() sys.exit(0) signal.signal(signal.SIGTERM, handle_shutdown) signal.signal(signal.SIGINT, handle_shutdown) controller = None try: from backend.app_controller import AppController from backend.api_server import APIServer # Override web server port from CLI arg from client.config import Config config = Config() config.set('web_server.host', args.host) config.set('web_server.port', args.port) # Create controller and initialize controller = AppController(config=config) # Wire a state callback that prints state events for the parent # process to read. Stdout writes can fail with EINVAL on Windows # when the parent stops reading the sidecar pipe; swallow those # so the engine state machine isn't taken down by a logging path. def on_state_changed(state, message): event = {"event": "state", "state": state, "message": message} try: print(json.dumps(event), flush=True) except (OSError, ValueError): pass controller.on_state_changed = on_state_changed # Initialize engine + web server controller.initialize() # Create API server wrapping the controller api_server = APIServer(controller) # OBS display runs on the configured port, API server on port+1 obs_port = controller.actual_web_port or args.port api_port = obs_port + 1 # Print ready event so Tauri can discover the API port print(json.dumps({ "event": "ready", "port": api_port, "obs_port": obs_port, }), flush=True) # Run the API server (blocks) import uvicorn import logging logging.getLogger("uvicorn").setLevel(logging.ERROR) logging.getLogger("uvicorn.access").setLevel(logging.ERROR) uvicorn.run( api_server.app, host=args.host, port=api_port, log_level="error", access_log=False, ) except KeyboardInterrupt: print(json.dumps({"event": "shutdown", "reason": "keyboard_interrupt"}), flush=True) except Exception as e: print(json.dumps({"event": "error", "message": str(e)}), flush=True) import traceback traceback.print_exc() sys.exit(1) finally: if controller: controller.shutdown() instance_lock.release() if __name__ == "__main__": main()