diff --git a/.gitignore b/.gitignore index 4b13ed4..98acb45 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *.pyc *.pyo .venv/ +.venv-win/ venv/ *.egg-info/ @@ -23,6 +24,9 @@ Thumbs.db # Logs *.log +# Meeting watcher output (runtime recordings — never commit audio) +meetings/ + # PyInstaller / Build artifacts build/ dist/ diff --git a/backend/api/routes/settings.py b/backend/api/routes/settings.py index fa08fd8..456fc02 100644 --- a/backend/api/routes/settings.py +++ b/backend/api/routes/settings.py @@ -1,11 +1,63 @@ """Settings endpoints.""" -from fastapi import APIRouter, Form +import asyncio +import platform +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path -from settings import _load_settings, _save_settings +from fastapi import APIRouter, Form, HTTPException + +from settings import ( + _get_meeting_capture_enabled, + _load_settings, + _save_settings, + _set_meeting_capture_enabled, +) router = APIRouter() +# A recording heartbeat older than this (seconds) is treated as idle, so a +# crashed/killed watcher never leaves the UI stuck showing "recording". +WATCHER_STATUS_TTL = 8.0 +# No heartbeat at all within this window means the watcher isn't running, so the +# UI shows its one-time setup prompt. Must exceed the watcher's heartbeat period. +WATCHER_ALIVE_TTL = 15.0 + +# Mirrors backend/main.py's BASE_DIR/SCRIPTS_DIR resolution (PyInstaller bundle +# vs running from source) so this route finds the same scripts/ the app served. +if hasattr(sys, "_MEIPASS"): + _BASE_DIR = Path(sys._MEIPASS) +else: + _BASE_DIR = Path(__file__).resolve().parents[2] +_SCRIPTS_DIR = _BASE_DIR / "scripts" if (_BASE_DIR / "scripts").exists() else _BASE_DIR.parent / "scripts" +_WATCHER_SRC_DIR = _SCRIPTS_DIR / "meeting_watcher" + + +def _bundled_watcher_version() -> str: + """Version of the watcher.py shipped with the *running* app, parsed without + importing it (it pulls in Windows-only audio deps we don't want to load + just to read a constant).""" + try: + text = (_WATCHER_SRC_DIR / "watcher.py").read_text(encoding="utf-8") + m = re.search(r'WATCHER_VERSION\s*=\s*["\']([^"\']+)["\']', text) + return m.group(1) if m else "" + except Exception: + return "" + + +def _to_bool(value: str) -> bool: + return (value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _require_session_token(token: str) -> None: + import state + if not getattr(state, "exit_token", "") or token != state.exit_token: + raise HTTPException(403, "Invalid session token") + @router.get("/api/settings") def get_settings() -> dict: @@ -14,6 +66,7 @@ def get_settings() -> dict: return { "hf_token": settings.get("hf_token", ""), "exit_token": getattr(state, "exit_token", ""), + "meeting_capture_enabled": _get_meeting_capture_enabled(), } @@ -23,3 +76,112 @@ async def save_settings(hf_token: str = Form("")) -> dict: settings["hf_token"] = hf_token _save_settings(settings) return {"ok": True} + + +@router.post("/api/settings/meeting-capture") +async def set_meeting_capture(enabled: str = Form("false"), token: str = Form("")) -> dict: + """Toggle the external meeting auto-capture watcher on/off. + + The watcher (scripts/meeting_watcher/watcher.py) polls this flag and only + records meetings while it is enabled. + """ + _require_session_token(token) + value = _to_bool(enabled) + _set_meeting_capture_enabled(value) + return {"ok": True, "enabled": value} + + +@router.post("/api/watcher/status") +async def set_watcher_status( + recording: str = Form("false"), + app: str = Form(""), + version: str = Form(""), + token: str = Form(""), +) -> dict: + """Heartbeat from the meeting watcher (scripts/meeting_watcher/watcher.py). + + The watcher posts ``recording=true`` when a capture starts and again + periodically while it runs, then ``recording=false`` on stop. Stored only in + memory — see WATCHER_STATUS_TTL for the staleness rule. + """ + _require_session_token(token) + import state + is_recording = _to_bool(recording) + prev = getattr(state, "watcher_status", None) or {} + was_recording = bool(prev.get("recording")) and (time.time() - prev.get("ts", 0)) < WATCHER_STATUS_TTL + started_at = prev.get("started_at", 0.0) if (is_recording and was_recording) else (time.time() if is_recording else 0.0) + state.watcher_status = { + "recording": is_recording, + "app": (app or "").strip(), + "version": (version or "").strip(), + "ts": time.time(), + "started_at": started_at, + } + return {"ok": True} + + +@router.get("/api/watcher/status") +def get_watcher_status() -> dict: + """Current watcher state for the web UI: whether it's installed/running + (``alive``) and whether it's recording right now (``recording``).""" + import state + st = getattr(state, "watcher_status", None) or {} + ts = st.get("ts", 0) + age = time.time() - ts + alive = ts > 0 and age < WATCHER_ALIVE_TTL + fresh = bool(st.get("recording")) and age < WATCHER_STATUS_TTL + installed_version = st.get("version", "") if alive else "" + current_version = _bundled_watcher_version() + return { + "alive": alive, + "recording": fresh, + "app": st.get("app", "") if fresh else "", + "since": st.get("started_at", 0) if fresh else 0, + "installed_version": installed_version, + "current_version": current_version, + "update_available": bool(installed_version and current_version and installed_version != current_version), + } + + +def _install_watcher_sync() -> dict: + """Copy the bundled watcher into %LOCALAPPDATA%\\AmicoScript\\watcher and + (re)register it, mirroring what setup.bat does by hand. Windows-only — the + app may itself run elsewhere (e.g. in Docker) while the browser/host it + needs to install onto is this machine, so callers must treat a platform + mismatch as "use the manual setup.bat download instead", not an error.""" + if platform.system() != "Windows": + return {"ok": False, "error": "not_windows"} + if not _WATCHER_SRC_DIR.exists(): + return {"ok": False, "error": "bundled watcher files not found"} + + import os + dest = Path(os.environ.get("LOCALAPPDATA", "")) / "AmicoScript" / "watcher" + try: + shutil.copytree( + _WATCHER_SRC_DIR, dest, dirs_exist_ok=True, + ignore=shutil.ignore_patterns("meetings", "__pycache__", "*.pyc"), + ) + except Exception as exc: + return {"ok": False, "error": f"copy failed: {exc}"} + + installer = dest / "install-windows.ps1" + try: + proc = subprocess.run( + ["powershell", "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", str(installer)], + cwd=str(dest), capture_output=True, text=True, timeout=60, + ) + except Exception as exc: + return {"ok": False, "error": f"install failed: {exc}"} + if proc.returncode != 0: + return {"ok": False, "error": (proc.stderr or proc.stdout or "install script failed").strip()[-500:]} + return {"ok": True} + + +@router.post("/api/watcher/install") +async def install_watcher(token: str = Form("")) -> dict: + """Install/update the external watcher on this host, triggered from the + UI instead of the user manually running setup.bat. Only works when the + backend itself runs on the target Windows host (not e.g. in Docker) — + see _install_watcher_sync.""" + _require_session_token(token) + return await asyncio.to_thread(_install_watcher_sync) diff --git a/backend/main.py b/backend/main.py index e5ed6be..6a64895 100644 --- a/backend/main.py +++ b/backend/main.py @@ -115,6 +115,111 @@ async def _startup() -> None: asyncio.create_task(_release_poller_loop()) except Exception: pass + _maybe_start_embedded_watcher() + + +# Handle to the embedded meeting watcher (thread + module), set by +# _maybe_start_embedded_watcher so the shutdown hook can signal a clean stop +# and let an in-progress capture finalize instead of being lost with the +# daemon thread. None when the watcher isn't running (Docker / non-Windows). +_watcher_thread = None +_watcher_module = None + + +def _watcher_output_dir() -> Path: + """User-writable folder for meeting captures (never Program Files).""" + try: + from config import STORAGE_ROOT + return Path(STORAGE_ROOT) / "meetings" + except Exception: + return Path.home() / "AmicoScript" / "meetings" + + +def _maybe_start_external_watcher_task() -> None: + """Start the installed per-user watcher task, if setup.bat registered it.""" + import platform + import subprocess + + if platform.system() != "Windows": + return + task_name = os.environ.get("AMICOSCRIPT_WATCHER_TASK", "AmicoScript Meeting Watcher") + try: + proc = subprocess.run( + ["schtasks", "/Run", "/TN", task_name], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except Exception as exc: + print(f"External meeting watcher task could not be started ({exc}).") + return + if proc.returncode == 0: + print(f"External meeting watcher task started: {task_name}") + else: + msg = (proc.stderr or proc.stdout or "").strip() + print(f"External meeting watcher task not available: {task_name}. {msg}") + + +def _maybe_start_embedded_watcher() -> None: + """Run the meeting watcher in-process on the native Windows host. + + The watcher needs WASAPI/mic access, which only exists when AmicoScript runs + directly on the host (the PyInstaller build), not inside the Linux Docker + image. So we start it here only on Windows; Docker/other platforms fall back + to the external watcher (scripts/meeting_watcher). Override with + AMICOSCRIPT_EMBEDDED_WATCHER=on|off|auto (default auto). + """ + import platform + + mode = os.environ.get("AMICOSCRIPT_EMBEDDED_WATCHER", "auto").lower() + if mode in {"0", "off", "false", "no"}: + _maybe_start_external_watcher_task() + return + if mode == "auto" and platform.system() != "Windows": + return + + watcher_dir = SCRIPTS_DIR / "meeting_watcher" + if watcher_dir.exists() and str(watcher_dir) not in sys.path: + sys.path.insert(0, str(watcher_dir)) + os.environ.setdefault("AMICOSCRIPT_WATCHER_OUT", str(_watcher_output_dir())) + os.environ.setdefault("AMICOSCRIPT_URL", "http://127.0.0.1:8002") + + def _run() -> None: + global _watcher_module + try: + import watcher # noqa: imports pyaudiowpatch/pycaw — Windows-only + except Exception as exc: + # Audio deps not bundled, or not on a host that supports them. + print(f"Embedded meeting watcher unavailable ({exc}); " + "use the external watcher (scripts/meeting_watcher) instead.") + if mode == "auto": + _maybe_start_external_watcher_task() + return + _watcher_module = watcher + print("Embedded meeting watcher started (enable via the UI toggle).") + watcher.run_embedded(base_url="http://127.0.0.1:8002") + + import threading + global _watcher_thread + _watcher_thread = threading.Thread(target=_run, daemon=True, name="meeting-watcher") + _watcher_thread.start() + + +@app.on_event("shutdown") +async def _shutdown() -> None: + """Signal the embedded meeting watcher to stop so an in-progress capture + is finalized (WAV saved + transcription queued) before the process exits.""" + mod = _watcher_module + if mod is None or not hasattr(mod, "stop_embedded"): + return + try: + mod.stop_embedded() + except Exception: + return + # Give the watcher a moment to finalize a capture; don't block shutdown long. + if _watcher_thread is not None: + _watcher_thread.join(timeout=10) def _recover_interrupted_jobs() -> None: diff --git a/backend/settings.py b/backend/settings.py index 7282653..1cc2656 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -53,6 +53,19 @@ def _get_saved_hf_token() -> str: return settings.get("hf_token", "") or os.environ.get("HF_TOKEN", "") +def _get_meeting_capture_enabled() -> bool: + """Return whether the external meeting auto-capture watcher is enabled.""" + settings = _load_settings() + return bool(settings.get("meeting_capture_enabled", False)) + + +def _set_meeting_capture_enabled(enabled: bool) -> None: + """Persist the meeting auto-capture enabled flag.""" + settings = _load_settings() + settings["meeting_capture_enabled"] = bool(enabled) + _save_settings(settings) + + def _get_llm_settings() -> dict: """Return LLM config: base_url, model_name, api_key.""" settings = _load_settings() diff --git a/backend/state.py b/backend/state.py index 7ed6600..07cf7a8 100644 --- a/backend/state.py +++ b/backend/state.py @@ -50,3 +50,12 @@ def _init_queue() -> None: # --------------------------------------------------------------------------- exit_token: str = "" + +# --------------------------------------------------------------------------- +# Meeting-watcher status — set by the external watcher (scripts/meeting_watcher) +# via POST /api/watcher/status and read by the web UI to show a "recording" +# chip. Transient (in-memory only); a stale heartbeat is treated as idle by the +# route's TTL so a crashed watcher never leaves the UI stuck on "recording". +# --------------------------------------------------------------------------- + +watcher_status: dict = {"recording": False, "app": "", "ts": 0.0, "started_at": 0.0, "version": ""} diff --git a/frontend/index.html b/frontend/index.html index 9b25204..138ce4d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -854,6 +854,35 @@
Checking helper…
+ Download one-click setup ++ Recording meetings may need all-party consent — check your policy. +
+setup.bat — double-click it (in Downloads, or run
+ scripts\meeting_watcher\setup.bat from the project). It installs the helper and
+ starts it; this bar disappears automatically once it's running.
+