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:
+10
-1
@@ -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.6–0.8× realtime, so keep local models small.
|
Fairphone 6: ~0.6–0.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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
data/
|
||||||
|
.git
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn>=0.30
|
||||||
|
python-multipart>=0.0.9
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user