Scaffold Tauri app with local-first recording UI.

Add React frontend and Rust backend stubs for MP3 import, metadata, SQLite storage, and transcription hooks.
This commit is contained in:
ben
2026-08-12 12:18:15 +02:00
parent db24d11197
commit 9ae4ca1b2b
21 changed files with 2974 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "mp3transriber"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
uuid = { version = "1", features = ["v4", "serde"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability set for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"path:default",
"event:default",
"window:default",
"app:default",
"resources:default",
"shell:allow-execute",
"shell:allow-open"
]
}
+164
View File
@@ -0,0 +1,164 @@
use std::path::Path;
use rusqlite::{params, Connection};
use crate::models::{Recording, RecordingMetadata, TranscriptRecord};
pub struct Database {
connection: Connection,
}
impl Database {
pub fn open(path: &Path) -> Result<Self, String> {
let connection = Connection::open(path).map_err(|err| err.to_string())?;
let database = Self { connection };
database.migrate()?;
Ok(database)
}
fn migrate(&self) -> Result<(), String> {
self.connection
.execute_batch(
"
CREATE TABLE IF NOT EXISTS recordings (
id TEXT PRIMARY KEY,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL UNIQUE,
imported_at TEXT NOT NULL,
title TEXT,
artist TEXT,
album TEXT,
date TEXT,
duration_seconds INTEGER,
bitrate_kbps INTEGER,
transcript_text TEXT,
transcript_source TEXT,
transcript_status TEXT
);
",
)
.map_err(|err| err.to_string())
}
pub fn list_recordings(&self) -> Result<Vec<Recording>, String> {
let mut statement = self
.connection
.prepare(
"
SELECT
id,
file_name,
file_path,
imported_at,
title,
artist,
album,
date,
duration_seconds,
bitrate_kbps,
transcript_text,
transcript_source,
transcript_status
FROM recordings
ORDER BY imported_at DESC
",
)
.map_err(|err| err.to_string())?;
let rows = statement
.query_map([], |row| {
let transcript_text: Option<String> = row.get(10)?;
let transcript_source: Option<String> = row.get(11)?;
let transcript_status: Option<String> = row.get(12)?;
Ok(Recording {
id: row.get(0)?,
file_name: row.get(1)?,
file_path: row.get(2)?,
imported_at: row.get(3)?,
metadata: RecordingMetadata {
title: row.get(4)?,
artist: row.get(5)?,
album: row.get(6)?,
date: row.get(7)?,
duration_seconds: row.get(8)?,
bitrate_kbps: row.get(9)?,
},
transcript: transcript_text.map(|text| TranscriptRecord {
text,
source: transcript_source.unwrap_or_else(|| "local".to_string()),
status: transcript_status.unwrap_or_else(|| "ready".to_string()),
}),
})
})
.map_err(|err| err.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|err| err.to_string())
}
pub fn create_recording(
&self,
id: &str,
file_name: &str,
file_path: &str,
imported_at: &str,
metadata: &RecordingMetadata,
) -> Result<(), String> {
self.connection
.execute(
"
INSERT INTO recordings (
id,
file_name,
file_path,
imported_at,
title,
artist,
album,
date,
duration_seconds,
bitrate_kbps,
transcript_status
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(file_path) DO UPDATE SET
title = excluded.title,
artist = excluded.artist,
album = excluded.album,
date = excluded.date,
duration_seconds = excluded.duration_seconds,
bitrate_kbps = excluded.bitrate_kbps
",
params![
id,
file_name,
file_path,
imported_at,
metadata.title,
metadata.artist,
metadata.album,
metadata.date,
metadata.duration_seconds,
metadata.bitrate_kbps,
"pending"
],
)
.map_err(|err| err.to_string())?;
Ok(())
}
pub fn save_transcript(&self, recording_id: &str, text: &str, source: &str) -> Result<(), String> {
self.connection
.execute(
"
UPDATE recordings
SET transcript_text = ?1, transcript_source = ?2, transcript_status = ?3
WHERE id = ?4
",
params![text, source, "ready", recording_id],
)
.map_err(|err| err.to_string())?;
Ok(())
}
}
+107
View File
@@ -0,0 +1,107 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod db;
mod metadata;
mod models;
use std::path::PathBuf;
use std::sync::Mutex;
use db::Database;
use models::Recording;
use tauri::Manager;
use uuid::Uuid;
struct AppState {
db: Mutex<Database>,
}
#[tauri::command]
fn list_recordings(state: tauri::State<'_, AppState>) -> Result<Vec<Recording>, String> {
state
.db
.lock()
.map_err(|_| "Failed to lock database state".to_string())?
.list_recordings()
}
#[tauri::command]
fn import_mp3(file_path: String, state: tauri::State<'_, AppState>) -> Result<Recording, String> {
let path = PathBuf::from(&file_path);
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| "Invalid file path".to_string())?
.to_string();
let metadata = metadata::read_metadata(&file_path);
let id = Uuid::new_v4().to_string();
let imported_at = format!("{:?}", std::time::SystemTime::now());
{
let database = state
.db
.lock()
.map_err(|_| "Failed to lock database state".to_string())?;
database.create_recording(&id, &file_name, &file_path, &imported_at, &metadata)?;
}
Ok(Recording {
id,
file_name,
file_path,
imported_at,
metadata,
transcript: None,
})
}
#[tauri::command]
fn transcribe_recording(
recording_id: String,
state: tauri::State<'_, AppState>,
) -> Result<Recording, String> {
let transcript_text = "Local transcription pipeline not wired yet. This is the first scaffolded backend placeholder.".to_string();
{
let database = state
.db
.lock()
.map_err(|_| "Failed to lock database state".to_string())?;
database.save_transcript(&recording_id, &transcript_text, "local")?;
let recordings = database.list_recordings()?;
if let Some(recording) = recordings.into_iter().find(|item| item.id == recording_id) {
return Ok(recording);
}
}
Err("Recording not found".to_string())
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.setup(|app| {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|err| -> Box<dyn std::error::Error> { Box::new(err) })?;
std::fs::create_dir_all(&app_data_dir)?;
let db_path = app_data_dir.join("mp3transriber.sqlite3");
let database = Database::open(&db_path).map_err(std::io::Error::other)?;
app.manage(AppState {
db: Mutex::new(database),
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
list_recordings,
import_mp3,
transcribe_recording
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+89
View File
@@ -0,0 +1,89 @@
use std::path::Path;
use std::process::Command;
use crate::models::RecordingMetadata;
pub fn read_metadata(file_path: &str) -> RecordingMetadata {
let fallback_title = Path::new(file_path)
.file_stem()
.and_then(|stem| stem.to_str())
.map(|value| value.to_string());
let output = Command::new("ffprobe")
.args([
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
"-show_streams",
file_path,
])
.output();
let Ok(output) = output else {
return RecordingMetadata {
title: fallback_title,
artist: None,
album: None,
date: None,
duration_seconds: None,
bitrate_kbps: None,
};
};
let json: serde_json::Value = match serde_json::from_slice(&output.stdout) {
Ok(value) => value,
Err(_) => {
return RecordingMetadata {
title: fallback_title,
artist: None,
album: None,
date: None,
duration_seconds: None,
bitrate_kbps: None,
}
}
};
let tags = json
.get("format")
.and_then(|format| format.get("tags"))
.and_then(|tags| tags.as_object());
let duration_seconds = json
.get("format")
.and_then(|format| format.get("duration"))
.and_then(|value| value.as_str())
.and_then(|value| value.parse::<f64>().ok())
.map(|value| value.round() as u64);
let bitrate_kbps = json
.get("format")
.and_then(|format| format.get("bit_rate"))
.and_then(|value| value.as_str())
.and_then(|value| value.parse::<u64>().ok())
.map(|value| (value / 1000) as u32);
RecordingMetadata {
title: tags
.and_then(|items| items.get("title"))
.and_then(|value| value.as_str())
.map(|value| value.to_string())
.or(fallback_title),
artist: tags
.and_then(|items| items.get("artist"))
.and_then(|value| value.as_str())
.map(|value| value.to_string()),
album: tags
.and_then(|items| items.get("album"))
.and_then(|value| value.as_str())
.map(|value| value.to_string()),
date: tags
.and_then(|items| items.get("date"))
.and_then(|value| value.as_str())
.map(|value| value.to_string()),
duration_seconds,
bitrate_kbps,
}
}
+31
View File
@@ -0,0 +1,31 @@
use serde::Serialize;
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RecordingMetadata {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub date: Option<String>,
pub duration_seconds: Option<u64>,
pub bitrate_kbps: Option<u32>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TranscriptRecord {
pub text: String,
pub source: String,
pub status: String,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Recording {
pub id: String,
pub file_name: String,
pub file_path: String,
pub imported_at: String,
pub metadata: RecordingMetadata,
pub transcript: Option<TranscriptRecord>,
}
+29
View File
@@ -0,0 +1,29 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "mp3Transriber",
"version": "0.1.0",
"identifier": "com.ben.mp3transriber",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist",
"devUrl": "http://localhost:5173"
},
"app": {
"windows": [
{
"title": "mp3Transriber",
"width": 1320,
"height": 860,
"resizable": true
}
],
"security": {
"csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'"
}
},
"bundle": {
"active": true,
"targets": "all"
}
}