summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-12-23 02:14:58 +0000
committersoryu <soryu@soryu.co>2025-12-23 14:47:18 +0000
commita32dc56d2e5447ef8988cb98b8686476cc94e70c (patch)
tree61307503c4af82103cea2360fe95d3ea324968d6 /makima/src
parent73649d135efccda7e446775db773e21b458de202 (diff)
downloadsoryu-a32dc56d2e5447ef8988cb98b8686476cc94e70c.tar.gz
soryu-a32dc56d2e5447ef8988cb98b8686476cc94e70c.zip
Add Postgres for persistence and File cabinet
Migrations are local only currently, and must be run manually by setting POSTGRES_CONNECTION_URI
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/bin/server.rs26
-rw-r--r--makima/src/db/mod.rs15
-rw-r--r--makima/src/db/models.rs101
-rw-r--r--makima/src/db/repository.rs128
-rw-r--r--makima/src/lib.rs1
-rw-r--r--makima/src/server/handlers/files.rs230
-rw-r--r--makima/src/server/handlers/listen.rs82
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/mod.rs9
-rw-r--r--makima/src/server/openapi.rs22
-rw-r--r--makima/src/server/state.rs14
11 files changed, 617 insertions, 12 deletions
diff --git a/makima/src/bin/server.rs b/makima/src/bin/server.rs
index 3ea3a67..bbc56fd 100644
--- a/makima/src/bin/server.rs
+++ b/makima/src/bin/server.rs
@@ -1,6 +1,6 @@
//! Makima Audio API Server binary.
//!
-//! This server provides WebSocket-based speech-to-text streaming.
+//! This server provides WebSocket-based speech-to-text streaming with optional persistence.
use std::sync::Arc;
@@ -43,13 +43,29 @@ async fn main() -> anyhow::Result<()> {
);
// Load ML models
- let state = Arc::new(
- AppState::new(&parakeet_dir, &parakeet_eou_dir, &sortformer_path)
- .map_err(|e| anyhow::anyhow!("Failed to load models: {}", e))?,
- );
+ let mut app_state = AppState::new(&parakeet_dir, &parakeet_eou_dir, &sortformer_path)
+ .map_err(|e| anyhow::anyhow!("Failed to load models: {}", e))?;
tracing::info!("Models loaded successfully");
+ // Initialize database (optional - server works without it)
+ if let Ok(database_url) = std::env::var("POSTGRES_CONNECTION_URI") {
+ tracing::info!("Connecting to database...");
+ match makima::db::create_pool(&database_url).await {
+ Ok(pool) => {
+ tracing::info!("Database connected successfully");
+ app_state = app_state.with_db_pool(pool);
+ }
+ Err(e) => {
+ tracing::warn!("Failed to connect to database: {}. Running without persistence.", e);
+ }
+ }
+ } else {
+ tracing::info!("POSTGRES_CONNECTION_URI not set. Running without persistence.");
+ }
+
+ let state = Arc::new(app_state);
+
// Run the server
let addr = format!("0.0.0.0:{}", port);
run_server(state, &addr).await
diff --git a/makima/src/db/mod.rs b/makima/src/db/mod.rs
new file mode 100644
index 0000000..dbfeeab
--- /dev/null
+++ b/makima/src/db/mod.rs
@@ -0,0 +1,15 @@
+//! Database module for PostgreSQL connectivity and models.
+
+pub mod models;
+pub mod repository;
+
+use sqlx::postgres::PgPoolOptions;
+use sqlx::PgPool;
+
+/// Create a database connection pool.
+pub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
+ PgPoolOptions::new()
+ .max_connections(5)
+ .connect(database_url)
+ .await
+}
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
new file mode 100644
index 0000000..45b0e53
--- /dev/null
+++ b/makima/src/db/models.rs
@@ -0,0 +1,101 @@
+//! Database models for the files table.
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use sqlx::FromRow;
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+/// TranscriptEntry stored in JSONB - matches frontend TranscriptEntry
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TranscriptEntry {
+ pub id: String,
+ pub speaker: String,
+ pub start: f32,
+ pub end: f32,
+ pub text: String,
+ pub is_final: bool,
+}
+
+/// File record from the database.
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct File {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub name: String,
+ pub description: Option<String>,
+ #[sqlx(json)]
+ pub transcript: Vec<TranscriptEntry>,
+ pub location: Option<String>,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Request payload for creating a new file.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateFileRequest {
+ /// Name of the file (auto-generated if not provided)
+ pub name: Option<String>,
+ /// Optional description
+ pub description: Option<String>,
+ /// Transcript entries
+ pub transcript: Vec<TranscriptEntry>,
+ /// Storage location (e.g., s3://bucket/path) - not used yet
+ pub location: Option<String>,
+}
+
+/// Request payload for updating an existing file.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateFileRequest {
+ /// New name (optional)
+ pub name: Option<String>,
+ /// New description (optional)
+ pub description: Option<String>,
+ /// New transcript (optional)
+ pub transcript: Option<Vec<TranscriptEntry>>,
+}
+
+/// Response for file list endpoint.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct FileListResponse {
+ pub files: Vec<FileSummary>,
+ pub total: i64,
+}
+
+/// Summary of a file for list views (excludes full transcript).
+#[derive(Debug, Clone, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct FileSummary {
+ pub id: Uuid,
+ pub name: String,
+ pub description: Option<String>,
+ pub transcript_count: usize,
+ /// Duration derived from last transcript end time
+ pub duration: Option<f32>,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+impl From<File> for FileSummary {
+ fn from(file: File) -> Self {
+ let duration = file
+ .transcript
+ .iter()
+ .map(|t| t.end)
+ .fold(0.0_f32, f32::max);
+ Self {
+ id: file.id,
+ name: file.name,
+ description: file.description,
+ transcript_count: file.transcript.len(),
+ duration: if duration > 0.0 { Some(duration) } else { None },
+ created_at: file.created_at,
+ updated_at: file.updated_at,
+ }
+ }
+}
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
new file mode 100644
index 0000000..90cb1b9
--- /dev/null
+++ b/makima/src/db/repository.rs
@@ -0,0 +1,128 @@
+//! Repository pattern for file database operations.
+
+use chrono::Utc;
+use sqlx::PgPool;
+use uuid::Uuid;
+
+use super::models::{CreateFileRequest, File, UpdateFileRequest};
+
+/// Default owner ID for anonymous users.
+pub const ANONYMOUS_OWNER_ID: Uuid = Uuid::from_u128(0x00000000_0000_0000_0000_000000000002);
+
+/// Generate a default name based on current timestamp.
+fn generate_default_name() -> String {
+ let now = Utc::now();
+ now.format("Recording - %b %d %Y %H:%M:%S").to_string()
+}
+
+/// Create a new file record.
+pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File, sqlx::Error> {
+ let name = req.name.unwrap_or_else(generate_default_name);
+ let transcript_json = serde_json::to_value(&req.transcript).unwrap_or_default();
+
+ sqlx::query_as::<_, File>(
+ r#"
+ INSERT INTO files (owner_id, name, description, transcript, location)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING id, owner_id, name, description, transcript, location, created_at, updated_at
+ "#,
+ )
+ .bind(ANONYMOUS_OWNER_ID)
+ .bind(&name)
+ .bind(&req.description)
+ .bind(&transcript_json)
+ .bind(&req.location)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get a file by ID.
+pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Error> {
+ sqlx::query_as::<_, File>(
+ r#"
+ SELECT id, owner_id, name, description, transcript, location, created_at, updated_at
+ FROM files
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(ANONYMOUS_OWNER_ID)
+ .fetch_optional(pool)
+ .await
+}
+
+/// List all files for the owner, ordered by created_at DESC.
+pub async fn list_files(pool: &PgPool) -> Result<Vec<File>, sqlx::Error> {
+ sqlx::query_as::<_, File>(
+ r#"
+ SELECT id, owner_id, name, description, transcript, location, created_at, updated_at
+ FROM files
+ WHERE owner_id = $1
+ ORDER BY created_at DESC
+ "#,
+ )
+ .bind(ANONYMOUS_OWNER_ID)
+ .fetch_all(pool)
+ .await
+}
+
+/// Update a file by ID.
+pub async fn update_file(
+ pool: &PgPool,
+ id: Uuid,
+ req: UpdateFileRequest,
+) -> Result<Option<File>, sqlx::Error> {
+ // Get the existing file first
+ let existing = get_file(pool, id).await?;
+ let Some(existing) = existing else {
+ return Ok(None);
+ };
+
+ // Apply updates
+ let name = req.name.unwrap_or(existing.name);
+ let description = req.description.or(existing.description);
+ let transcript = req.transcript.unwrap_or(existing.transcript);
+ let transcript_json = serde_json::to_value(&transcript).unwrap_or_default();
+
+ sqlx::query_as::<_, File>(
+ r#"
+ UPDATE files
+ SET name = $3, description = $4, transcript = $5
+ WHERE id = $1 AND owner_id = $2
+ RETURNING id, owner_id, name, description, transcript, location, created_at, updated_at
+ "#,
+ )
+ .bind(id)
+ .bind(ANONYMOUS_OWNER_ID)
+ .bind(&name)
+ .bind(&description)
+ .bind(&transcript_json)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Delete a file by ID.
+pub async fn delete_file(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM files
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(ANONYMOUS_OWNER_ID)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Count total files for owner.
+pub async fn count_files(pool: &PgPool) -> Result<i64, sqlx::Error> {
+ let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM files WHERE owner_id = $1")
+ .bind(ANONYMOUS_OWNER_ID)
+ .fetch_one(pool)
+ .await?;
+
+ Ok(result.0)
+}
diff --git a/makima/src/lib.rs b/makima/src/lib.rs
index 1e95d95..35d376c 100644
--- a/makima/src/lib.rs
+++ b/makima/src/lib.rs
@@ -1,4 +1,5 @@
pub mod audio;
+pub mod db;
pub mod listen;
pub mod server;
pub mod tts;
diff --git a/makima/src/server/handlers/files.rs b/makima/src/server/handlers/files.rs
new file mode 100644
index 0000000..746d66b
--- /dev/null
+++ b/makima/src/server/handlers/files.rs
@@ -0,0 +1,230 @@
+//! HTTP handlers for file CRUD operations.
+
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use uuid::Uuid;
+
+use crate::db::models::{CreateFileRequest, FileListResponse, FileSummary, UpdateFileRequest};
+use crate::db::repository;
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+/// List all files for the current owner.
+#[utoipa::path(
+ get,
+ path = "/api/v1/files",
+ responses(
+ (status = 200, description = "List of files", body = FileListResponse),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Files"
+)]
+pub async fn list_files(State(state): State<SharedState>) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::list_files(pool).await {
+ Ok(files) => {
+ let summaries: Vec<FileSummary> = files.into_iter().map(FileSummary::from).collect();
+ let total = summaries.len() as i64;
+ Json(FileListResponse {
+ files: summaries,
+ total,
+ })
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list files: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get a single file by ID.
+#[utoipa::path(
+ get,
+ path = "/api/v1/files/{id}",
+ params(
+ ("id" = Uuid, Path, description = "File ID")
+ ),
+ responses(
+ (status = 200, description = "File details", body = crate::db::models::File),
+ (status = 404, description = "File not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Files"
+)]
+pub async fn get_file(
+ State(state): State<SharedState>,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::get_file(pool, id).await {
+ Ok(Some(file)) => Json(file).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get file {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Create a new file.
+#[utoipa::path(
+ post,
+ path = "/api/v1/files",
+ request_body = CreateFileRequest,
+ responses(
+ (status = 201, description = "File created", body = crate::db::models::File),
+ (status = 400, description = "Invalid request", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Files"
+)]
+pub async fn create_file(
+ State(state): State<SharedState>,
+ Json(req): Json<CreateFileRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::create_file(pool, req).await {
+ Ok(file) => (StatusCode::CREATED, Json(file)).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to create file: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update an existing file.
+#[utoipa::path(
+ put,
+ path = "/api/v1/files/{id}",
+ params(
+ ("id" = Uuid, Path, description = "File ID")
+ ),
+ request_body = UpdateFileRequest,
+ responses(
+ (status = 200, description = "File updated", body = crate::db::models::File),
+ (status = 404, description = "File not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Files"
+)]
+pub async fn update_file(
+ State(state): State<SharedState>,
+ Path(id): Path<Uuid>,
+ Json(req): Json<UpdateFileRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::update_file(pool, id, req).await {
+ Ok(Some(file)) => Json(file).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to update file {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Delete a file.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/files/{id}",
+ params(
+ ("id" = Uuid, Path, description = "File ID")
+ ),
+ responses(
+ (status = 204, description = "File deleted"),
+ (status = 404, description = "File not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Files"
+)]
+pub async fn delete_file(
+ State(state): State<SharedState>,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::delete_file(pool, id).await {
+ Ok(true) => StatusCode::NO_CONTENT.into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to delete file {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/handlers/listen.rs b/makima/src/server/handlers/listen.rs
index bf6746c..93062f3 100644
--- a/makima/src/server/handlers/listen.rs
+++ b/makima/src/server/handlers/listen.rs
@@ -9,6 +9,8 @@ use tokio::sync::mpsc;
use uuid::Uuid;
use crate::audio::{resample_and_mixdown, TARGET_CHANNELS, TARGET_SAMPLE_RATE};
+use crate::db::models::{CreateFileRequest, TranscriptEntry, UpdateFileRequest};
+use crate::db::repository;
use crate::listen::{align_speakers, samples_per_chunk, DialogueSegment, TimestampMode};
use crate::server::messages::{
AudioEncoding, ClientMessage, ServerMessage, StartMessage, TranscriptMessage,
@@ -99,6 +101,11 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
let mut audio_offset: f32 = 0.0; // Time offset from trimmed audio
let mut finalized_segments: Vec<DialogueSegment> = Vec::new();
+ // File persistence state
+ let mut file_id: Option<Uuid> = None;
+ let mut transcript_entries: Vec<TranscriptEntry> = Vec::new();
+ let mut transcript_counter: u32 = 0;
+
// Reset Sortformer state for new session
{
let mut sortformer = state.sortformer.lock().await;
@@ -329,12 +336,52 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
// Send segments with adjusted timestamps
for seg in &segments {
+ let adjusted_start = seg.start + audio_offset;
let adjusted_end = seg.end + audio_offset;
if adjusted_end > last_sent_end_time {
+ // Create file on first transcript if database is available
+ if file_id.is_none() {
+ if let Some(ref pool) = state.db_pool {
+ match repository::create_file(pool, CreateFileRequest {
+ name: None, // Auto-generated
+ description: None,
+ transcript: vec![],
+ location: None,
+ }).await {
+ Ok(file) => {
+ file_id = Some(file.id);
+ tracing::info!(
+ session_id = %session_id,
+ file_id = %file.id,
+ "Created file for session"
+ );
+ }
+ Err(e) => {
+ tracing::warn!(
+ session_id = %session_id,
+ error = %e,
+ "Failed to create file for session"
+ );
+ }
+ }
+ }
+ }
+
+ // Track transcript entry
+ transcript_counter += 1;
+ transcript_entries.push(TranscriptEntry {
+ id: format!("{}-{}", session_id, transcript_counter),
+ speaker: seg.speaker.clone(),
+ start: adjusted_start,
+ end: adjusted_end,
+ text: seg.text.clone(),
+ is_final: false,
+ });
+
let _ = response_tx
.send(ServerMessage::Transcript(TranscriptMessage {
speaker: seg.speaker.clone(),
- start: seg.start + audio_offset,
+ start: adjusted_start,
end: adjusted_end,
text: seg.text.clone(),
is_final: false,
@@ -399,6 +446,39 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
}
}
+ // Save final transcript to file if we have one
+ if let Some(fid) = file_id {
+ if let Some(ref pool) = state.db_pool {
+ // Mark all entries as final
+ for entry in &mut transcript_entries {
+ entry.is_final = true;
+ }
+
+ match repository::update_file(pool, fid, UpdateFileRequest {
+ name: None,
+ description: None,
+ transcript: Some(transcript_entries.clone()),
+ }).await {
+ Ok(_) => {
+ tracing::info!(
+ session_id = %session_id,
+ file_id = %fid,
+ transcript_count = transcript_entries.len(),
+ "Saved final transcript to file"
+ );
+ }
+ Err(e) => {
+ tracing::error!(
+ session_id = %session_id,
+ file_id = %fid,
+ error = %e,
+ "Failed to save final transcript to file"
+ );
+ }
+ }
+ }
+ }
+
// Cleanup
drop(response_tx);
let _ = sender_task.await;
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index 94b0384..f249234 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -1,3 +1,4 @@
//! HTTP and WebSocket request handlers.
+pub mod files;
pub mod listen;
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index c509afa..bc3e679 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -17,7 +17,7 @@ use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
-use crate::server::handlers::listen;
+use crate::server::handlers::{files, listen};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -43,6 +43,13 @@ pub fn make_router(state: SharedState) -> Router {
// API v1 routes
let api_v1 = Router::new()
.route("/listen", get(listen::websocket_handler))
+ .route("/files", get(files::list_files).post(files::create_file))
+ .route(
+ "/files/{id}",
+ get(files::get_file)
+ .put(files::update_file)
+ .delete(files::delete_file),
+ )
.with_state(state);
let swagger = SwaggerUi::new("/swagger-ui")
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index 3e8c06c..b946ff3 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -2,19 +2,27 @@
use utoipa::OpenApi;
-use crate::server::handlers::listen;
+use crate::db::models::{
+ CreateFileRequest, File, FileListResponse, FileSummary, TranscriptEntry, UpdateFileRequest,
+};
+use crate::server::handlers::{files, listen};
use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage};
#[derive(OpenApi)]
#[openapi(
info(
- title = "Makima Listen API",
+ title = "Makima API",
version = "1.0.0",
- description = "Streaming audio APIs for speech-to-text.",
+ description = "Streaming audio APIs for speech-to-text with persistence.",
license(name = "MIT"),
),
paths(
listen::websocket_handler,
+ files::list_files,
+ files::get_file,
+ files::create_file,
+ files::update_file,
+ files::delete_file,
),
components(
schemas(
@@ -23,10 +31,18 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
StartMessage,
StopMessage,
TranscriptMessage,
+ // File schemas
+ File,
+ FileSummary,
+ FileListResponse,
+ CreateFileRequest,
+ UpdateFileRequest,
+ TranscriptEntry,
)
),
tags(
(name = "Listen", description = "Speech-to-text streaming endpoints"),
+ (name = "Files", description = "Transcript file management"),
)
)]
pub struct ApiDoc;
diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs
index 31e1518..8cdc26c 100644
--- a/makima/src/server/state.rs
+++ b/makima/src/server/state.rs
@@ -1,11 +1,12 @@
-//! Application state holding shared ML models.
+//! Application state holding shared ML models and database pool.
use std::sync::Arc;
+use sqlx::PgPool;
use tokio::sync::Mutex;
use crate::listen::{DiarizationConfig, ParakeetEOU, ParakeetTDT, Sortformer};
-/// Shared application state containing ML models.
+/// Shared application state containing ML models and database pool.
///
/// Models are wrapped in `Mutex` for thread-safe mutable access during inference.
pub struct AppState {
@@ -15,6 +16,8 @@ pub struct AppState {
pub parakeet_eou: Mutex<ParakeetEOU>,
/// Speaker diarization model (Sortformer)
pub sortformer: Mutex<Sortformer>,
+ /// Optional database connection pool
+ pub db_pool: Option<PgPool>,
}
impl AppState {
@@ -41,8 +44,15 @@ impl AppState {
parakeet: Mutex::new(parakeet),
parakeet_eou: Mutex::new(parakeet_eou),
sortformer: Mutex::new(sortformer),
+ db_pool: None,
})
}
+
+ /// Set the database pool.
+ pub fn with_db_pool(mut self, pool: PgPool) -> Self {
+ self.db_pool = Some(pool);
+ self
+ }
}
/// Type alias for the shared application state.