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:
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user