M3a: meetrec-server storage service + automatic upload from the app
- server/meetrec-server: FastAPI storage API (upload bundle with wav/ txt/srt/json + metadata incl. agenda, list, fetch, download, delete); file-based index.json, no database; Docker Compose on port 8090, Tailscale-only bind like whisper-server; Ollama env prepared for M3c (gemma4:12b, German) - phone: StorageClient (stdlib multipart upload); RecorderService uploads the bundle in the background after the final pass and publishes UploadState (Uploading/Done/Error) to the UI - app: Library URL setting (persisted, default http://100.103.83.12:8090, empty disables upload); status line reports upload progress - storage API validated locally end-to-end: upload, list, metadata, download, path-traversal rejected, delete
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"""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/<id>/
|
||||
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)
|
||||
Reference in New Issue
Block a user