summaryrefslogtreecommitdiff
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
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
-rw-r--r--Cargo.lock726
-rw-r--r--makima/Cargo.toml8
-rw-r--r--makima/frontend/src/components/NavStrip.tsx1
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx143
-rw-r--r--makima/frontend/src/components/files/FileList.tsx96
-rw-r--r--makima/frontend/src/components/listen/ControlPanel.tsx11
-rw-r--r--makima/frontend/src/hooks/useFiles.ts105
-rw-r--r--makima/frontend/src/lib/api.ts102
-rw-r--r--makima/frontend/src/main.tsx3
-rw-r--r--makima/frontend/src/routes/files.tsx95
-rw-r--r--makima/frontend/src/routes/listen.tsx8
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/migrations/20241222000000_create_files_table.sql31
-rwxr-xr-x[-rw-r--r--]makima/sh/download-models.sh0
-rwxr-xr-xmakima/sh/run-migrations.sh11
-rwxr-xr-xmakima/sh/setup-db.sh22
-rwxr-xr-xmakima/sh/start-postgres.sh32
-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
28 files changed, 2002 insertions, 23 deletions
diff --git a/Cargo.lock b/Cargo.lock
index d111f9a..fb2d175 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -32,6 +32,21 @@ dependencies = [
]
[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -103,6 +118,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -199,6 +223,9 @@ name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
+dependencies = [
+ "serde_core",
+]
[[package]]
name = "block-buffer"
@@ -259,6 +286,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
+name = "chrono"
+version = "0.4.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
name = "clap"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -320,6 +361,15 @@ dependencies = [
]
[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -333,6 +383,12 @@ dependencies = [
]
[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -358,6 +414,21 @@ dependencies = [
]
[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
+
+[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -386,6 +457,15 @@ dependencies = [
]
[[package]]
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -457,6 +537,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
+ "const-oid",
"pem-rfc7468",
"zeroize",
]
@@ -510,7 +591,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
+ "const-oid",
"crypto-common",
+ "subtle",
]
[[package]]
@@ -546,10 +629,19 @@ dependencies = [
]
[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+dependencies = [
+ "serde",
+]
[[package]]
name = "encode_unicode"
@@ -592,6 +684,28 @@ dependencies = [
]
[[package]]
+name = "etcetera"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
+dependencies = [
+ "cfg-if",
+ "home",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -643,12 +757,29 @@ dependencies = [
]
[[package]]
+name = "flume"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -715,6 +846,17 @@ dependencies = [
]
[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -821,11 +963,31 @@ dependencies = [
[[package]]
name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -838,6 +1000,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
name = "hf-hub"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -862,6 +1030,33 @@ dependencies = [
]
[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "hound"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -994,6 +1189,30 @@ dependencies = [
]
[[package]]
+name = "iana-time-zone"
+version = "0.1.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
name = "icu_collections"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1114,7 +1333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
dependencies = [
"equivalent",
- "hashbrown",
+ "hashbrown 0.16.1",
"serde",
"serde_core",
]
@@ -1202,6 +1421,9 @@ name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
[[package]]
name = "libc"
@@ -1210,6 +1432,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
+name = "libm"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
+
+[[package]]
name = "libredox"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1221,6 +1449,16 @@ dependencies = [
]
[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
name = "libz-rs-sys"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1279,6 +1517,7 @@ dependencies = [
"anyhow",
"axum",
"bytes",
+ "chrono",
"futures",
"hf-hub",
"ndarray",
@@ -1286,6 +1525,7 @@ dependencies = [
"parakeet-rs",
"serde",
"serde_json",
+ "sqlx",
"symphonia",
"thiserror 2.0.17",
"tokenizers 0.21.4",
@@ -1324,6 +1564,16 @@ dependencies = [
]
[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
+[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1463,6 +1713,22 @@ dependencies = [
]
[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.5",
+ "smallvec 1.15.1",
+ "zeroize",
+]
+
+[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1481,12 +1747,24 @@ dependencies = [
]
[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
+ "libm",
]
[[package]]
@@ -1630,6 +1908,12 @@ dependencies = [
]
[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1686,6 +1970,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1980,6 +2285,26 @@ dependencies = [
]
[[package]]
+name = "rsa"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
name = "rust-embed"
version = "8.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2247,6 +2572,16 @@ dependencies = [
]
[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core 0.6.4",
+]
+
+[[package]]
name = "simd-adler32"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2263,6 +2598,9 @@ name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+dependencies = [
+ "serde",
+]
[[package]]
name = "smallvec"
@@ -2296,6 +2634,19 @@ name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
[[package]]
name = "spm_precompiled"
@@ -2310,6 +2661,202 @@ dependencies = [
]
[[package]]
+name = "sqlx"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "chrono",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.15.5",
+ "hashlink",
+ "indexmap",
+ "log",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec 1.15.1",
+ "thiserror 2.0.17",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
+dependencies = [
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx-core",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+ "syn",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-mysql"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
+dependencies = [
+ "atoi",
+ "base64 0.22.1",
+ "bitflags 2.10.0",
+ "byteorder",
+ "bytes",
+ "chrono",
+ "crc",
+ "digest",
+ "dotenvy",
+ "either",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "generic-array",
+ "hex",
+ "hkdf",
+ "hmac",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rand 0.8.5",
+ "rsa",
+ "serde",
+ "sha1",
+ "sha2",
+ "smallvec 1.15.1",
+ "sqlx-core",
+ "stringprep",
+ "thiserror 2.0.17",
+ "tracing",
+ "uuid",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-postgres"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
+dependencies = [
+ "atoi",
+ "base64 0.22.1",
+ "bitflags 2.10.0",
+ "byteorder",
+ "chrono",
+ "crc",
+ "dotenvy",
+ "etcetera",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "hkdf",
+ "hmac",
+ "home",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "rand 0.8.5",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec 1.15.1",
+ "sqlx-core",
+ "stringprep",
+ "thiserror 2.0.17",
+ "tracing",
+ "uuid",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
+dependencies = [
+ "atoi",
+ "chrono",
+ "flume",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "serde_urlencoded",
+ "sqlx-core",
+ "thiserror 2.0.17",
+ "tracing",
+ "url",
+ "uuid",
+]
+
+[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2328,6 +2875,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
+[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2648,6 +3206,21 @@ dependencies = [
]
[[package]]
+name = "tinyvec"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
name = "tokenizers"
version = "0.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2762,6 +3335,17 @@ dependencies = [
]
[[package]]
+name = "tokio-stream"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2974,12 +3558,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
name = "unicode-normalization-alignments"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2989,6 +3588,12 @@ dependencies = [
]
[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3114,6 +3719,7 @@ dependencies = [
"quote",
"regex",
"syn",
+ "uuid",
]
[[package]]
@@ -3142,6 +3748,7 @@ checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
dependencies = [
"getrandom 0.3.4",
"js-sys",
+ "serde_core",
"wasm-bindgen",
]
@@ -3198,6 +3805,12 @@ dependencies = [
]
[[package]]
+name = "wasite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+
+[[package]]
name = "wasm-bindgen"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3316,6 +3929,16 @@ dependencies = [
]
[[package]]
+name = "whoami"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
+dependencies = [
+ "libredox",
+ "wasite",
+]
+
+[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3347,6 +3970,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3383,6 +4041,15 @@ dependencies = [
[[package]]
name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
@@ -3419,6 +4086,21 @@ dependencies = [
[[package]]
name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
@@ -3452,6 +4134,12 @@ dependencies = [
[[package]]
name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
@@ -3464,6 +4152,12 @@ checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
@@ -3476,6 +4170,12 @@ checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
@@ -3500,6 +4200,12 @@ checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
@@ -3512,6 +4218,12 @@ checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
@@ -3524,6 +4236,12 @@ checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
@@ -3536,6 +4254,12 @@ checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
diff --git a/makima/Cargo.toml b/makima/Cargo.toml
index 3368a6e..35c5db8 100644
--- a/makima/Cargo.toml
+++ b/makima/Cargo.toml
@@ -27,12 +27,16 @@ futures = "0.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
bytes = "1.0"
-uuid = { version = "1.0", features = ["v4"] }
+uuid = { version = "1.0", features = ["v4", "serde"] }
# OpenAPI
-utoipa = { version = "5", features = ["axum_extras"] }
+utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
utoipa-swagger-ui = { version = "9", features = ["axum"] }
# Error handling
thiserror = "2.0"
anyhow = "1.0"
+
+# Database
+sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
+chrono = { version = "0.4", features = ["serde"] }
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index 875af5a..4e90d4d 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -9,6 +9,7 @@ interface NavLink {
const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
+ { label: "Files", href: "/files" },
{ label: "Mesh", href: "/mesh", disabled: true },
{ label: "Register", href: "/register", disabled: true },
{ label: "Login", href: "/login", disabled: true },
diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx
new file mode 100644
index 0000000..643f35e
--- /dev/null
+++ b/makima/frontend/src/components/files/FileDetail.tsx
@@ -0,0 +1,143 @@
+import { useState } from "react";
+import type { FileDetail as FileDetailType } from "../../lib/api";
+
+interface FileDetailProps {
+ file: FileDetailType;
+ loading: boolean;
+ onBack: () => void;
+ onSave: (id: string, name: string, description: string) => void;
+ onDelete: (id: string) => void;
+}
+
+export function FileDetail({
+ file,
+ loading,
+ onBack,
+ onSave,
+ onDelete,
+}: FileDetailProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [name, setName] = useState(file.name);
+ const [description, setDescription] = useState(file.description || "");
+
+ const handleSave = () => {
+ onSave(file.id, name, description);
+ setIsEditing(false);
+ };
+
+ const handleCancel = () => {
+ setName(file.name);
+ setDescription(file.description || "");
+ setIsEditing(false);
+ };
+
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel h-full flex flex-col">
+ {/* Header */}
+ <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]">
+ <div className="flex items-center justify-between mb-3">
+ <button
+ onClick={onBack}
+ className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ &larr; Back to list
+ </button>
+ <div className="flex gap-2">
+ {isEditing ? (
+ <>
+ <button
+ onClick={handleCancel}
+ className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSave}
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ Save
+ </button>
+ </>
+ ) : (
+ <>
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Edit
+ </button>
+ <button
+ onClick={() => onDelete(file.id)}
+ className="px-3 py-1.5 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ </>
+ )}
+ </div>
+ </div>
+
+ {isEditing ? (
+ <div className="space-y-3">
+ <input
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ placeholder="File name"
+ />
+ <textarea
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none"
+ rows={2}
+ placeholder="Description (optional)"
+ />
+ </div>
+ ) : (
+ <>
+ <h2 className="font-mono text-lg text-[#dbe7ff] mb-1">
+ {file.name}
+ </h2>
+ {file.description && (
+ <p className="font-mono text-sm text-[#9bc3ff]">
+ {file.description}
+ </p>
+ )}
+ </>
+ )}
+ </div>
+
+ {/* Transcript */}
+ <div className="flex-1 overflow-y-auto p-4 space-y-3">
+ {file.transcript.length === 0 ? (
+ <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8">
+ No transcript entries.
+ </div>
+ ) : (
+ file.transcript.map((entry) => (
+ <div key={entry.id} className="font-mono text-sm">
+ <div className="flex items-baseline gap-2 mb-1">
+ <span className="text-[#75aafc] text-xs">
+ [{entry.start.toFixed(2)}s - {entry.end.toFixed(2)}s]
+ </span>
+ <span className="text-[#9bc3ff] text-xs font-bold">
+ {entry.speaker}
+ </span>
+ </div>
+ <p className="m-0 text-[#dbe7ff] leading-relaxed">{entry.text}</p>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx
new file mode 100644
index 0000000..7e1eea4
--- /dev/null
+++ b/makima/frontend/src/components/files/FileList.tsx
@@ -0,0 +1,96 @@
+import type { FileSummary } from "../../lib/api";
+
+interface FileListProps {
+ files: FileSummary[];
+ loading: boolean;
+ onSelect: (id: string) => void;
+ onDelete: (id: string) => void;
+}
+
+function formatDuration(seconds: number | null): string {
+ if (seconds === null) return "-";
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
+}
+
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export function FileList({
+ files,
+ loading,
+ onSelect,
+ onDelete,
+}: FileListProps) {
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading files...</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel h-full flex flex-col">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]">
+ FILES//
+ </div>
+
+ <div className="flex-1 overflow-y-auto">
+ {files.length === 0 ? (
+ <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8">
+ No saved files yet. Start recording to create one.
+ </div>
+ ) : (
+ <div className="divide-y divide-[rgba(117,170,252,0.15)]">
+ {files.map((file) => (
+ <div
+ key={file.id}
+ className="p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors"
+ >
+ <div className="flex items-start justify-between gap-4">
+ <button
+ onClick={() => onSelect(file.id)}
+ className="flex-1 text-left"
+ >
+ <h3 className="font-mono text-sm text-[#dbe7ff] mb-1">
+ {file.name}
+ </h3>
+ {file.description && (
+ <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2">
+ {file.description}
+ </p>
+ )}
+ <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
+ <span>{file.transcriptCount} segments</span>
+ <span>{formatDuration(file.duration)}</span>
+ <span>{formatDate(file.createdAt)}</span>
+ </div>
+ </button>
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(file.id);
+ }}
+ className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/listen/ControlPanel.tsx b/makima/frontend/src/components/listen/ControlPanel.tsx
index 25dbefe..af2cd05 100644
--- a/makima/frontend/src/components/listen/ControlPanel.tsx
+++ b/makima/frontend/src/components/listen/ControlPanel.tsx
@@ -6,8 +6,9 @@ interface ControlPanelProps {
isConnected: boolean;
micStatus: MicrophoneStatus;
micVolume: number;
+ hasTranscripts: boolean;
onToggle: () => void;
- onReset: () => void;
+ onNew: () => void;
error?: string | null;
}
@@ -33,8 +34,9 @@ export function ControlPanel({
isConnected,
micStatus,
micVolume,
+ hasTranscripts,
onToggle,
- onReset,
+ onNew,
error,
}: ControlPanelProps) {
const statusText = getStatusText(isListening, micStatus);
@@ -125,10 +127,11 @@ export function ControlPanel({
{/* Buttons */}
<div className="flex gap-2">
<button
- onClick={onReset}
+ onClick={onNew}
className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase tracking-wide"
+ title={hasTranscripts ? "Save and start new session" : "Start new session"}
>
- Reset
+ New
</button>
<button
disabled
diff --git a/makima/frontend/src/hooks/useFiles.ts b/makima/frontend/src/hooks/useFiles.ts
new file mode 100644
index 0000000..aacbb6a
--- /dev/null
+++ b/makima/frontend/src/hooks/useFiles.ts
@@ -0,0 +1,105 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ listFiles,
+ getFile,
+ createFile,
+ updateFile,
+ deleteFile,
+ type FileSummary,
+ type FileDetail,
+ type CreateFileRequest,
+ type UpdateFileRequest,
+} from "../lib/api";
+
+export function useFiles() {
+ const [files, setFiles] = useState<FileSummary[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const fetchFiles = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await listFiles();
+ setFiles(response.files);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch files");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const fetchFile = useCallback(
+ async (id: string): Promise<FileDetail | null> => {
+ setError(null);
+ try {
+ return await getFile(id);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch file");
+ return null;
+ }
+ },
+ []
+ );
+
+ const saveFile = useCallback(
+ async (data: CreateFileRequest): Promise<FileDetail | null> => {
+ setError(null);
+ try {
+ const file = await createFile(data);
+ await fetchFiles(); // Refresh list
+ return file;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to save file");
+ return null;
+ }
+ },
+ [fetchFiles]
+ );
+
+ const editFile = useCallback(
+ async (id: string, data: UpdateFileRequest): Promise<FileDetail | null> => {
+ setError(null);
+ try {
+ const file = await updateFile(id, data);
+ await fetchFiles(); // Refresh list
+ return file;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to update file");
+ return null;
+ }
+ },
+ [fetchFiles]
+ );
+
+ const removeFile = useCallback(
+ async (id: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await deleteFile(id);
+ await fetchFiles(); // Refresh list
+ return true;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to delete file");
+ return false;
+ }
+ },
+ [fetchFiles]
+ );
+
+ // Initial fetch
+ useEffect(() => {
+ fetchFiles();
+ }, [fetchFiles]);
+
+ return {
+ files,
+ loading,
+ error,
+ fetchFiles,
+ fetchFile,
+ saveFile,
+ editFile,
+ removeFile,
+ };
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index a6f6c3e..ec596ce 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -38,3 +38,105 @@ export const LISTEN_ENDPOINT = `${WS_BASE}/api/v1/listen`;
export function getEnvironment(): Environment {
return env;
}
+
+// File API types
+export interface TranscriptEntry {
+ id: string;
+ speaker: string;
+ start: number;
+ end: number;
+ text: string;
+ isFinal: boolean;
+}
+
+export interface FileSummary {
+ id: string;
+ name: string;
+ description: string | null;
+ transcriptCount: number;
+ duration: number | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface FileDetail {
+ id: string;
+ ownerId: string;
+ name: string;
+ description: string | null;
+ transcript: TranscriptEntry[];
+ location: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface FileListResponse {
+ files: FileSummary[];
+ total: number;
+}
+
+export interface CreateFileRequest {
+ name?: string;
+ description?: string;
+ transcript: TranscriptEntry[];
+ location?: string;
+}
+
+export interface UpdateFileRequest {
+ name?: string;
+ description?: string;
+ transcript?: TranscriptEntry[];
+}
+
+// File API functions
+export async function listFiles(): Promise<FileListResponse> {
+ const res = await fetch(`${API_BASE}/api/v1/files`);
+ if (!res.ok) {
+ throw new Error(`Failed to list files: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function getFile(id: string): Promise<FileDetail> {
+ const res = await fetch(`${API_BASE}/api/v1/files/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get file: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function createFile(data: CreateFileRequest): Promise<FileDetail> {
+ const res = await fetch(`${API_BASE}/api/v1/files`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to create file: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function updateFile(
+ id: string,
+ data: UpdateFileRequest
+): Promise<FileDetail> {
+ const res = await fetch(`${API_BASE}/api/v1/files/${id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to update file: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function deleteFile(id: string): Promise<void> {
+ const res = await fetch(`${API_BASE}/api/v1/files/${id}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to delete file: ${res.statusText}`);
+ }
+}
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index fe5be21..874ab1a 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -5,6 +5,7 @@ import "./index.css";
import { GridOverlay } from "./components/GridOverlay";
import HomePage from "./routes/_index";
import ListenPage from "./routes/listen";
+import FilesPage from "./routes/files";
createRoot(document.getElementById("root")!).render(
<StrictMode>
@@ -13,6 +14,8 @@ createRoot(document.getElementById("root")!).render(
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/listen" element={<ListenPage />} />
+ <Route path="/files" element={<FilesPage />} />
+ <Route path="/files/:id" element={<FilesPage />} />
</Routes>
</BrowserRouter>
</StrictMode>
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
new file mode 100644
index 0000000..86a24b8
--- /dev/null
+++ b/makima/frontend/src/routes/files.tsx
@@ -0,0 +1,95 @@
+import { useState, useCallback, useEffect } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { FileList } from "../components/files/FileList";
+import { FileDetail } from "../components/files/FileDetail";
+import { useFiles } from "../hooks/useFiles";
+import type { FileDetail as FileDetailType } from "../lib/api";
+
+export default function FilesPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { files, loading, error, fetchFile, editFile, removeFile } = useFiles();
+ const [fileDetail, setFileDetail] = useState<FileDetailType | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+
+ // Load file detail when URL has an id
+ useEffect(() => {
+ if (id) {
+ setDetailLoading(true);
+ fetchFile(id).then((detail) => {
+ setFileDetail(detail);
+ setDetailLoading(false);
+ });
+ } else {
+ setFileDetail(null);
+ }
+ }, [id, fetchFile]);
+
+ const handleSelectFile = useCallback(
+ (fileId: string) => {
+ navigate(`/files/${fileId}`);
+ },
+ [navigate]
+ );
+
+ const handleBack = useCallback(() => {
+ navigate("/files");
+ }, [navigate]);
+
+ const handleDelete = useCallback(
+ async (fileId: string) => {
+ if (confirm("Are you sure you want to delete this file?")) {
+ const success = await removeFile(fileId);
+ if (success && id === fileId) {
+ navigate("/files");
+ }
+ }
+ },
+ [removeFile, id, navigate]
+ );
+
+ const handleSave = useCallback(
+ async (fileId: string, name: string, description: string) => {
+ await editFile(fileId, { name, description });
+ const detail = await fetchFile(fileId);
+ setFileDetail(detail);
+ },
+ [editFile, fetchFile]
+ );
+
+ return (
+ <div className="relative z-10 h-screen flex flex-col overflow-hidden">
+ <Masthead showTicker={false} showNav />
+
+ <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden">
+ {error && (
+ <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm">
+ {error}
+ </div>
+ )}
+
+ {id && fileDetail ? (
+ <FileDetail
+ file={fileDetail}
+ loading={detailLoading}
+ onBack={handleBack}
+ onSave={handleSave}
+ onDelete={handleDelete}
+ />
+ ) : id && detailLoading ? (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
+ </div>
+ ) : (
+ <FileList
+ files={files}
+ loading={loading}
+ onSelect={handleSelectFile}
+ onDelete={handleDelete}
+ />
+ )}
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx
index 9ac0a94..aaba90c 100644
--- a/makima/frontend/src/routes/listen.tsx
+++ b/makima/frontend/src/routes/listen.tsx
@@ -112,10 +112,11 @@ export default function ListenPage() {
setIsListening(true);
}, [isListening, mic, ws]);
- const handleReset = useCallback(() => {
+ const handleNew = useCallback(() => {
+ // Stop current session - backend auto-saves transcript on disconnect
mic.stop();
if (ws.isConnected) {
- ws.stopSession("reset");
+ ws.stopSession("new_session");
}
ws.clearTranscripts();
ws.disconnect();
@@ -147,8 +148,9 @@ export default function ListenPage() {
isConnected={ws.isConnected}
micStatus={mic.status}
micVolume={mic.volume}
+ hasTranscripts={ws.transcripts.length > 0}
onToggle={handleToggle}
- onReset={handleReset}
+ onNew={handleNew}
error={error}
/>
</div>
diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo
index 5d6a380..9bb4ba8 100644
--- a/makima/frontend/tsconfig.tsbuildinfo
+++ b/makima/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
+{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usefiles.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
diff --git a/makima/migrations/20241222000000_create_files_table.sql b/makima/migrations/20241222000000_create_files_table.sql
new file mode 100644
index 0000000..cf6f76c
--- /dev/null
+++ b/makima/migrations/20241222000000_create_files_table.sql
@@ -0,0 +1,31 @@
+-- Create files table for storing transcription records
+CREATE TABLE IF NOT EXISTS files (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000002',
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ transcript JSONB NOT NULL DEFAULT '[]'::jsonb,
+ location VARCHAR(512),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Create index on owner_id for efficient filtering
+CREATE INDEX idx_files_owner_id ON files(owner_id);
+
+-- Create index on created_at for sorting
+CREATE INDEX idx_files_created_at ON files(created_at DESC);
+
+-- Create trigger to auto-update updated_at
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ language 'plpgsql';
+
+CREATE TRIGGER update_files_updated_at
+ BEFORE UPDATE ON files
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
diff --git a/makima/sh/download-models.sh b/makima/sh/download-models.sh
index 0381e15..0381e15 100644..100755
--- a/makima/sh/download-models.sh
+++ b/makima/sh/download-models.sh
diff --git a/makima/sh/run-migrations.sh b/makima/sh/run-migrations.sh
new file mode 100755
index 0000000..d34d6b1
--- /dev/null
+++ b/makima/sh/run-migrations.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+# Run sqlx migrations
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MAKIMA_DIR="$(dirname "${SCRIPT_DIR}")"
+
+POSTGRES_CONNECTION_URI="${POSTGRES_CONNECTION_URI:-postgres://makima:makima_dev@localhost:5432/makima}"
+
+echo "Running migrations from ${MAKIMA_DIR}/migrations..."
+sqlx migrate run --source "${MAKIMA_DIR}/migrations" --database-url "${POSTGRES_CONNECTION_URI}"
+echo "Migrations complete!"
diff --git a/makima/sh/setup-db.sh b/makima/sh/setup-db.sh
new file mode 100755
index 0000000..95e35ac
--- /dev/null
+++ b/makima/sh/setup-db.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+# Combined database setup script
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+echo "=== Setting up Makima Database ==="
+echo ""
+
+# Start PostgreSQL
+echo "Step 1: Starting PostgreSQL..."
+bash "${SCRIPT_DIR}/start-postgres.sh"
+echo ""
+
+# Wait a moment for full initialization
+sleep 2
+
+# Run migrations
+echo "Step 2: Running migrations..."
+bash "${SCRIPT_DIR}/run-migrations.sh"
+echo ""
+
+echo "=== Database setup complete! ==="
diff --git a/makima/sh/start-postgres.sh b/makima/sh/start-postgres.sh
new file mode 100755
index 0000000..203b178
--- /dev/null
+++ b/makima/sh/start-postgres.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+# Start PostgreSQL via Docker for local development
+
+CONTAINER_NAME="makima-postgres"
+POSTGRES_USER="makima"
+POSTGRES_PASSWORD="makima_dev"
+POSTGRES_DB="makima"
+POSTGRES_PORT="5432"
+
+# Check if container already exists
+if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
+ echo "Container ${CONTAINER_NAME} exists. Starting..."
+ docker start ${CONTAINER_NAME}
+else
+ echo "Creating new PostgreSQL container..."
+ docker run -d \
+ --name ${CONTAINER_NAME} \
+ -e POSTGRES_USER=${POSTGRES_USER} \
+ -e POSTGRES_PASSWORD=${POSTGRES_PASSWORD} \
+ -e POSTGRES_DB=${POSTGRES_DB} \
+ -p ${POSTGRES_PORT}:5432 \
+ -v makima_postgres_data:/var/lib/postgresql/data \
+ postgres:16-alpine
+fi
+
+echo "Waiting for PostgreSQL to be ready..."
+until docker exec ${CONTAINER_NAME} pg_isready -U ${POSTGRES_USER} 2>/dev/null; do
+ sleep 1
+done
+
+echo "PostgreSQL is ready!"
+echo "Connection URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}"
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.