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
+10 -1
View File
@@ -14,13 +14,22 @@ The phone's local whisper.cpp engine remains as the offline fallback.
(tiny/base for live, e.g. small for the final pass). Measured on the (tiny/base for live, e.g. small for the final pass). Measured on the
Fairphone 6: ~0.60.8× realtime, so keep local models small. Fairphone 6: ~0.60.8× realtime, so keep local models small.
**server** — a remote [whisper.cpp server](../server/whisper-server/) **server** — a remote [whisper.cpp server](../whisper-server/)
(Docker Compose, Vulkan GPU) transcribes BOTH the live pass and the final (Docker Compose, Vulkan GPU) transcribes BOTH the live pass and the final
pass; the model lives on the server (e.g. large-v3). The phone only pass; the model lives on the server (e.g. large-v3). The phone only
records and displays. Set the server URL in the app (persisted; e.g. records and displays. Set the server URL in the app (persisted; e.g.
`http://100.103.83.12:8085` over Tailscale — the phone needs Tailscale `http://100.103.83.12:8085` over Tailscale — the phone needs Tailscale
too). Local model selection is greyed out in this mode. too). Local model selection is greyed out in this mode.
## Recording library (meetrec-server)
Finished recordings (WAV + txt/srt/json) upload automatically to the
[meetrec-server](../meetrec-server/) storage service. Set the **Library
URL** in the app (`http://100.103.83.12:8090`, persisted) or leave it
empty to disable upload. Uploads run in the background after the final
pass; the status line reports the result. (A browse UI for previous
recordings arrives with M3b.)
## Requirements ## Requirements
- Android Studio (or: SDK Platform 36, Build Tools 36, NDK 27.1, CMake 3.22.1) - Android Studio (or: SDK Platform 36, Build Tools 36, NDK 27.1, CMake 3.22.1)
@@ -80,6 +80,12 @@ fun MeetRecScreen() {
?: "http://100.103.83.12:8085", ?: "http://100.103.83.12:8085",
) )
} }
var storageUrl by remember {
mutableStateOf(
prefs.getString("storage_url", "http://100.103.83.12:8090")
?: "http://100.103.83.12:8090",
)
}
LaunchedEffect(transcribeOn) { LaunchedEffect(transcribeOn) {
prefs.edit().putString("transcribe_on", transcribeOn).apply() prefs.edit().putString("transcribe_on", transcribeOn).apply()
@@ -87,6 +93,9 @@ fun MeetRecScreen() {
LaunchedEffect(serverUrl) { LaunchedEffect(serverUrl) {
prefs.edit().putString("server_url", serverUrl).apply() prefs.edit().putString("server_url", serverUrl).apply()
} }
LaunchedEffect(storageUrl) {
prefs.edit().putString("storage_url", storageUrl).apply()
}
var model by remember { mutableStateOf("tiny") } var model by remember { mutableStateOf("tiny") }
var modelMenu by remember { mutableStateOf(false) } var modelMenu by remember { mutableStateOf(false) }
@@ -106,6 +115,16 @@ fun MeetRecScreen() {
val recState by RecorderService.state.collectAsState() val recState by RecorderService.state.collectAsState()
val live by RecorderService.liveLines.collectAsState() val live by RecorderService.liveLines.collectAsState()
val upload by RecorderService.uploadState.collectAsState()
LaunchedEffect(upload) {
when (val u = upload) {
is UploadState.Uploading -> status = "Uploading recording to library…"
is UploadState.Done -> status = "Uploaded to library (${u.id})"
is UploadState.Error -> status = "Upload failed: ${u.message}"
UploadState.None -> {}
}
}
fun <T> runBusy(block: suspend () -> T) { fun <T> runBusy(block: suspend () -> T) {
if (busy) return if (busy) return
@@ -197,6 +216,7 @@ fun MeetRecScreen() {
} else { } else {
null null
}, },
storageUrl = storageUrl.trim().ifBlank { null },
) )
if (transcribeOn == "server") { if (transcribeOn == "server") {
status = "Recording — live and final pass on ${serverUrl.trim()}" status = "Recording — live and final pass on ${serverUrl.trim()}"
@@ -282,6 +302,15 @@ fun MeetRecScreen() {
) )
} }
// recording library (leave empty to disable upload)
OutlinedTextField(
value = storageUrl,
onValueChange = { storageUrl = it },
label = { Text("Library URL") },
placeholder = { Text("http://100.103.83.12:8090") },
modifier = Modifier.fillMaxWidth(),
)
// model + language // model + language
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
@@ -37,11 +37,23 @@ import kotlinx.coroutines.withContext
/** One line of the live rolling transcript, with its absolute start time. */ /** One line of the live rolling transcript, with its absolute start time. */
data class LiveLine(val startMs: Long, val text: String) data class LiveLine(val startMs: Long, val text: String)
/** Upload progress for the recording library (meetrec-server). */
sealed interface UploadState {
data object None : UploadState
data class Uploading(val file: File) : UploadState
data class Done(val id: String) : UploadState
data class Error(val message: String) : UploadState
}
/** /**
* Engine/session for the next recording; set by the UI right before * Engine/session for the next recording; set by the UI right before
* starting the service. When [serverUrl] is set, BOTH passes transcribe * starting the service. When [serverUrl] is set, BOTH passes transcribe
* remotely (model lives on the server); otherwise [finalEngine] and the * remotely (model lives on the server); otherwise [finalEngine] and the
* local [liveModel] are used. All engines may be null — record only. * local [liveModel] are used. All engines may be null — record only.
* When [storageUrl] is set, finished recordings upload to the library.
*/ */
data class SessionConfig( data class SessionConfig(
val finalEngine: WhisperEngine?, val finalEngine: WhisperEngine?,
@@ -49,6 +61,7 @@ data class SessionConfig(
val language: String?, val language: String?,
val liveModel: String?, val liveModel: String?,
val liveIntervalSec: Int = 8, val liveIntervalSec: Int = 8,
val storageUrl: String? = null,
) )
/** Recording state published to the UI (collect RecorderService.state). */ /** Recording state published to the UI (collect RecorderService.state). */
@@ -107,6 +120,10 @@ class RecorderService : Service() {
val liveLines: StateFlow<List<LiveLine>> = val liveLines: StateFlow<List<LiveLine>> =
MutableStateFlow<List<LiveLine>>(emptyList()) MutableStateFlow<List<LiveLine>>(emptyList())
private val _liveLines = liveLines as MutableStateFlow<List<LiveLine>> private val _liveLines = liveLines as MutableStateFlow<List<LiveLine>>
/** Recording library upload (meetrec-server). */
val uploadState: StateFlow<UploadState> = MutableStateFlow(UploadState.None)
private val _uploadState = uploadState as MutableStateFlow<UploadState>
} }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -261,6 +278,7 @@ class RecorderService : Service() {
cfg?.serverUrl?.let { RemoteWhisperEngine(it) } ?: cfg?.finalEngine cfg?.serverUrl?.let { RemoteWhisperEngine(it) } ?: cfg?.finalEngine
if (transcriber == null) { if (transcriber == null) {
_state.value = RecordingState.Finished(file, durationMs, sampleRate) _state.value = RecordingState.Finished(file, durationMs, sampleRate)
maybeUpload(file, emptyList(), durationMs, cfg)
cleanup() cleanup()
return return
} }
@@ -282,6 +300,7 @@ class RecorderService : Service() {
"${SystemClock.elapsedRealtime() - t0} ms") "${SystemClock.elapsedRealtime() - t0} ms")
val outputs = TranscriptFiles.writeAll(file, durationMs, segments) val outputs = TranscriptFiles.writeAll(file, durationMs, segments)
_state.value = RecordingState.Finished(file, durationMs, sampleRate, segments, outputs) _state.value = RecordingState.Finished(file, durationMs, sampleRate, segments, outputs)
maybeUpload(file, outputs, durationMs, cfg)
} catch (e: Exception) { } catch (e: Exception) {
_state.value = RecordingState.Finished( _state.value = RecordingState.Finished(
file, durationMs, sampleRate, error = e.message, file, durationMs, sampleRate, error = e.message,
@@ -290,6 +309,41 @@ class RecorderService : Service() {
cleanup() cleanup()
} }
/** Uploads the bundle to the recording library in the background. */
private fun maybeUpload(
file: File,
outputs: List<File>,
durationMs: Long,
cfg: SessionConfig?,
) {
val url = cfg?.storageUrl?.trim()
if (url.isNullOrBlank()) return
scope.launch {
_uploadState.value = UploadState.Uploading(file)
try {
val id = withContext(Dispatchers.IO) {
StorageClient.upload(
baseUrl = url,
wav = file,
txt = outputs.firstOrNull { it.name.endsWith(".txt") },
srt = outputs.firstOrNull { it.name.endsWith(".srt") },
json = outputs.firstOrNull { it.name.endsWith(".json") },
startedAt = file.nameWithoutExtension.removePrefix("meeting-"),
durationMs = durationMs,
language = cfg.language ?: "",
device = "Android",
agenda = emptyList(),
)
}
Log.i(TAG, "upload done: $id")
_uploadState.value = UploadState.Done(id)
} catch (e: Exception) {
Log.w(TAG, "upload failed: ${e.message}", e)
_uploadState.value = UploadState.Error(e.message ?: "upload failed")
}
}
}
private fun cleanup() { private fun cleanup() {
session = null session = null
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
@@ -0,0 +1,113 @@
package com.meetrec.android
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import org.json.JSONObject
/** Client for the meetrec-server storage API (see server/meetrec-server/). */
object StorageClient {
/** Uploads a recording bundle; returns the new recording id. */
fun upload(
baseUrl: String,
wav: File,
txt: File?,
srt: File?,
json: File?,
startedAt: String,
durationMs: Long,
language: String?,
device: String,
agenda: List<String>,
timeoutMs: Int = 300_000,
): String {
val base = baseUrl.trim().trimEnd('/')
val body = multipart(
mapOf(
"started_at" to startedAt,
"duration_ms" to durationMs.toString(),
"language" to (language ?: ""),
"device" to device,
"agenda" to toJsonArray(agenda),
),
listOfNotNull(
wav.takeIf { it.isFile }?.let { "wav" to it },
txt?.takeIf { it.isFile }?.let { "txt" to it },
srt?.takeIf { it.isFile }?.let { "srt" to it },
json?.takeIf { it.isFile }?.let { "json" to it },
),
)
val conn = (URL("$base/api/recordings").openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
connectTimeout = 15_000
readTimeout = timeoutMs
setFixedLengthStreamingMode(body.size)
setRequestProperty(
"Content-Type",
"multipart/form-data; boundary=$BOUNDARY",
)
}
try {
conn.outputStream.use { it.write(body) }
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val resp = stream?.use { it.readBytes() } ?: ByteArray(0)
if (code !in 200..299) {
throw IOException(
"server returned $code: " + String(resp, 0, minOf(resp.size, 200)),
)
}
return JSONObject(String(resp)).getString("id")
} finally {
conn.disconnect()
}
}
private fun multipart(fields: Map<String, String>, files: List<Pair<String, File>>): ByteArray {
val parts = ArrayList<ByteArray>()
for ((name, value) in fields) {
parts.add(
("--$BOUNDARY\r\n" +
"Content-Disposition: form-data; name=\"$name\"\r\n\r\n" +
"$value\r\n").toByteArray(),
)
}
for ((name, file) in files) {
parts.add(
("--$BOUNDARY\r\n" +
"Content-Disposition: form-data; name=\"$name\"; " +
"filename=\"meeting.$name\"\r\n" +
"Content-Type: application/octet-stream\r\n\r\n").toByteArray(),
)
parts.add(file.readBytes())
parts.add("\r\n".toByteArray())
}
parts.add("--$BOUNDARY--\r\n".toByteArray())
return parts.reduce { acc, b -> acc + b }
}
private fun toJsonArray(items: List<String>): String =
items.joinToString(",", "[", "]") { "\"" + escape(it) + "\"" }
private fun escape(s: String): String {
val sb = StringBuilder(s.length + 8)
for (c in s) {
when {
c == '"' -> sb.append("\\\"")
c == '\\' -> sb.append("\\\\")
c == '\n' -> sb.append("\\n")
c == '\r' -> sb.append("\\r")
c == '\t' -> sb.append("\\t")
c < ' ' -> sb.append("\\u%04x".format(c.code))
else -> sb.append(c)
}
}
return sb.toString()
}
private const val BOUNDARY = "meetrec-android-3b6a9c2f"
}
+4
View File
@@ -0,0 +1,4 @@
data/
.git
Dockerfile
docker-compose.yml
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-slim
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
ENV MEETREC_DATA=/data
EXPOSE 8090
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"]
+34
View File
@@ -0,0 +1,34 @@
# meetrec-server: storage + orcheststration for meetrec recordings.
#
# docker compose up -d --build
#
# Stores recording bundles uploaded by the meetrec apps (wav/txt/srt/json +
# metadata) and — from M3c on — generates German meeting summaries and
# agenda coverage via Ollama. Single-user service for a private tailnet;
# bound only to the Tailscale interface, like whisper-server.
services:
meetrec-server:
build: .
image: meetrec-server:latest
container_name: meetrec-server
restart: unless-stopped
environment:
# Ollama (used from M3c on for summaries/agenda coverage)
OLLAMA_URL: http://100.103.83.12:11435
OLLAMA_MODEL: gemma4:12b
SUMMARY_LANG: de
volumes:
- ./data:/data
ports:
- "100.103.83.12:8090:8090" # tailscale-only; see whisper-server notes
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8090/api/health')\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
+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)
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.115
uvicorn>=0.30
python-multipart>=0.0.9
+5 -4
View File
@@ -65,7 +65,8 @@ Notes:
## Client status ## Client status
- Desktop `meetrec`: a `whisper-server` engine is planned - Desktop `meetrec`: `--engine whisper-server --server-url http://100.103.83.12:8085`.
(`--engine whisper-server --server-url http://100.103.83.12:8085`). - Android app: **Transcribe on: server** (phone records, server transcribes;
- Android app: a remote engine option is planned (phone records, server local JNI stays as the offline fallback).
transcribes; local JNI stays as the offline fallback). - Recording library: [meetrec-server](../meetrec-server/) stores recordings
+ transcripts; Android auto-uploads after the final pass.