Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ __pycache__/
*.pyc
*.pyo
.venv/
.venv-win/
venv/
*.egg-info/

Expand All @@ -23,6 +24,9 @@ Thumbs.db
# Logs
*.log

# Meeting watcher output (runtime recordings — never commit audio)
meetings/

# PyInstaller / Build artifacts
build/
dist/
Expand Down
166 changes: 164 additions & 2 deletions backend/api/routes/settings.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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(),
}


Expand All @@ -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)
105 changes: 105 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions backend/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": ""}
Loading
Loading