"""meetrec-server: storage + orchestration API for meetrec recordings. Single-user service for a private tailnet: stores the recording bundles (wav/txt/srt/json + metadata) uploaded by the meetrec apps and, later in M3c, generates meeting summaries and agenda coverage via Ollama. Storage layout (no database): data/ index.json # list of recording metadata recordings// meeting.wav / .txt / .srt / .json summary.md # (M3c) agenda.json # (M3c) """ import json import re import shutil import threading import time import uuid from datetime import datetime, timezone from pathlib import Path from typing import Optional from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse DATA_DIR = Path("/data") REC_DIR = DATA_DIR / "recordings" INDEX_PATH = DATA_DIR / "index.json" ALLOWED_FILES = ("wav", "txt", "srt", "json") RID_RE = re.compile(r"^[A-Za-z0-9-]+$") app = FastAPI(title="meetrec-server", version="0.1.0") _lock = threading.Lock() def _load_index() -> list: if not INDEX_PATH.exists(): return [] with INDEX_PATH.open() as f: return json.load(f) def _save_index(items: list) -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) tmp = INDEX_PATH.with_suffix(".json.tmp") with tmp.open("w") as f: json.dump(items, f, ensure_ascii=False, indent=2) tmp.replace(INDEX_PATH) def _find(items: list, rid: str) -> Optional[dict]: return next((it for it in items if it["id"] == rid), None) @app.get("/api/health") def health() -> dict: with _lock: return {"ok": True, "recordings": len(_load_index())} @app.get("/api/recordings") def list_recordings() -> list: with _lock: items = _load_index() return list(reversed(items)) # newest first @app.post("/api/recordings", status_code=201) async def upload( wav: UploadFile = File(...), txt: Optional[UploadFile] = File(None), srt: Optional[UploadFile] = File(None), json_file: Optional[UploadFile] = File(None, alias="json"), started_at: str = Form(""), duration_ms: int = Form(0), language: str = Form(""), device: str = Form(""), agenda: str = Form("[]"), ) -> dict: uploads = [] for name, f in (("wav", wav), ("txt", txt), ("srt", srt), ("json", json_file)): if f is not None and f.filename: uploads.append((name, f)) if not any(name == "wav" for name, _ in uploads): raise HTTPException(400, "a 'wav' file part is required") try: agenda_items = json.loads(agenda or "[]") except json.JSONDecodeError: raise HTTPException(400, "agenda must be a JSON list of strings") if not isinstance(agenda_items, list) or not all(isinstance(a, str) for a in agenda_items): raise HTTPException(400, "agenda must be a JSON list of strings") rid = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:4] rec_dir = REC_DIR / rid rec_dir.mkdir(parents=True, exist_ok=True) stored = [] for name, f in uploads: dest = rec_dir / f"meeting.{name}" with dest.open("wb") as out: while chunk := await f.read(1 << 20): out.write(chunk) stored.append(dest.name) item = { "id": rid, "started_at": started_at or datetime.now(timezone.utc).isoformat(timespec="seconds"), "duration_ms": duration_ms, "language": language, "device": device, "agenda": agenda_items, "files": sorted(stored), "summary": None, # set in M3c "agenda_results": None, "uploaded_at": time.time(), } with _lock: items = _load_index() items.append(item) _save_index(items) return {"id": rid, "files": sorted(stored)} @app.get("/api/recordings/{rid}") def get_recording(rid: str) -> dict: with _lock: item = _find(_load_index(), rid) if item is None: raise HTTPException(404, "unknown recording id") return item @app.get("/api/recordings/{rid}/files/{name}") def get_file(rid: str, name: str) -> FileResponse: if not RID_RE.match(rid) or "/" in name or ".." in name: raise HTTPException(400, "invalid id or file name") with _lock: item = _find(_load_index(), rid) if item is None: raise HTTPException(404, "unknown recording id") if name not in item["files"] and name not in ("summary.md", "agenda.json"): raise HTTPException(404, f"no such file: {name}") path = REC_DIR / rid / name if not path.is_file(): raise HTTPException(404, f"no such file: {name}") return FileResponse(path) @app.delete("/api/recordings/{rid}", status_code=204) def delete_recording(rid: str) -> None: if not RID_RE.match(rid): raise HTTPException(400, "invalid id") with _lock: items = _load_index() item = _find(items, rid) if item is None: raise HTTPException(404, "unknown recording id") _save_index([it for it in items if it["id"] != rid]) shutil.rmtree(REC_DIR / rid, ignore_errors=True)