diff options
28 files changed, 2002 insertions, 23 deletions
@@ -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" + > + ← 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(¶keet_dir, ¶keet_eou_dir, &sortformer_path) - .map_err(|e| anyhow::anyhow!("Failed to load models: {}", e))?, - ); + let mut app_state = AppState::new(¶keet_dir, ¶keet_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. |
