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:
2026-09-08 09:57:40 +02:00
parent 50d00286d7
commit 6b697f8d94
10 changed files with 427 additions and 5 deletions
+164
View File
@@ -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)