The state callback in main_headless.py wrote events to stdout synchronously, so an EINVAL on the Tauri sidecar pipe (Windows) bubbled up through _set_state and tore down engine init and reload_engine. That turned PUT /api/config into a "Failed to fetch" for the user. The print is now pipe-safe and api_server isolates the chained callback so a future misbehaving listener cannot break the engine state machine. Settings also now persists remote.email on login and shows a "Logged in as <email>" indicator with a Log out button when an auth_token is present, instead of leaving the email/password fields blank on reload. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
138 lines
4.2 KiB
Python
138 lines
4.2 KiB
Python
#!/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()
|