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
@@ -80,6 +80,12 @@ fun MeetRecScreen() {
?: "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) {
prefs.edit().putString("transcribe_on", transcribeOn).apply()
@@ -87,6 +93,9 @@ fun MeetRecScreen() {
LaunchedEffect(serverUrl) {
prefs.edit().putString("server_url", serverUrl).apply()
}
LaunchedEffect(storageUrl) {
prefs.edit().putString("storage_url", storageUrl).apply()
}
var model by remember { mutableStateOf("tiny") }
var modelMenu by remember { mutableStateOf(false) }
@@ -106,6 +115,16 @@ fun MeetRecScreen() {
val recState by RecorderService.state.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) {
if (busy) return
@@ -197,6 +216,7 @@ fun MeetRecScreen() {
} else {
null
},
storageUrl = storageUrl.trim().ifBlank { null },
)
if (transcribeOn == "server") {
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
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ExposedDropdownMenuBox(
@@ -37,11 +37,23 @@ import kotlinx.coroutines.withContext
/** One line of the live rolling transcript, with its absolute start time. */
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
* starting the service. When [serverUrl] is set, BOTH passes transcribe
* remotely (model lives on the server); otherwise [finalEngine] and the
* local [liveModel] are used. All engines may be null — record only.
* When [storageUrl] is set, finished recordings upload to the library.
*/
data class SessionConfig(
val finalEngine: WhisperEngine?,
@@ -49,6 +61,7 @@ data class SessionConfig(
val language: String?,
val liveModel: String?,
val liveIntervalSec: Int = 8,
val storageUrl: String? = null,
)
/** Recording state published to the UI (collect RecorderService.state). */
@@ -107,6 +120,10 @@ class RecorderService : Service() {
val liveLines: StateFlow<List<LiveLine>> =
MutableStateFlow<List<LiveLine>>(emptyList())
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)
@@ -261,6 +278,7 @@ class RecorderService : Service() {
cfg?.serverUrl?.let { RemoteWhisperEngine(it) } ?: cfg?.finalEngine
if (transcriber == null) {
_state.value = RecordingState.Finished(file, durationMs, sampleRate)
maybeUpload(file, emptyList(), durationMs, cfg)
cleanup()
return
}
@@ -282,6 +300,7 @@ class RecorderService : Service() {
"${SystemClock.elapsedRealtime() - t0} ms")
val outputs = TranscriptFiles.writeAll(file, durationMs, segments)
_state.value = RecordingState.Finished(file, durationMs, sampleRate, segments, outputs)
maybeUpload(file, outputs, durationMs, cfg)
} catch (e: Exception) {
_state.value = RecordingState.Finished(
file, durationMs, sampleRate, error = e.message,
@@ -290,6 +309,41 @@ class RecorderService : Service() {
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() {
session = null
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"
}