From 1741e7be305e9f472e86bc9e8c33b88c5cc22307 Mon Sep 17 00:00:00 2001 From: karitham Date: Sun, 17 May 2026 22:30:52 +0000 Subject: [PATCH] feat: rotation playlist --- Cargo.lock | 2685 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 5 +++-- flake.lock | 6 +++--- src/playback.rs | 38 ++++++++++++++++++++++---------------- src/room.rs | 725 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- src/state.rs | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------- src/store.rs | 306 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------ src/transport.rs | 42 +++++++++++++++++++++--------------------- src/types.rs | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- src/web.rs | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------ templates/room.html | 1477 +++++++++++++++++-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- templates/partials/room_scripts.html | 767 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ templates/partials/room_styles.html | 874 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 13 file(s) changed, 5577 insertion(s)(+), 1829 deletion(s)(-) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2685 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[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 = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "askama" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" +dependencies = [ + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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 = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[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" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[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 = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[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.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[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" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[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 = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moqbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "askama", + "async-trait", + "axum", + "bytes", + "chrono", + "clap", + "futures-util", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[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]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +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 = "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", + "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", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[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", + "bitflags", + "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.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "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.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "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", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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 = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[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.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[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" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -14,16 +14,17 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -askama = "0.13" +askama = "0.16" clap = { version = "4", features = ["derive", "env"] } futures-util = "0.3" async-trait = "0.1" sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } chrono = { version = "0.4", features = ["serde"] } bytes = "1" +uuid = { version = "1", features = ["v7", "serde"] } [dev-dependencies] -reqwest = { version = "0.12", features = ["json"] } +tower = { version = "0.5", features = ["util"] } [profile.release] lto = "fat" diff --git a/flake.lock b/flake.lock --- a/flake.lock +++ b/flake.lock @@ -93,11 +93,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1779074409, - "narHash": "sha256-6aXy8Ga41iLVM8ibddFU1O5+wYWcBGNEfZzZuL91eIc=", + "lastModified": 1778987862, + "narHash": "sha256-V3qGt9P1eJP/r/1ONablphfGiH0RP4agQhrRANpDYx8=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "2a77b5b1dc952f214e8102acdef1622b68515560", + "rev": "6f44d8874ac29806c8d5cae42bf8e19ebb5ce0d3", "type": "github" }, "original": { diff --git a/src/playback.rs b/src/playback.rs --- a/src/playback.rs +++ b/src/playback.rs @@ -13,7 +13,7 @@ use crate::media; use crate::source::SourceRegistry; use crate::transport::TrackPublishers; -use crate::types::{ActiveTrackInfo, RoomCommand}; +use crate::types::{ActiveTrackInfo, RoomCommand, TrackId}; /// Manages the abort handle for the currently-running transcoding pipeline. /// @@ -25,9 +25,11 @@ cache_dir: PathBuf, pipeline_tx: mpsc::Sender, /// The id of the currently-spawned pipeline, if any. - track_id: Option, + track_id: Option, /// Signal handle to abort the current pipeline. abort_tx: Option>, + /// Monotonically incrementing MoQ group ID. + group_seq: u64, } impl Player { @@ -42,6 +44,7 @@ pipeline_tx, track_id: None, abort_tx: None, + group_seq: 0, } } @@ -53,6 +56,8 @@ /// If a pipeline is already running, call [`abort`](Self::abort) first. pub(crate) fn start(&mut self, info: &ActiveTrackInfo, publishers: TrackPublishers) { let (abort_tx, abort_rx) = watch::channel(false); + self.group_seq += 1; + let group_id = self.group_seq; let track_id = info.id; let source_registry = self.source_registry.clone(); let url = info.url.clone(); @@ -69,7 +74,6 @@ } }; - let group_id = track_id as u64; let result = media::transcode_to_fmp4(&stream_url, publishers, abort_rx, group_id).await; @@ -82,7 +86,7 @@ send_track_ended(&pipeline_tx, track_id).await; }); - self.track_id = Some(track_id); + self.track_id = Some(info.id); self.abort_tx = Some(abort_tx); } @@ -99,7 +103,7 @@ /// Returns `true` if `item_id` matched the currently-tracked pipeline, /// `false` if it was a stale event from an older pipeline. #[must_use] - pub(crate) fn on_track_ended(&mut self, item_id: i64) -> bool { + pub(crate) fn on_track_ended(&mut self, item_id: TrackId) -> bool { if self.track_id == Some(item_id) { self.track_id = None; self.abort_tx = None; @@ -110,9 +114,9 @@ } } -async fn send_track_ended(tx: &mpsc::Sender, item_id: i64) { +async fn send_track_ended(tx: &mpsc::Sender, item_id: TrackId) { if let Err(e) = tx.send(RoomCommand::TrackEnded { item_id }).await { - tracing::warn!(item_id, error = %e, "player: failed to send TrackEnded"); + tracing::warn!(error = %e, "player: failed to send TrackEnded"); } } @@ -121,7 +125,6 @@ use super::*; use std::path::Path; use std::sync::Arc; - use std::sync::atomic::{AtomicI64, Ordering}; struct MockPlayerSource; @@ -150,12 +153,15 @@ Arc::new(reg) } - fn make_item_id() -> i64 { - static NEXT_ID: AtomicI64 = AtomicI64::new(1); - NEXT_ID.fetch_add(1, Ordering::Relaxed) + fn make_item_id() -> TrackId { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(1); + TrackId(uuid::Uuid::from_u128( + NEXT.fetch_add(1, Ordering::Relaxed) as u128 + )) } - fn test_info(id: i64) -> ActiveTrackInfo { + fn test_info(id: TrackId) -> ActiveTrackInfo { ActiveTrackInfo { id, title: "Test".into(), @@ -176,7 +182,7 @@ player.abort(); - assert!(!player.on_track_ended(99999)); + assert!(!player.on_track_ended(TrackId(uuid::Uuid::from_u128(99999)))); } #[tokio::test] @@ -189,7 +195,7 @@ player.abort(); player.abort(); - assert!(!player.on_track_ended(99999)); + assert!(!player.on_track_ended(TrackId(uuid::Uuid::from_u128(99999)))); } #[tokio::test] @@ -197,7 +203,7 @@ let (tx, _rx) = mpsc::channel(256); let mut player = Player::new(test_registry(), PathBuf::from("/tmp"), tx); player.abort(); - assert!(!player.on_track_ended(99999)); + assert!(!player.on_track_ended(TrackId(uuid::Uuid::from_u128(99999)))); } #[tokio::test] @@ -219,6 +225,6 @@ let mut player = Player::new(test_registry(), PathBuf::from("/tmp"), tx); player.start(&test_info(item_id), TrackPublishers::new()); - assert!(!player.on_track_ended(99999)); + assert!(!player.on_track_ended(TrackId(uuid::Uuid::from_u128(99999)))); } } diff --git a/src/room.rs b/src/room.rs --- a/src/room.rs +++ b/src/room.rs @@ -1,6 +1,6 @@ //! Room state management. //! -//! Each room is identified by a snowflake ID, persists metadata in +//! Each room is identified by a UUIDv7, persists metadata in //! SQLite, and holds ephemeral playback state in memory via a pure //! [`PlaybackState`] machine. //! @@ -13,8 +13,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use bytes::Bytes; use chrono::{DateTime, Utc}; @@ -26,89 +25,15 @@ use crate::playback::Player; use crate::source::SourceRegistry; use crate::state::{Effect, Event, PlaybackState, QueuedTrack}; -use crate::store::RoomStore; +use crate::store::{PlaylistEntry, RoomStore}; use crate::transport::TrackPublishers; use crate::types::{ - ActiveTrackInfo, ChatMessage, HistoryEntry, QueueSummary, RoomCommand, RoomHandle, RoomId, - TrackMeta, TrackState, + ActiveTrackInfo, ChatMessage, HistoryEntry, PlaylistEntryId, QueueSummary, RoomCommand, + RoomHandle, RoomId, TrackId, TrackMeta, TrackState, }; -/// Distributed unique ID generator (Discord-style snowflake). -/// -/// Bit layout: -/// 1 bit unused (always 0) -/// 41 bits timestamp in milliseconds since custom epoch -/// 10 bits node ID (0-1023) -/// 12 bits sequence number (0-4095) -/// -/// Thread-safety is provided by a single `AtomicU64` that packs -/// `(timestamp_ms << 12) | sequence`, allowing lock-free CAS updates. -pub(crate) struct SnowflakeIdGen { - node_id: u16, - state: AtomicU64, // (timestamp_ms << 12) | sequence (12 bits) -} - -const CUSTOM_EPOCH_MS: u64 = 1_735_689_600_000; // 2025-01-01T00:00:00Z - -impl SnowflakeIdGen { - pub(crate) fn new(node_id: u16) -> Self { - assert!(node_id < 1024, "node_id must be in 0..1024"); - Self { - node_id, - state: AtomicU64::new(0), - } - } - - /// Generate a unique 64-bit ID. - /// - /// Thread-safe. Under extreme load (≥4096 IDs in the same - /// millisecond) the generator yields to the tokio runtime until - /// the next millisecond tick. - #[must_use] - pub(crate) async fn next_id(&self) -> u64 { - loop { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before Unix epoch") - .as_millis() as u64; - let ts = now.wrapping_sub(CUSTOM_EPOCH_MS); - - let current = self.state.load(Ordering::Acquire); - let last_ts = current >> 12; - let last_seq = (current & 0xFFF) as u16; - - if ts > last_ts { - // Advance to a new millisecond, resetting the sequence to 0. - let new_val = ts << 12; - if self - .state - .compare_exchange_weak(current, new_val, Ordering::Release, Ordering::Relaxed) - .is_ok() - { - return (ts << 22) | ((self.node_id as u64) << 12); - } - continue; - } - - // Same millisecond: try to claim the next sequence number. - let seq = last_seq + 1; - if seq < 4096 { - let new_val = current + 1; // increment the 12-bit sequence field - if self - .state - .compare_exchange_weak(current, new_val, Ordering::Release, Ordering::Relaxed) - .is_ok() - { - return (last_ts << 22) | ((self.node_id as u64) << 12) | seq as u64; - } - continue; - } - - // Per-millisecond sequence exhausted; yield and retry. - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - } - } -} +#[cfg(test)] +use crate::types::ChatMessageId; /// Per-room event-loop actor. Owns all mutable state. struct RoomActor { @@ -117,7 +42,6 @@ room_name: String, publishers: TrackPublishers, store: Arc, - id_gen: Arc, source_registry: Arc, cache_dir: PathBuf, /// Clone of the command sender, used by spawned extraction tasks to @@ -130,13 +54,17 @@ client_count: u64, next_user_id: u64, last_active_tx: watch::Sender>, + + /// Perpetual playlist entries for auto-fill. + playlist: Vec, + /// Index into the playlist for round-robin auto-fill. + playlist_index: usize, } /// Thread-safe registry of all active rooms with persistent store. #[derive(Clone)] pub(crate) struct Registry { store: Arc, - id_gen: Arc, cache_dir: PathBuf, sources: Arc, inner: Arc>, @@ -154,7 +82,6 @@ ) -> Self { Self { store, - id_gen: Arc::new(SnowflakeIdGen::new(1)), inner: Arc::new(RwLock::new(Inner { rooms: HashMap::new(), })), @@ -163,12 +90,12 @@ } } - /// Create a new room with a snowflake ID, persist to store. + /// Create a new room with a UUIDv7 ID, persist to store. pub(crate) async fn create(&self) -> RoomId { - let id = RoomId(self.id_gen.next_id().await.to_string()); - let room_name = generate_room_name(&id.0); + let id = RoomId::new(); + let room_name = generate_room_name(&id.as_str_buf()); let now = Utc::now(); - let _ = self.store.create_room(&id.0, &room_name).await; + let _ = self.store.create_room(&id.as_str_buf(), &room_name).await; let publishers = TrackPublishers::new(); let (tx, rx) = mpsc::channel(256); @@ -177,11 +104,10 @@ let player = Player::new(self.sources.clone(), self.cache_dir.clone(), tx.clone()); let actor = RoomActor { rx, - room_id: id.clone(), + room_id: id, room_name: room_name.clone(), publishers: publishers.clone(), store: self.store.clone(), - id_gen: self.id_gen.clone(), source_registry: self.sources.clone(), cache_dir: self.cache_dir.clone(), cmd_tx: tx.clone(), @@ -190,6 +116,8 @@ client_count: 0, next_user_id: 1, last_active_tx, + playlist: Vec::new(), + playlist_index: 0, }; tokio::spawn(actor.run()); @@ -214,9 +142,9 @@ } // Slow path: try to rehydrate from store - let snapshot = self.store.load(&id.0).await.ok()??; + let snapshot = self.store.load(&id.as_str_buf()).await.ok()??; - let (state, _effects) = PlaybackState::from_snapshot(snapshot.clone()); + let (state, effects) = PlaybackState::from_snapshot(snapshot.clone()); let publishers = TrackPublishers::new(); let (tx, rx) = mpsc::channel(256); @@ -229,7 +157,6 @@ room_name: snapshot.room_name, publishers: publishers.clone(), store: self.store.clone(), - id_gen: self.id_gen.clone(), source_registry: self.sources.clone(), cache_dir: self.cache_dir.clone(), cmd_tx: tx.clone(), @@ -238,8 +165,17 @@ client_count: 0, next_user_id: 1, last_active_tx, + playlist: snapshot.playlist, + playlist_index: 0, }; tokio::spawn(actor.run()); + + // Persist any stale-active effects before returning the handle. + if !effects.is_empty() { + if let Err(e) = self.store.persist(&id.as_str_buf(), &effects).await { + tracing::warn!(room = %id, error = %e, "rehydration: failed to persist stale effects"); + } + } let handle = RoomHandle { cmd_tx: tx, @@ -260,12 +196,17 @@ if self.inner.read().await.rooms.contains_key(id) { return true; } - self.store.load(&id.0).await.ok().flatten().is_some() + self.store + .load(&id.as_str_buf()) + .await + .ok() + .flatten() + .is_some() } /// Load room name + history (for SSR rendering). pub(crate) async fn load_room_data(&self, id: &RoomId) -> Option<(String, Vec)> { - let snapshot = self.store.load(&id.0).await.ok()??; + let snapshot = self.store.load(&id.as_str_buf()).await.ok()??; let name = snapshot.room_name; let history = snapshot .history @@ -283,7 +224,7 @@ /// Get the upcoming queue for a room from the store. pub(crate) async fn queue(&self, id: &RoomId) -> Vec { - let snapshot = match self.store.load(&id.0).await { + let snapshot = match self.store.load(&id.as_str_buf()).await { Ok(Some(s)) => s, _ => return Vec::new(), }; @@ -321,7 +262,7 @@ .await; } self.inner.write().await.rooms.remove(id); - let _ = self.store.delete_room(&id.0).await; + let _ = self.store.delete_room(&id.as_str_buf()).await; } /// Send a chat message: validate, send to actor, return best-effort ChatMessage. @@ -356,7 +297,7 @@ .await; } Ok(ChatMessage { - id: 0, + id: ChatMessageId::new(), user_name: user_name.to_string(), content: content.to_string(), msg_type: msg_type.to_string(), @@ -372,7 +313,7 @@ room_id: &RoomId, limit: i64, ) -> Result, sqlx::Error> { - self.store.recent_chat(&room_id.0, limit).await + self.store.recent_chat(&room_id.as_str_buf(), limit).await } /// Register a client in the room. @@ -386,14 +327,6 @@ .await .ok()?; resp_rx.await.ok() - } - - /// Unregister a client. - #[cfg(test)] - pub(crate) async fn unregister_client(&self, room_id: &RoomId) { - if let Some(handle) = self.handle(room_id).await { - let _ = handle.cmd_tx.send(RoomCommand::UnregisterClient).await; - } } /// Skip the current track. @@ -441,6 +374,51 @@ swept } + + pub(crate) async fn add_playlist_entry(&self, id: &RoomId, url: String) { + if let Some(handle) = self.handle(id).await { + let _ = handle + .cmd_tx + .send(RoomCommand::AddPlaylistEntry { + url, + added_by: None, + }) + .await; + } + } + + pub(crate) async fn remove_playlist_entry(&self, id: &RoomId, entry_id: PlaylistEntryId) { + if let Some(handle) = self.handle(id).await { + let _ = handle + .cmd_tx + .send(RoomCommand::RemovePlaylistEntry { id: entry_id }) + .await; + } + } +} + +/// Given the current state and playlist, return the next track to auto-fill +/// and the updated index. Returns `None` if auto-fill should not happen. +fn next_playlist_track<'a>( + state: &PlaybackState, + playlist: &'a [PlaylistEntry], + index: usize, +) -> Option<(&'a PlaylistEntry, usize)> { + if state.active.is_some() || !state.queue.is_empty() || playlist.is_empty() { + return None; + } + // Only pick entries whose metadata has been resolved. + let ready: Vec<(usize, &PlaylistEntry)> = playlist + .iter() + .enumerate() + .filter(|(_, e)| !e.pending) + .collect(); + if ready.is_empty() { + return None; + } + let pos = index % ready.len(); + let (_, entry) = ready[pos]; + Some((entry, index.wrapping_add(1))) } impl RoomActor { @@ -465,7 +443,7 @@ RoomCommand::QueueUrl(url) => { tracing::debug!(room = %self.room_id, %url, "track url queued"); - let item_id = self.id_gen.next_id().await as i64; + let item_id = TrackId::new(); let now = Utc::now().timestamp_millis(); let track = QueuedTrack { @@ -479,7 +457,11 @@ let effects = self.state.transition(&Event::TrackQueued(track), now); - if let Err(e) = self.store.persist(&self.room_id.0, &effects).await { + if let Err(e) = self + .store + .persist(&self.room_id.as_str_buf(), &effects) + .await + { tracing::warn!("failed to persist queued track: {e}"); } @@ -508,7 +490,7 @@ } RoomCommand::MetadataReady { item_id, meta } => { - tracing::debug!(room = %self.room_id, item_id, title = %meta.title, "metadata ready"); + tracing::debug!(room = %self.room_id, %item_id, title = %meta.title, "metadata ready"); let now = Utc::now().timestamp_millis(); @@ -523,20 +505,24 @@ now, ); - if let Err(e) = self.store.persist(&self.room_id.0, &effects).await { - tracing::warn!(item_id, error = %e, "failed to persist metadata"); + if let Err(e) = self + .store + .persist(&self.room_id.as_str_buf(), &effects) + .await + { + tracing::warn!(error = %e, "failed to persist metadata"); } self.execute_effects(effects).await; } RoomCommand::MetadataFailed { item_id, error } => { - tracing::debug!(room = %self.room_id, item_id, %error, "metadata extraction failed"); + tracing::debug!(room = %self.room_id, %item_id, %error, "metadata extraction failed"); let now = Utc::now().timestamp_millis(); let effects = self.state.transition( &Event::MetadataUpdated { - item_id, + item_id: item_id.clone(), title: format!("Failed to load: {error}"), duration: "--:--".into(), thumbnail: None, @@ -545,8 +531,12 @@ now, ); - if let Err(e) = self.store.persist(&self.room_id.0, &effects).await { - tracing::warn!(item_id, error = %e, "failed to persist metadata failure"); + if let Err(e) = self + .store + .persist(&self.room_id.as_str_buf(), &effects) + .await + { + tracing::warn!(item_id = %item_id, error = %e, "failed to persist metadata failure"); } self.execute_effects(effects).await; @@ -559,15 +549,20 @@ let effects = self.state.transition(&Event::Skip, now); - if let Err(e) = self.store.persist(&self.room_id.0, &effects).await { + if let Err(e) = self + .store + .persist(&self.room_id.as_str_buf(), &effects) + .await + { tracing::warn!("failed to persist skip effects: {e}"); } self.execute_effects(effects).await; + self.maybe_auto_fill().await; } RoomCommand::TrackEnded { item_id } => { - tracing::debug!(room = %self.room_id, item_id, "track ended"); + tracing::debug!(room = %self.room_id, %item_id, "track ended"); if !self.player.on_track_ended(item_id) { return false; @@ -577,11 +572,124 @@ let effects = self.state.transition(&Event::TrackEnded { item_id }, now); - if let Err(e) = self.store.persist(&self.room_id.0, &effects).await { + if let Err(e) = self + .store + .persist(&self.room_id.as_str_buf(), &effects) + .await + { tracing::warn!("failed to persist track ended effects: {e}"); } self.execute_effects(effects).await; + self.maybe_auto_fill().await; + } + + RoomCommand::AddPlaylistEntry { url, added_by } => { + let id = PlaylistEntryId::new(); + let entry = PlaylistEntry { + id, + url: url.clone(), + title: "Loading...".into(), + duration: "--:--".into(), + thumbnail: None, + source: "direct".into(), + added_by, + added_at: crate::util::now_iso(), + pending: true, + }; + let effects = vec![Effect::AddPlaylistEntry { + id, + url: url.clone(), + title: "Loading...".into(), + duration: "--:--".into(), + thumbnail: None, + source: "direct".into(), + }]; + if let Err(e) = self + .store + .persist(&self.room_id.as_str_buf(), &effects) + .await + { + tracing::warn!("failed to persist playlist entry: {e}"); + return false; + } + self.playlist.push(entry); + self.publish_state_snapshot().await; + + let cmd_tx = self.cmd_tx.clone(); + let sources = self.source_registry.clone(); + let cache_dir = self.cache_dir.clone(); + tokio::spawn(async move { + match sources.extract(&url, &cache_dir).await { + Ok(meta) => { + let _ = cmd_tx + .send(RoomCommand::PlaylistMetadataReady { id, meta }) + .await; + } + Err(e) => { + let _ = cmd_tx + .send(RoomCommand::PlaylistMetadataFailed { + id, + error: e.to_string(), + }) + .await; + } + } + }); + } + + RoomCommand::RemovePlaylistEntry { id } => { + let effects = vec![Effect::RemovePlaylistEntry(id.clone())]; + if let Err(e) = self + .store + .persist(&self.room_id.as_str_buf(), &effects) + .await + { + tracing::warn!("failed to persist playlist removal: {e}"); + return false; + } + self.playlist.retain(|e| e.id != id); + self.publish_state_snapshot().await; + } + + RoomCommand::PlaylistMetadataReady { id, meta } => { + if let Some(entry) = self.playlist.iter_mut().find(|e| e.id == id) { + entry.title = meta.title.clone(); + entry.duration = meta.duration.clone(); + entry.thumbnail = meta.thumbnail.clone(); + entry.source = meta.source.to_string(); + entry.pending = false; + } + let effects = vec![Effect::UpdatePlaylistEntry { + id: id.clone(), + title: meta.title.clone(), + duration: meta.duration.clone(), + thumbnail: meta.thumbnail.clone(), + source: meta.source.to_string(), + }]; + if let Err(e) = self + .store + .persist(&self.room_id.as_str_buf(), &effects) + .await + { + tracing::warn!("failed to persist playlist metadata: {e}"); + } + self.publish_state_snapshot().await; + self.maybe_auto_fill().await; + } + + RoomCommand::PlaylistMetadataFailed { id, error } => { + tracing::warn!(room = %self.room_id, %id, %error, "playlist metadata extraction failed"); + let effects = vec![Effect::RemovePlaylistEntry(id.clone())]; + if let Err(e) = self + .store + .persist(&self.room_id.as_str_buf(), &effects) + .await + { + tracing::warn!("failed to persist playlist removal: {e}"); + } + self.playlist.retain(|e| e.id != id); + self.publish_state_snapshot().await; } RoomCommand::Chat { @@ -598,7 +706,11 @@ msg_type: msg_type.clone(), created_at: created_at.clone(), }]; - if let Ok(output) = self.store.persist(&self.room_id.0, &effects).await { + if let Ok(output) = self + .store + .persist(&self.room_id.as_str_buf(), &effects) + .await + { if let Some(msg_id) = output.chat_message_id { let json = serde_json::json!({ "user": user_name, @@ -667,12 +779,43 @@ } } + /// If the room is idle and has playlist entries, auto-fill the next track. + async fn maybe_auto_fill(&mut self) { + let entry = match next_playlist_track(&self.state, &self.playlist, self.playlist_index) { + Some((e, idx)) => { + self.playlist_index = idx; + e.clone() + } + None => return, + }; + + let id = TrackId::new(); + let now = Utc::now().timestamp_millis(); + let track = QueuedTrack { + id, + title: entry.title, + url: entry.url, + duration: entry.duration, + thumbnail: entry.thumbnail, + pending: false, + }; + let effects = self.state.transition(&Event::TrackQueued(track), now); + if let Err(e) = self + .store + .persist(&self.room_id.as_str_buf(), &effects) + .await + { + tracing::warn!("auto-fill: failed to persist: {e}"); + } + self.execute_effects(effects).await; + } + /// Publish the current state snapshot to all connected clients. /// /// Reads from in-memory [`PlaybackState`], no SQLite queries. async fn publish_state_snapshot(&self) { let current = self.state.active.as_ref().map(|t| TrackState { - id: t.id, + id: t.id.clone(), title: t.title.clone(), url: t.url.clone(), duration: t.duration.clone(), @@ -685,7 +828,7 @@ .queue .iter() .map(|q| QueueSummary { - id: q.id, + id: q.id.clone(), title: q.title.clone(), url: q.url.clone(), duration: q.duration.clone(), @@ -699,7 +842,7 @@ .history .iter() .map(|h| HistoryEntry { - id: h.id, + id: h.id.clone(), title: h.title.clone(), url: h.url.clone(), duration: h.duration.clone(), @@ -720,6 +863,7 @@ "current_track": current, "queue": queue, "history": history, + "playlist": self.playlist, "clients": client_count, }); let payload = Bytes::from(serde_json::to_vec(&snapshot).unwrap_or_default()); @@ -731,10 +875,6 @@ mod tests { use super::*; use crate::store::{InMemoryStore, SqliteStore}; - - // ----------------------------------------------------------------------- - // Shared test infrastructure - // ----------------------------------------------------------------------- async fn poll_until(check: F, timeout: Duration, label: &str) where @@ -797,37 +937,125 @@ Arc::new(reg) } - // ----------------------------------------------------------------------- - // Backend-independent test: snowflake generator - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn test_snowflake_uniqueness() { - let gen_id = Arc::new(SnowflakeIdGen::new(1)); - let mut handles = Vec::new(); - for _ in 0..100 { - let g = gen_id.clone(); - handles.push(tokio::spawn(async move { g.next_id().await })); - } - - let mut ids: Vec = Vec::with_capacity(100); - for h in handles { - ids.push(h.await.unwrap()); - } - - ids.sort(); - let original_len = ids.len(); - ids.dedup(); - assert_eq!(ids.len(), original_len, "duplicate snowflake IDs"); - assert!( - ids.windows(2).all(|w| w[0] < w[1]), - "IDs not monotonically increasing" - ); + fn tid(n: u128) -> TrackId { + TrackId(uuid::Uuid::from_u128(n)) } - // ----------------------------------------------------------------------- - // Shared scenario functions — run against any RoomStore backend - // ----------------------------------------------------------------------- + fn pid(n: u128) -> PlaylistEntryId { + PlaylistEntryId(uuid::Uuid::from_u128(n)) + } + + #[test] + fn test_next_playlist_track_returns_none_when_active() { + let mut state = PlaybackState::default(); + state.active = Some(ActiveTrackInfo { + id: tid(1), + title: "Active".into(), + url: "https://example.com".into(), + duration: "3:00".into(), + thumbnail: None, + started_at_wall: 0, + }); + let playlist = vec![PlaylistEntry { + id: pid(1), + url: "https://example.com/p".into(), + title: "P".into(), + duration: "3:00".into(), + thumbnail: None, + source: "direct".into(), + added_by: None, + added_at: "now".into(), + pending: false, + }]; + assert!(next_playlist_track(&state, &playlist, 0).is_none()); + } + + #[test] + fn test_next_playlist_track_returns_none_when_queue_not_empty() { + let mut state = PlaybackState::default(); + state.queue.push(QueuedTrack { + id: tid(1), + title: "Queued".into(), + url: "https://example.com".into(), + duration: "3:00".into(), + thumbnail: None, + pending: false, + }); + let playlist = vec![PlaylistEntry { + id: pid(1), + url: "https://example.com/p".into(), + title: "P".into(), + duration: "3:00".into(), + thumbnail: None, + source: "direct".into(), + added_by: None, + added_at: "now".into(), + pending: false, + }]; + assert!(next_playlist_track(&state, &playlist, 0).is_none()); + } + + #[test] + fn test_next_playlist_track_returns_none_when_empty_playlist() { + let state = PlaybackState::default(); + let playlist: Vec = Vec::new(); + assert!(next_playlist_track(&state, &playlist, 0).is_none()); + } + + #[test] + fn test_next_playlist_track_returns_entry_when_idle() { + let state = PlaybackState::default(); + let playlist = vec![PlaylistEntry { + id: pid(1), + url: "https://example.com/p".into(), + title: "P".into(), + duration: "3:00".into(), + thumbnail: None, + source: "direct".into(), + added_by: None, + added_at: "now".into(), + pending: false, + }]; + let (entry, index) = next_playlist_track(&state, &playlist, 0).unwrap(); + assert_eq!(entry.id, pid(1)); + assert_eq!(index, 1); + } + + #[test] + fn test_next_playlist_track_wraps_around() { + let state = PlaybackState::default(); + let playlist = vec![ + PlaylistEntry { + id: pid(1), + url: "https://example.com/a".into(), + title: "A".into(), + duration: "3:00".into(), + thumbnail: None, + source: "direct".into(), + added_by: None, + added_at: "now".into(), + pending: false, + }, + PlaylistEntry { + id: pid(2), + url: "https://example.com/b".into(), + title: "B".into(), + duration: "4:00".into(), + thumbnail: None, + source: "direct".into(), + added_by: None, + added_at: "now".into(), + pending: false, + }, + ]; + let (entry, index) = next_playlist_track(&state, &playlist, 1).unwrap(); + assert_eq!(entry.id, pid(2)); + assert_eq!(index, 2); + // Wrap around + let (entry, index) = next_playlist_track(&state, &playlist, 2).unwrap(); + assert_eq!(entry.id, pid(1)); + assert_eq!(index, 3); + } mod scenarios { use super::*; @@ -896,7 +1124,7 @@ reg.skip(room_id).await; poll_until( - || async { history_len(&store, &room_id.0).await == 1 }, + || async { history_len(&store, &room_id.as_str_buf()).await == 1 }, Duration::from_secs(2), "skip should move track to history", ) @@ -954,7 +1182,7 @@ reg.skip(room_id).await; poll_until( || async { - match reg.store.load(&room_id.0).await.ok().flatten() { + match reg.store.load(&room_id.as_str_buf()).await.ok().flatten() { Some(s) => s.history.first().map(|h| h.title.as_str()) == Some("b"), None => false, } @@ -964,7 +1192,13 @@ ) .await; - let snapshot = reg.store.load(&room_id.0).await.ok().flatten().unwrap(); + let snapshot = reg + .store + .load(&room_id.as_str_buf()) + .await + .ok() + .flatten() + .unwrap(); assert!(!snapshot.history.is_empty()); assert_eq!(snapshot.history[0].title, "b"); } @@ -1081,7 +1315,7 @@ reg.skip(room_id).await; poll_until( - || async { history_len(&store, &room_id.0).await > 0 }, + || async { history_len(&store, &room_id.as_str_buf()).await > 0 }, Duration::from_secs(2), "skip should move track to history", ) @@ -1095,13 +1329,132 @@ ) .await; - assert!(history_len(&store, &room_id.0).await > 0); + assert!(history_len(&store, &room_id.as_str_buf()).await > 0); + } + + pub async fn add_playlist_entry(reg: &Registry, room_id: &RoomId) { + reg.add_playlist_entry(room_id, "https://example.com/p1".into()) + .await; + + poll_until( + || async { + reg.store + .load(&room_id.as_str_buf()) + .await + .ok() + .flatten() + .map(|s| s.playlist.len()) + .unwrap_or(0) + == 1 + }, + Duration::from_secs(2), + "playlist should have 1 entry", + ) + .await; + + let snapshot = reg + .store + .load(&room_id.as_str_buf()) + .await + .ok() + .flatten() + .unwrap(); + assert_eq!(snapshot.playlist.len(), 1); + assert_eq!(snapshot.playlist[0].url, "https://example.com/p1"); + } + + pub async fn auto_fill_from_playlist(reg: &Registry, room_id: &RoomId) { + let store = reg.store.clone(); + + reg.add_playlist_entry(room_id, "https://example.com/p1".into()) + .await; + poll_until( + || async { + store + .load(&room_id.as_str_buf()) + .await + .ok() + .flatten() + .map(|s| s.playlist.len()) + .unwrap_or(0) + == 1 + }, + Duration::from_secs(2), + "playlist should have 1 entry", + ) + .await; + + reg.push_url(room_id, "https://example.com/a".into()).await; + poll_until( + || async { reg.queue(room_id).await.is_empty() }, + Duration::from_secs(2), + "first push should have started playing", + ) + .await; + + reg.skip(room_id).await; + + poll_until( + || async { + store + .load(&room_id.as_str_buf()) + .await + .ok() + .flatten() + .map(|s| s.active.is_some()) + .unwrap_or(false) + }, + Duration::from_secs(2), + "auto-fill should start playlist track", + ) + .await; + } + + pub async fn remove_playlist_entry(reg: &Registry, room_id: &RoomId) { + reg.add_playlist_entry(room_id, "https://example.com/p1".into()) + .await; + poll_until( + || async { + reg.store + .load(&room_id.as_str_buf()) + .await + .ok() + .flatten() + .map(|s| s.playlist.len()) + .unwrap_or(0) + == 1 + }, + Duration::from_secs(2), + "playlist should have 1 entry", + ) + .await; + + let snapshot = reg + .store + .load(&room_id.as_str_buf()) + .await + .ok() + .flatten() + .unwrap(); + let entry_id = snapshot.playlist[0].id.clone(); + + reg.remove_playlist_entry(room_id, entry_id).await; + poll_until( + || async { + reg.store + .load(&room_id.as_str_buf()) + .await + .ok() + .flatten() + .map(|s| s.playlist.is_empty()) + .unwrap_or(false) + }, + Duration::from_secs(2), + "playlist should be empty after removal", + ) + .await; } } - - // ----------------------------------------------------------------------- - // InMemoryStore backend - // ----------------------------------------------------------------------- mod inmem { use super::*; @@ -1208,11 +1561,25 @@ let (reg, id) = setup().await; scenarios::push_after_skip_works(®, &id).await; } - } - // ----------------------------------------------------------------------- - // SqliteStore backend — runs same scenarios via :memory: SQLite - // ----------------------------------------------------------------------- + #[tokio::test] + async fn test_add_playlist_entry() { + let (reg, id) = setup().await; + scenarios::add_playlist_entry(®, &id).await; + } + + #[tokio::test] + async fn test_auto_fill_from_playlist() { + let (reg, id) = setup().await; + scenarios::auto_fill_from_playlist(®, &id).await; + } + + #[tokio::test] + async fn test_remove_playlist_entry() { + let (reg, id) = setup().await; + scenarios::remove_playlist_entry(®, &id).await; + } + } mod sqlite { use super::*; @@ -1323,6 +1690,24 @@ async fn test_push_after_skip_works() { let (reg, id) = setup().await; scenarios::push_after_skip_works(®, &id).await; + } + + #[tokio::test] + async fn test_add_playlist_entry() { + let (reg, id) = setup().await; + scenarios::add_playlist_entry(®, &id).await; + } + + #[tokio::test] + async fn test_auto_fill_from_playlist() { + let (reg, id) = setup().await; + scenarios::auto_fill_from_playlist(®, &id).await; + } + + #[tokio::test] + async fn test_remove_playlist_entry() { + let (reg, id) = setup().await; + scenarios::remove_playlist_entry(®, &id).await; } } } diff --git a/src/state.rs b/src/state.rs --- a/src/state.rs +++ b/src/state.rs @@ -3,12 +3,12 @@ //! Owns the in-memory queue, active track, and history. All transitions //! are pure functions returning [`Effect`]s that the caller executes. -use crate::types::ActiveTrackInfo; +use crate::types::{ActiveTrackInfo, PlaylistEntryId, TrackId}; /// A track waiting in the queue (has a DB id). #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct QueuedTrack { - pub id: i64, + pub id: TrackId, pub title: String, pub url: String, pub duration: String, @@ -20,7 +20,7 @@ /// A finished track in history. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct FinishedTrack { - pub id: i64, + pub id: TrackId, pub title: String, pub url: String, pub duration: String, @@ -36,10 +36,10 @@ /// User requested to skip the current track. Skip, /// The transcoding pipeline finished (naturally or via abort). - TrackEnded { item_id: i64 }, + TrackEnded { item_id: TrackId }, /// Asynchronous metadata extraction completed for a queued/playing track. MetadataUpdated { - item_id: i64, + item_id: TrackId, title: String, duration: String, thumbnail: Option, @@ -57,16 +57,16 @@ /// Start transcoding a new track. RoomActor fills in the timestamp. StartPipeline(QueuedTrack), /// Mark a track as started in the DB (set started_playing_at). - PersistStarted(i64), + PersistStarted(TrackId), /// Mark a track as finished in the DB (set played=1, played_at). - PersistFinished(i64), + PersistFinished(TrackId), /// Broadcast the current state snapshot to all clients. PublishSnapshot, /// Persist a newly queued track to the store. PersistQueuedTrack(QueuedTrack), /// Persist metadata update after async extraction completes. PersistMetadata { - item_id: i64, + item_id: TrackId, title: String, duration: String, thumbnail: Option, @@ -79,6 +79,25 @@ msg_type: String, created_at: String, }, + /// Persist a new perpetual playlist entry. + AddPlaylistEntry { + id: PlaylistEntryId, + url: String, + title: String, + duration: String, + thumbnail: Option, + source: String, + }, + /// Update a playlist entry's metadata after extraction. + UpdatePlaylistEntry { + id: PlaylistEntryId, + title: String, + duration: String, + thumbnail: Option, + source: String, + }, + /// Remove a playlist entry. + RemovePlaylistEntry(PlaylistEntryId), } /// Pure playback state: no IO handles, no DB connections. @@ -133,7 +152,6 @@ fn handle_skip(&mut self, _now: i64) -> Vec { let mut effects = Vec::new(); - // Abort any active track and move it to history. if let Some(active) = self.active.take() { effects.push(Effect::AbortPipeline); effects.push(Effect::PersistFinished(active.id)); @@ -153,10 +171,9 @@ } if effects.is_empty() { - return effects; // nothing was playing, nothing to do + return effects; } - // Advance to next track. if let Some(next) = self.queue.first().cloned() { effects.extend(self.advance(next)); } else if !effects.is_empty() { @@ -167,7 +184,7 @@ } /// A transcoding pipeline finished (naturally or after abort signal). - fn handle_track_ended(&mut self, item_id: i64, _now: i64) -> Vec { + fn handle_track_ended(&mut self, item_id: TrackId, _now: i64) -> Vec { // Only react if this matches the currently active track. let is_current = self .active @@ -181,7 +198,6 @@ let mut effects = Vec::new(); - // Move to history. if let Some(active) = self.active.take() { effects.push(Effect::PersistFinished(active.id)); self.history.insert( @@ -199,7 +215,6 @@ ); } - // Advance to next track. if let Some(next) = self.queue.first().cloned() { effects.extend(self.advance(next)); } else { @@ -214,7 +229,7 @@ /// state, only updates display fields. fn handle_metadata_updated( &mut self, - item_id: i64, + item_id: TrackId, title: &str, duration: &str, thumbnail: &Option, @@ -318,9 +333,17 @@ mod tests { use super::*; - fn queued(title: &str, id: i64) -> QueuedTrack { + fn track_id(n: u128) -> TrackId { + TrackId(uuid::Uuid::from_u128(n)) + } + + fn playlist_id(n: u128) -> PlaylistEntryId { + PlaylistEntryId(uuid::Uuid::from_u128(n)) + } + + fn queued(title: &str, n: u128) -> QueuedTrack { QueuedTrack { - id, + id: track_id(n), title: title.into(), url: format!("https://example.com/{title}"), duration: "3:45".into(), @@ -338,7 +361,7 @@ effects, vec![ Effect::PersistQueuedTrack(queued("A", 1)), - Effect::PersistStarted(1), + Effect::PersistStarted(track_id(1)), Effect::StartPipeline(queued("A", 1)), Effect::PublishSnapshot, ], @@ -373,8 +396,8 @@ &effects[..3], &[ Effect::AbortPipeline, - Effect::PersistFinished(1), - Effect::PersistStarted(2), + Effect::PersistFinished(track_id(1)), + Effect::PersistStarted(track_id(2)), ], "skip with next: abort current, finalize A, start B" ); @@ -400,7 +423,7 @@ effects, vec![ Effect::AbortPipeline, - Effect::PersistFinished(1), + Effect::PersistFinished(track_id(1)), Effect::PublishSnapshot, ], "skip last: abort, finalize, notify, no StartPipeline" @@ -411,11 +434,19 @@ fn test_track_ended_last_goes_idle_effect_order() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); - let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); + let effects = s.transition( + &Event::TrackEnded { + item_id: track_id(1), + }, + 0, + ); assert_eq!( effects, - vec![Effect::PersistFinished(1), Effect::PublishSnapshot,], + vec![ + Effect::PersistFinished(track_id(1)), + Effect::PublishSnapshot, + ], "track ended last: finalize, notify, no StartPipeline, no AbortPipeline" ); } @@ -479,11 +510,19 @@ let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); s.transition(&Event::TrackQueued(queued("B", 2)), 0); - let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); + let effects = s.transition( + &Event::TrackEnded { + item_id: track_id(1), + }, + 0, + ); assert_eq!( &effects[..2], - &[Effect::PersistFinished(1), Effect::PersistStarted(2),], + &[ + Effect::PersistFinished(track_id(1)), + Effect::PersistStarted(track_id(2)), + ], "track ended (next): finalize A, start B, no AbortPipeline, no re-persist queued" ); assert!( @@ -503,7 +542,12 @@ let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); - let effects = s.transition(&Event::TrackEnded { item_id: 999 }, 0); + let effects = s.transition( + &Event::TrackEnded { + item_id: track_id(999), + }, + 0, + ); assert!(s.active.is_some()); assert_eq!(s.active.as_ref().unwrap().title, "A"); @@ -514,7 +558,12 @@ fn test_track_ended_last_track_goes_idle() { let mut s = PlaybackState::default(); s.transition(&Event::TrackQueued(queued("A", 1)), 0); - s.transition(&Event::TrackEnded { item_id: 1 }, 0); + s.transition( + &Event::TrackEnded { + item_id: track_id(1), + }, + 0, + ); assert!(s.active.is_none()); assert_eq!(s.history.len(), 1); @@ -537,7 +586,12 @@ s.transition(&Event::Skip, 0); assert_eq!(s.active.as_ref().unwrap().title, "B"); - let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); + let effects = s.transition( + &Event::TrackEnded { + item_id: track_id(1), + }, + 0, + ); assert!(effects.is_empty()); assert_eq!(s.active.as_ref().unwrap().title, "B"); } @@ -555,7 +609,7 @@ s.transition(&Event::TrackQueued(queued("A", 1)), 0); let effects = s.transition( &Event::MetadataUpdated { - item_id: 1, + item_id: track_id(1), title: "Track A (Real)".into(), duration: "4:20".into(), thumbnail: Some("https://img.example/a.jpg".into()), @@ -568,7 +622,7 @@ effects, vec![ Effect::PersistMetadata { - item_id: 1, + item_id: track_id(1), title: "Track A (Real)".into(), duration: "4:20".into(), thumbnail: Some("https://img.example/a.jpg".into()), @@ -597,7 +651,7 @@ // Update the queued item (B, id=2). let effects = s.transition( &Event::MetadataUpdated { - item_id: 2, + item_id: track_id(2), title: "Track B (Real)".into(), duration: "5:55".into(), thumbnail: None, @@ -609,7 +663,7 @@ effects, vec![ Effect::PersistMetadata { - item_id: 2, + item_id: track_id(2), title: "Track B (Real)".into(), duration: "5:55".into(), thumbnail: None, @@ -627,7 +681,7 @@ let mut s = PlaybackState::default(); let effects = s.transition( &Event::MetadataUpdated { - item_id: 999, + item_id: track_id(999), title: "Ghost".into(), duration: "0:00".into(), thumbnail: None, @@ -641,7 +695,7 @@ effects, vec![ Effect::PersistMetadata { - item_id: 999, + item_id: track_id(999), title: "Ghost".into(), duration: "0:00".into(), thumbnail: None, @@ -652,5 +706,11 @@ ); assert!(s.active.is_none()); assert!(s.queue.is_empty()); + } + + // Suppress unused warning for playlist_id helper used for future tests. + #[allow(dead_code)] + fn _use_playlist_id() { + let _ = playlist_id(1); } } diff --git a/src/store.rs b/src/store.rs --- a/src/store.rs +++ b/src/store.rs @@ -14,7 +14,23 @@ use sqlx::{Pool, Sqlite}; use crate::state::{Effect, FinishedTrack, QueuedTrack}; -use crate::types::ChatMessage; +use crate::types::{ChatMessage, ChatMessageId, PlaylistEntryId, TrackId}; + +/// An entry in a room's perpetual playlist — used for auto-fill when the +/// queue runs dry. Metadata is pre-resolved at add-time so auto-played +/// tracks never show a "Loading..." placeholder. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub(crate) struct PlaylistEntry { + pub id: PlaylistEntryId, + pub url: String, + pub title: String, + pub duration: String, + pub thumbnail: Option, + pub source: String, + pub added_by: Option, + pub added_at: String, + pub pending: bool, +} /// Full snapshot of a room's state loaded from the store. /// @@ -31,6 +47,8 @@ pub active: Option, /// Recently finished tracks (newest first). pub history: Vec, + /// Pre-resolved perpetual playlist entries. + pub playlist: Vec, pub chat_messages: Vec, } @@ -40,7 +58,7 @@ #[derive(Debug, Clone, Default)] pub(crate) struct PersistOutput { /// Set when a [`Effect::PersistChat`] was applied. - pub chat_message_id: Option, + pub chat_message_id: Option, } /// Abstraction over room persistence. @@ -74,9 +92,37 @@ async fn delete_room(&self, room_id: &str) -> Result<(), sqlx::Error>; } -// --------------------------------------------------------------------------- -// In-memory implementation (for testing) -// --------------------------------------------------------------------------- +/// In-memory variant of [`PlaylistEntry`] that carries extra fields needed +/// for round-trip persistence but not exposed via the public type. +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Debug, Clone)] +struct InMemoryPlaylistEntry { + id: PlaylistEntryId, + url: String, + title: String, + duration: String, + thumbnail: Option, + source: String, + added_by: Option, + added_at: String, + pending: bool, +} + +impl From for PlaylistEntry { + fn from(e: InMemoryPlaylistEntry) -> Self { + PlaylistEntry { + id: e.id, + url: e.url, + title: e.title, + duration: e.duration, + thumbnail: e.thumbnail, + source: e.source, + added_by: e.added_by, + added_at: e.added_at, + pending: e.pending, + } + } +} #[cfg_attr(not(test), allow(dead_code))] #[derive(Debug)] @@ -85,8 +131,8 @@ queue: Vec, active: Option, history: Vec, + playlist: Vec, chat_messages: Vec, - next_chat_id: i64, } /// Thread-safe in-memory store backed by a [`Mutex`]-protected [`HashMap`]. @@ -125,8 +171,8 @@ queue: Vec::new(), active: None, history: Vec::new(), + playlist: Vec::new(), chat_messages: Vec::new(), - next_chat_id: 1, }, ); Ok(()) @@ -184,14 +230,12 @@ thumbnail, source: _, } => { - // Update in the queue. if let Some(track) = room.queue.iter_mut().find(|t| t.id == *item_id) { track.title.clone_from(title); track.duration.clone_from(duration); track.thumbnail.clone_from(thumbnail); track.pending = false; } - // Update in the active track if it matches. if let Some(ref mut active) = room.active { if active.id == *item_id { active.title.clone_from(title); @@ -207,8 +251,7 @@ msg_type, created_at, } => { - let id = room.next_chat_id; - room.next_chat_id += 1; + let id = ChatMessageId::new(); room.chat_messages.push(ChatMessage { id, user_name: user_name.clone(), @@ -217,6 +260,44 @@ created_at: created_at.clone(), }); output.chat_message_id = Some(id); + } + Effect::AddPlaylistEntry { + id, + url, + title, + duration, + thumbnail, + source, + } => { + room.playlist.push(InMemoryPlaylistEntry { + id: *id, + url: url.clone(), + title: title.clone(), + duration: duration.clone(), + thumbnail: thumbnail.clone(), + source: source.clone(), + added_by: None, + added_at: crate::util::now_iso(), + pending: true, + }); + } + Effect::UpdatePlaylistEntry { + id, + title, + duration, + thumbnail, + source, + } => { + if let Some(entry) = room.playlist.iter_mut().find(|e| e.id == *id) { + entry.title.clone_from(title); + entry.duration.clone_from(duration); + entry.thumbnail.clone_from(thumbnail); + entry.source.clone_from(source); + entry.pending = false; + } + } + Effect::RemovePlaylistEntry(id) => { + room.playlist.retain(|e| e.id != *id); } _ => {} } @@ -238,6 +319,7 @@ queue: room.queue.clone(), active: room.active.clone(), history: room.history.clone(), + playlist: room.playlist.iter().map(|e| e.clone().into()).collect(), chat_messages: room.chat_messages.clone(), })) } @@ -269,10 +351,6 @@ Ok(()) } } - -// --------------------------------------------------------------------------- -// SQLite-backed implementation (production) -// --------------------------------------------------------------------------- /// Production [`RoomStore`] backed by an [`sqlx`] SQLite pool. /// @@ -324,7 +402,7 @@ sqlx::query( "CREATE TABLE IF NOT EXISTS tracks ( - id INTEGER PRIMARY KEY, + id TEXT PRIMARY KEY, room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, url TEXT NOT NULL, title TEXT NOT NULL, @@ -352,12 +430,29 @@ sqlx::query( "CREATE TABLE IF NOT EXISTS chat_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT PRIMARY KEY, room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, user_name TEXT NOT NULL, content TEXT NOT NULL, msg_type TEXT NOT NULL DEFAULT 'message', created_at TEXT NOT NULL + )", + ) + .execute(&pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS playlist_entries ( + id TEXT PRIMARY KEY, + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + url TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + duration TEXT NOT NULL DEFAULT '--:--', + thumbnail TEXT, + source TEXT NOT NULL DEFAULT '', + added_by TEXT, + added_at TEXT NOT NULL, + pending INTEGER NOT NULL DEFAULT 1 )", ) .execute(&pool) @@ -376,7 +471,7 @@ #[derive(sqlx::FromRow)] #[allow(dead_code)] struct TrackRow { - id: i64, + id: String, title: String, url: String, duration: String, @@ -387,14 +482,40 @@ pending: i64, } +/// Helper row type for deserialising `playlist_entries` rows. +#[derive(sqlx::FromRow)] +struct PlaylistRow { + id: String, + url: String, + title: String, + duration: String, + thumbnail: Option, + source: String, + added_by: Option, + added_at: String, + pending: i64, +} + /// Helper row type for deserialising `chat_messages` rows. #[derive(sqlx::FromRow)] struct ChatMsgRow { - id: i64, + id: String, user_name: String, content: String, msg_type: String, created_at: String, +} + +fn parse_track_id(s: &str) -> TrackId { + TrackId(uuid::Uuid::parse_str(s).expect("valid TrackId UUID in DB")) +} + +fn parse_playlist_id(s: &str) -> PlaylistEntryId { + PlaylistEntryId(uuid::Uuid::parse_str(s).expect("valid PlaylistEntryId UUID in DB")) +} + +fn parse_chat_message_id(s: &str) -> ChatMessageId { + ChatMessageId(uuid::Uuid::parse_str(s).expect("valid ChatMessageId UUID in DB")) } #[async_trait] @@ -436,7 +557,7 @@ position, added_at, pending) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", ) - .bind(track.id) + .bind(track.id.as_str_buf()) .bind(room_id) .bind(&track.url) .bind(&track.title) @@ -455,7 +576,7 @@ "UPDATE tracks SET started_at_ms = ?1 WHERE id = ?2 AND played = 0", ) .bind(now_ms) - .bind(id) + .bind(id.as_str_buf()) .execute(&mut *tx) .await?; } @@ -463,7 +584,7 @@ let played_at = crate::util::now_iso(); sqlx::query("UPDATE tracks SET played = 1, played_at = ?1 WHERE id = ?2") .bind(&played_at) - .bind(id) + .bind(id.as_str_buf()) .execute(&mut *tx) .await?; } @@ -485,7 +606,7 @@ .bind(duration.as_str()) .bind(thumbnail.as_deref()) .bind(&src_str) - .bind(item_id) + .bind(item_id.as_str_buf()) .execute(&mut *tx) .await?; } @@ -495,11 +616,13 @@ msg_type, created_at, } => { - let result = sqlx::query( + let id = ChatMessageId::new(); + sqlx::query( "INSERT INTO chat_messages - (room_id, user_name, content, msg_type, created_at) - VALUES (?1, ?2, ?3, ?4, ?5)", + (id, room_id, user_name, content, msg_type, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", ) + .bind(id.as_str_buf()) .bind(room_id) .bind(user_name.as_str()) .bind(content.as_str()) @@ -507,7 +630,54 @@ .bind(created_at.as_str()) .execute(&mut *tx) .await?; - output.chat_message_id = Some(result.last_insert_rowid()); + output.chat_message_id = Some(id); + } + Effect::AddPlaylistEntry { + id, + url, + title, + duration, + thumbnail, + source, + } => { + sqlx::query( + "INSERT INTO playlist_entries (id, room_id, url, title, duration, thumbnail, source, added_at, pending) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)", + ) + .bind(id.as_str_buf()) + .bind(room_id) + .bind(url.as_str()) + .bind(title.as_str()) + .bind(duration.as_str()) + .bind(thumbnail.as_deref()) + .bind(source.as_str()) + .bind(crate::util::now_iso()) + .execute(&mut *tx) + .await?; + } + Effect::UpdatePlaylistEntry { + id, + title, + duration, + thumbnail, + source, + } => { + sqlx::query( + "UPDATE playlist_entries SET title = ?1, duration = ?2, thumbnail = ?3, source = ?4, pending = 0 WHERE id = ?5", + ) + .bind(title.as_str()) + .bind(duration.as_str()) + .bind(thumbnail.as_deref()) + .bind(source.as_str()) + .bind(id.as_str_buf()) + .execute(&mut *tx) + .await?; + } + Effect::RemovePlaylistEntry(id) => { + sqlx::query("DELETE FROM playlist_entries WHERE id = ?") + .bind(id.as_str_buf()) + .execute(&mut *tx) + .await?; } // Non-persistence effects are ignored. _ => {} @@ -579,10 +749,21 @@ .fetch_all(&self.pool) .await?; + // 6. Playlist entries, ordered by id. + let playlist_rows: Vec = sqlx::query_as( + "SELECT id, url, title, duration, thumbnail, source, added_by, added_at, pending + FROM playlist_entries + WHERE room_id = ? + ORDER BY id", + ) + .bind(room_id) + .fetch_all(&self.pool) + .await?; + let queue: Vec = queue_rows .into_iter() .map(|r| QueuedTrack { - id: r.id, + id: parse_track_id(&r.id), title: r.title, url: r.url, duration: r.duration, @@ -592,7 +773,7 @@ .collect(); let active = active_row.map(|r| QueuedTrack { - id: r.id, + id: parse_track_id(&r.id), title: r.title, url: r.url, duration: r.duration, @@ -603,7 +784,7 @@ let history: Vec = history_rows .into_iter() .map(|r| FinishedTrack { - id: r.id, + id: parse_track_id(&r.id), title: r.title, url: r.url, duration: r.duration, @@ -615,11 +796,26 @@ let chat_messages: Vec = chat_rows .into_iter() .map(|r| ChatMessage { - id: r.id, + id: parse_chat_message_id(&r.id), user_name: r.user_name, content: r.content, msg_type: r.msg_type, created_at: r.created_at, + }) + .collect(); + + let playlist: Vec = playlist_rows + .into_iter() + .map(|r| PlaylistEntry { + id: parse_playlist_id(&r.id), + url: r.url, + title: r.title, + duration: r.duration, + thumbnail: r.thumbnail, + source: r.source, + added_by: r.added_by, + added_at: r.added_at, + pending: r.pending != 0, }) .collect(); @@ -629,6 +825,7 @@ queue, active, history, + playlist, chat_messages, })) } @@ -653,7 +850,7 @@ Ok(rows .into_iter() .map(|r| ChatMessage { - id: r.id, + id: parse_chat_message_id(&r.id), user_name: r.user_name, content: r.content, msg_type: r.msg_type, @@ -675,9 +872,9 @@ mod tests { use super::*; - // ------------------------------------------------------------------ - // InMemoryStore tests - // ------------------------------------------------------------------ + fn tid(n: u128) -> TrackId { + TrackId(uuid::Uuid::from_u128(n)) + } #[tokio::test] async fn test_create_and_load_roundtrip() { @@ -749,7 +946,7 @@ store.create_room("r", "R").await.unwrap(); let track = QueuedTrack { - id: 1, + id: tid(1), title: "Loading...".into(), url: "https://example.com/t".into(), duration: "--:--".into(), @@ -764,7 +961,7 @@ let snap = store.load("r").await.unwrap().unwrap(); assert_eq!(snap.queue.len(), 1); - assert_eq!(snap.queue[0].id, 1); + assert_eq!(snap.queue[0].id, tid(1)); assert!(snap.queue[0].pending); assert!(snap.active.is_none()); assert!(snap.history.is_empty()); @@ -776,7 +973,7 @@ store.create_room("r", "R").await.unwrap(); let track = QueuedTrack { - id: 1, + id: tid(1), title: "Track A".into(), url: "https://example.com/a".into(), duration: "3:45".into(), @@ -789,14 +986,14 @@ .await .unwrap(); store - .persist("r", &[Effect::PersistStarted(1)]) + .persist("r", &[Effect::PersistStarted(tid(1))]) .await .unwrap(); let snap = store.load("r").await.unwrap().unwrap(); assert!(snap.queue.is_empty()); assert!(snap.active.is_some()); - assert_eq!(snap.active.unwrap().id, 1); + assert_eq!(snap.active.unwrap().id, tid(1)); } #[tokio::test] @@ -805,7 +1002,7 @@ store.create_room("r", "R").await.unwrap(); store - .persist("r", &[Effect::PersistStarted(999)]) + .persist("r", &[Effect::PersistStarted(tid(999))]) .await .unwrap(); let snap = store.load("r").await.unwrap().unwrap(); @@ -822,27 +1019,27 @@ "r", &[ Effect::PersistQueuedTrack(QueuedTrack { - id: 1, + id: tid(1), title: "A".into(), url: "https://e.com/a".into(), duration: "3:00".into(), thumbnail: None, pending: false, }), - Effect::PersistStarted(1), + Effect::PersistStarted(tid(1)), ], ) .await .unwrap(); store - .persist("r", &[Effect::PersistFinished(1)]) + .persist("r", &[Effect::PersistFinished(tid(1))]) .await .unwrap(); let snap = store.load("r").await.unwrap().unwrap(); assert!(snap.active.is_none()); assert_eq!(snap.history.len(), 1); - assert_eq!(snap.history[0].id, 1); + assert_eq!(snap.history[0].id, tid(1)); assert!(!snap.history[0].played_at.is_empty()); } @@ -852,7 +1049,7 @@ store.create_room("r", "R").await.unwrap(); store - .persist("r", &[Effect::PersistFinished(1)]) + .persist("r", &[Effect::PersistFinished(tid(1))]) .await .unwrap(); let snap = store.load("r").await.unwrap().unwrap(); @@ -865,7 +1062,7 @@ store.create_room("r", "R").await.unwrap(); let track = QueuedTrack { - id: 1, + id: tid(1), title: "Loading...".into(), url: "https://e.com/t".into(), duration: "--:--".into(), @@ -881,7 +1078,7 @@ .persist( "r", &[Effect::PersistMetadata { - item_id: 1, + item_id: tid(1), title: "Real Title".into(), duration: "4:20".into(), thumbnail: Some("https://img.example/t.jpg".into()), @@ -911,14 +1108,14 @@ "r", &[ Effect::PersistQueuedTrack(QueuedTrack { - id: 1, + id: tid(1), title: "Loading...".into(), url: "https://e.com/t".into(), duration: "--:--".into(), thumbnail: None, pending: true, }), - Effect::PersistStarted(1), + Effect::PersistStarted(tid(1)), ], ) .await @@ -927,7 +1124,7 @@ .persist( "r", &[Effect::PersistMetadata { - item_id: 1, + item_id: tid(1), title: "Real Title".into(), duration: "4:20".into(), thumbnail: None, @@ -962,7 +1159,7 @@ .await .unwrap(); - assert_eq!(output.chat_message_id, Some(1)); + assert!(output.chat_message_id.is_some()); let snap = store.load("r").await.unwrap().unwrap(); assert_eq!(snap.chat_messages.len(), 1); @@ -1000,8 +1197,9 @@ .await .unwrap(); - assert_eq!(o1.chat_message_id, Some(1)); - assert_eq!(o2.chat_message_id, Some(2)); + assert!(o1.chat_message_id.is_some()); + assert!(o2.chat_message_id.is_some()); + assert_ne!(o1.chat_message_id, o2.chat_message_id); let snap = store.load("r").await.unwrap().unwrap(); assert_eq!(snap.chat_messages.len(), 2); diff --git a/src/transport.rs b/src/transport.rs --- a/src/transport.rs +++ b/src/transport.rs @@ -13,7 +13,7 @@ use tokio::sync::{broadcast, mpsc, watch}; use crate::room; -use crate::types::{MoqObject, RoomCommand, RoomId, StateValue, TrackId}; +use crate::types::{MoqObject, MoqTrackKind, RoomCommand, RoomId, StateValue}; pub(crate) fn encode_varint(value: u64) -> Vec { if value < 64 { @@ -290,7 +290,7 @@ pub(crate) fn publish_video(&self, group_id: u64, object_id: u64, payload: Bytes) { let _ = self.video.send(MoqObject { - track_id: TrackId::Video, + track_id: MoqTrackKind::Video, group_id, object_id, payload, @@ -301,7 +301,7 @@ pub(crate) fn publish_chat(&self, group_id: u64, payload: Bytes) { let object_id = self.chat_seq.fetch_add(1, Ordering::Relaxed); let _ = self.chat.send(MoqObject { - track_id: TrackId::Chat, + track_id: MoqTrackKind::Chat, group_id, object_id, payload, @@ -366,7 +366,7 @@ let (mut ws_tx, mut ws_rx) = ws.split(); let announce = encode_message(&MoqMessage::Announce { - namespace: room_namespace(&room_id.0), + namespace: room_namespace(&room_id.0.to_string()), }); if ws_tx.send(Message::Binary(announce)).await.is_err() { return; @@ -517,7 +517,7 @@ ) { match msg { MoqMessage::Subscribe { namespace, track } => { - let track_id = match TrackId::from_name(&track) { + let track_id = match MoqTrackKind::from_name(&track) { Some(id) => id, None => { let rst = encode_message(&MoqMessage::SubscribeRst { @@ -539,9 +539,9 @@ } match track_id { - TrackId::Chat => { + MoqTrackKind::Chat => { if let Ok(messages) = registry.recent_chat(room_id, 50).await { - for msg in messages.iter().rev() { + for (seq, msg) in messages.iter().rev().enumerate() { let json = serde_json::json!({ "user": msg.user_name, "content": msg.content, @@ -551,9 +551,9 @@ }); let payload = serde_json::to_vec(&json).unwrap_or_default(); let obj = encode_message(&MoqMessage::Object { - track_id: TrackId::Chat as u64, + track_id: MoqTrackKind::Chat as u64, group_id: 0, - object_id: msg.id as u64, + object_id: seq as u64, payload: Bytes::from(payload), }); if object_tx.send(obj).is_err() { @@ -585,10 +585,10 @@ } })); } - TrackId::State => { + MoqTrackKind::State => { let initial = publishers.state.borrow().clone(); let obj = encode_message(&MoqMessage::Object { - track_id: TrackId::State as u64, + track_id: MoqTrackKind::State as u64, group_id: 0, object_id: initial.seq, payload: initial.payload, @@ -606,7 +606,7 @@ } let val = state_rx.borrow().clone(); let obj = encode_message(&MoqMessage::Object { - track_id: TrackId::State as u64, + track_id: MoqTrackKind::State as u64, group_id: 0, object_id: val.seq, payload: val.payload, @@ -627,7 +627,7 @@ object_id: _, payload, } => { - if track_id == TrackId::Chat as u64 { + if track_id == MoqTrackKind::Chat as u64 { let json: serde_json::Value = serde_json::from_slice(&payload).unwrap_or(serde_json::Value::Null); let content = json.get("content").and_then(|v| v.as_str()).unwrap_or(""); @@ -802,12 +802,12 @@ #[test] fn test_track_id_from_name() { - assert_eq!(TrackId::from_name("audio"), Some(TrackId::Audio)); - assert_eq!(TrackId::from_name("video"), Some(TrackId::Video)); - assert_eq!(TrackId::from_name("chat"), Some(TrackId::Chat)); - assert_eq!(TrackId::from_name("state"), Some(TrackId::State)); - assert_eq!(TrackId::from_name("invalid"), None); - assert_eq!(TrackId::from_name(""), None); + assert_eq!(MoqTrackKind::from_name("audio"), Some(MoqTrackKind::Audio)); + assert_eq!(MoqTrackKind::from_name("video"), Some(MoqTrackKind::Video)); + assert_eq!(MoqTrackKind::from_name("chat"), Some(MoqTrackKind::Chat)); + assert_eq!(MoqTrackKind::from_name("state"), Some(MoqTrackKind::State)); + assert_eq!(MoqTrackKind::from_name("invalid"), None); + assert_eq!(MoqTrackKind::from_name(""), None); } #[tokio::test] @@ -818,7 +818,7 @@ publishers.publish_video(5, 99, Bytes::from("video-frame")); let obj = rx.recv().await.unwrap(); - assert_eq!(obj.track_id, TrackId::Video); + assert_eq!(obj.track_id, MoqTrackKind::Video); assert_eq!(obj.payload, Bytes::from("video-frame")); } @@ -830,7 +830,7 @@ publishers.publish_chat(0, Bytes::from("hello")); let obj = rx.recv().await.unwrap(); - assert_eq!(obj.track_id, TrackId::Chat); + assert_eq!(obj.track_id, MoqTrackKind::Chat); assert_eq!(obj.object_id, 0); assert_eq!(obj.payload, Bytes::from("hello")); } diff --git a/src/types.rs b/src/types.rs --- a/src/types.rs +++ b/src/types.rs @@ -9,9 +9,92 @@ pub(crate) use crate::transport::TrackPublishers; -/// Opaque room identifier, human-readable and URL-safe. -#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize)] -pub(crate) struct RoomId(pub String); +/// Typed identifier for a queued / active / history track. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub(crate) struct TrackId(pub(crate) uuid::Uuid); + +impl TrackId { + pub(crate) fn new() -> Self { + Self(uuid::Uuid::now_v7()) + } + + pub(crate) fn as_str_buf(&self) -> String { + self.0.to_string() + } +} + +impl fmt::Display for TrackId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// Typed identifier for a perpetual playlist entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub(crate) struct PlaylistEntryId(pub(crate) uuid::Uuid); + +impl PlaylistEntryId { + pub(crate) fn new() -> Self { + Self(uuid::Uuid::now_v7()) + } + + pub(crate) fn parse(s: &str) -> Option { + uuid::Uuid::parse_str(s).ok().map(Self) + } + + pub(crate) fn as_str_buf(&self) -> String { + self.0.to_string() + } +} + +impl fmt::Display for PlaylistEntryId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// Typed identifier for a chat message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub(crate) struct ChatMessageId(pub(crate) uuid::Uuid); + +impl ChatMessageId { + pub(crate) fn new() -> Self { + Self(uuid::Uuid::now_v7()) + } + + pub(crate) fn as_str_buf(&self) -> String { + self.0.to_string() + } +} + +impl fmt::Display for ChatMessageId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// Opaque room identifier backed by a UUIDv7. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub(crate) struct RoomId(pub(crate) uuid::Uuid); + +impl RoomId { + pub(crate) fn new() -> Self { + Self(uuid::Uuid::now_v7()) + } + + pub(crate) fn as_str_buf(&self) -> String { + self.0.to_string() + } + + /// Parse a UUID string into a `RoomId`. Returns `None` if invalid. + pub(crate) fn parse(s: &str) -> Option { + uuid::Uuid::parse_str(s).ok().map(Self) + } +} impl fmt::Display for RoomId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -64,7 +147,7 @@ /// Snapshot of the currently playing track for state publishing. #[derive(Debug, Clone, Serialize)] pub(crate) struct TrackState { - pub(crate) id: i64, + pub(crate) id: TrackId, pub(crate) title: String, pub(crate) url: String, pub(crate) duration: String, @@ -75,7 +158,7 @@ /// Summary of a queued track for state publishing. #[derive(Debug, Clone, Serialize)] pub(crate) struct QueueSummary { - pub(crate) id: i64, + pub(crate) id: TrackId, pub(crate) title: String, pub(crate) url: String, pub(crate) duration: String, @@ -91,7 +174,7 @@ #[derive(Debug, Clone, Serialize)] pub(crate) struct HistoryEntry { - pub(crate) id: i64, + pub(crate) id: TrackId, pub(crate) title: String, pub(crate) url: String, pub(crate) duration: String, @@ -102,29 +185,29 @@ /// A chat message returned from send/recent operations. #[derive(Debug, Clone, Serialize)] pub(crate) struct ChatMessage { - pub(crate) id: i64, + pub(crate) id: ChatMessageId, pub(crate) user_name: String, pub(crate) content: String, pub(crate) msg_type: String, pub(crate) created_at: String, } -/// Logical track IDs within a MoQ room. +/// Logical MoQ track kinds within a room (not the same as domain TrackId). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum TrackId { +pub(crate) enum MoqTrackKind { Audio = 0, Video = 1, Chat = 2, State = 3, } -impl TrackId { +impl MoqTrackKind { pub(crate) fn from_name(name: &str) -> Option { match name { - "audio" => Some(TrackId::Audio), - "video" => Some(TrackId::Video), - "chat" => Some(TrackId::Chat), - "state" => Some(TrackId::State), + "audio" => Some(MoqTrackKind::Audio), + "video" => Some(MoqTrackKind::Video), + "chat" => Some(MoqTrackKind::Chat), + "state" => Some(MoqTrackKind::State), _ => None, } } @@ -133,7 +216,7 @@ /// A single MoQ object on a track. #[derive(Debug, Clone)] pub(crate) struct MoqObject { - pub(crate) track_id: TrackId, + pub(crate) track_id: MoqTrackKind, pub(crate) group_id: u64, pub(crate) object_id: u64, pub(crate) payload: Bytes, @@ -152,18 +235,18 @@ QueueUrl(String), /// Async metadata extraction result. MetadataReady { - item_id: i64, + item_id: TrackId, meta: TrackMeta, }, /// Async metadata extraction failed. MetadataFailed { - item_id: i64, + item_id: TrackId, error: String, }, Skip, /// Sent by the pipeline task when it finishes (or errors, or is aborted). TrackEnded { - item_id: i64, + item_id: TrackId, }, Chat { user_name: String, @@ -174,6 +257,25 @@ resp: oneshot::Sender, }, UnregisterClient, + /// Add a URL to the perpetual playlist. + AddPlaylistEntry { + url: String, + added_by: Option, + }, + /// Remove an entry from the perpetual playlist by ID. + RemovePlaylistEntry { + id: PlaylistEntryId, + }, + /// Async metadata extraction completed for a playlist entry. + PlaylistMetadataReady { + id: PlaylistEntryId, + meta: TrackMeta, + }, + /// Async metadata extraction failed for a playlist entry. + PlaylistMetadataFailed { + id: PlaylistEntryId, + error: String, + }, /// Graceful shutdown (sent by idle sweeper). Shutdown, } @@ -181,7 +283,7 @@ /// Ephemeral info about the active track, stored in-memory only. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ActiveTrackInfo { - pub(crate) id: i64, + pub(crate) id: TrackId, pub(crate) title: String, pub(crate) url: String, pub(crate) duration: String, @@ -204,17 +306,20 @@ #[test] fn test_room_id_display() { - let id = RoomId("12345".into()); - assert_eq!(id.to_string(), "12345"); + let id = RoomId::new(); + assert!( + id.to_string().contains('-'), + "RoomId should be UUID with hyphens" + ); } #[test] fn test_track_id_from_name() { - assert_eq!(TrackId::from_name("audio"), Some(TrackId::Audio)); - assert_eq!(TrackId::from_name("video"), Some(TrackId::Video)); - assert_eq!(TrackId::from_name("chat"), Some(TrackId::Chat)); - assert_eq!(TrackId::from_name("state"), Some(TrackId::State)); - assert_eq!(TrackId::from_name("invalid"), None); - assert_eq!(TrackId::from_name(""), None); + assert_eq!(MoqTrackKind::from_name("audio"), Some(MoqTrackKind::Audio)); + assert_eq!(MoqTrackKind::from_name("video"), Some(MoqTrackKind::Video)); + assert_eq!(MoqTrackKind::from_name("chat"), Some(MoqTrackKind::Chat)); + assert_eq!(MoqTrackKind::from_name("state"), Some(MoqTrackKind::State)); + assert_eq!(MoqTrackKind::from_name("invalid"), None); + assert_eq!(MoqTrackKind::from_name(""), None); } } diff --git a/src/web.rs b/src/web.rs --- a/src/web.rs +++ b/src/web.rs @@ -15,7 +15,7 @@ use tokio::sync::Mutex; use crate::room; -use crate::types::{RoomId, TrackMeta}; +use crate::types::{PlaylistEntryId, RoomId, TrackMeta}; /// Per-IP rate limiter for POST /api/ingest (max 1 req/5s per IP). #[derive(Clone)] @@ -53,7 +53,7 @@ #[derive(Clone)] pub(crate) struct AppState { pub rooms: room::Registry, - pub rate_limiter: RateLimiter, + pub rate_limiter: Option, } pub(crate) fn router(rooms: room::Registry) -> Router { @@ -70,7 +70,7 @@ let state = AppState { rooms, - rate_limiter, + rate_limiter: Some(rate_limiter), }; Router::new() @@ -79,6 +79,8 @@ .route("/api/rooms", post(create_room)) .route("/api/ingest", post(ingest_url)) .route("/api/skip", post(skip_handler)) + .route("/api/playlist/add", post(add_playlist_entry)) + .route("/api/playlist/remove", post(remove_playlist_entry)) .route("/ws/{room_id}", get(ws_handler)) .route("/ws/{room_id}/video", get(video_ws_handler)) .with_state(state) @@ -115,20 +117,13 @@ State(state): State, Path(id): Path, ) -> Result, StatusCode> { - if !id.chars().all(|c| c.is_ascii_digit()) { - return Err(StatusCode::BAD_REQUEST); - } - let room_id = RoomId(id.clone()); + let room_id = RoomId::parse(&id).ok_or(StatusCode::BAD_REQUEST)?; let queue = state.rooms.queue(&room_id).await; let queue_empty = queue.is_empty(); - // Load snapshot once for room name + history. - let (room_name, history) = { - let rid = RoomId(id.clone()); - match state.rooms.load_room_data(&rid).await { - Some((name, hist)) => (name, hist), - None => (String::new(), Vec::new()), - } + let (room_name, history) = match state.rooms.load_room_data(&room_id).await { + Some((name, hist)) => (name, hist), + None => (String::new(), Vec::new()), }; let tpl = RoomTemplate { @@ -146,7 +141,7 @@ async fn create_room(State(state): State) -> Result { let room = state.rooms.create().await; - Ok(Json(serde_json::json!({ "room_id": room.0 }))) + Ok(Json(serde_json::json!({ "room_id": room.to_string() }))) } #[derive(serde::Deserialize)] @@ -169,24 +164,23 @@ return Err(err_response(StatusCode::BAD_REQUEST, "URL too long")); } - if !req.room_id.chars().all(|c| c.is_ascii_digit()) { - return Err(err_response(StatusCode::BAD_REQUEST, "invalid room ID")); + if let Some(limiter) = &state.rate_limiter { + if let Err(retry_after) = limiter.check(addr).await { + let mut res = err_response(StatusCode::TOO_MANY_REQUESTS, "rate limited"); + res.headers_mut().insert( + axum::http::header::RETRY_AFTER, + retry_after + .as_secs() + .to_string() + .parse() + .expect("valid retry-after seconds"), + ); + return Err(res); + } } - if let Err(retry_after) = state.rate_limiter.check(addr).await { - let mut res = err_response(StatusCode::TOO_MANY_REQUESTS, "rate limited"); - res.headers_mut().insert( - axum::http::header::RETRY_AFTER, - retry_after - .as_secs() - .to_string() - .parse() - .expect("valid retry-after seconds"), - ); - return Err(res); - } - - let room_id = RoomId(req.room_id); + let room_id = RoomId::parse(&req.room_id) + .ok_or_else(|| err_response(StatusCode::BAD_REQUEST, "invalid room ID"))?; if !state.rooms.exists(&room_id).await { return Err(err_response(StatusCode::NOT_FOUND, "room not found")); @@ -206,7 +200,12 @@ State(state): State, axum::Json(req): axum::Json, ) -> Result, (StatusCode, Json)> { - let room_id = RoomId(req.room_id); + let room_id = RoomId::parse(&req.room_id).ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "room not found" })), + ) + })?; if !state.rooms.exists(&room_id).await { return Err(( @@ -220,17 +219,56 @@ Ok(Json(serde_json::json!({ "ok": true }))) } +#[derive(serde::Deserialize)] +struct AddPlaylistReq { + room_id: String, + url: String, +} + +async fn add_playlist_entry( + State(state): State, + axum::Json(req): axum::Json, +) -> Result, axum::response::Response> { + if req.url.len() > 2048 { + return Err(err_response(StatusCode::BAD_REQUEST, "URL too long")); + } + let room_id = RoomId::parse(&req.room_id) + .ok_or_else(|| err_response(StatusCode::BAD_REQUEST, "invalid room ID"))?; + if !state.rooms.exists(&room_id).await { + return Err(err_response(StatusCode::NOT_FOUND, "room not found")); + } + state.rooms.add_playlist_entry(&room_id, req.url).await; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +#[derive(serde::Deserialize)] +struct RemovePlaylistReq { + room_id: String, + id: String, +} + +async fn remove_playlist_entry( + State(state): State, + axum::Json(req): axum::Json, +) -> Result, axum::response::Response> { + let room_id = RoomId::parse(&req.room_id) + .ok_or_else(|| err_response(StatusCode::BAD_REQUEST, "invalid room ID"))?; + let entry_id = PlaylistEntryId::parse(&req.id) + .ok_or_else(|| err_response(StatusCode::BAD_REQUEST, "invalid entry ID"))?; + if !state.rooms.exists(&room_id).await { + return Err(err_response(StatusCode::NOT_FOUND, "room not found")); + } + state.rooms.remove_playlist_entry(&room_id, entry_id).await; + Ok(Json(serde_json::json!({ "ok": true }))) +} + async fn ws_handler( ws: WebSocketUpgrade, Path(room_id): Path, Query(params): Query>, State(state): State, ) -> Result { - if !room_id.chars().all(|c| c.is_ascii_digit()) { - return Err((StatusCode::BAD_REQUEST, "invalid room ID")); - } - - let rid = RoomId(room_id); + let rid = RoomId::parse(&room_id).ok_or((StatusCode::BAD_REQUEST, "invalid room ID"))?; if !state.rooms.exists(&rid).await { return Err((StatusCode::NOT_FOUND, "room not found")); @@ -270,11 +308,7 @@ Path(room_id): Path, State(state): State, ) -> Result { - if !room_id.chars().all(|c| c.is_ascii_digit()) { - return Err((StatusCode::BAD_REQUEST, "invalid room ID")); - } - - let rid = RoomId(room_id); + let rid = RoomId::parse(&room_id).ok_or((StatusCode::BAD_REQUEST, "invalid room ID"))?; if !state.rooms.exists(&rid).await { return Err((StatusCode::NOT_FOUND, "room not found")); @@ -289,4 +323,80 @@ Ok(ws.on_upgrade(move |socket| async move { crate::transport::handle_video_session(socket, handle.publishers).await; })) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::{Body, to_bytes}; + use axum::http::{Request, header}; + use std::path::PathBuf; + use tower::ServiceExt; + + fn test_registry() -> room::Registry { + let store = Arc::new(crate::store::InMemoryStore::new()); + let sources = Arc::new(crate::source::SourceRegistry::new()); + room::Registry::new(store, PathBuf::from("/tmp/moqbox-test"), sources) + } + + fn test_app(registry: room::Registry) -> Router { + let state = AppState { + rooms: registry, + rate_limiter: None, + }; + Router::new() + .route("/api/ingest", post(ingest_url)) + .with_state(state) + } + + fn ingest_request(room_id: &str, url: &str) -> Request { + let body = serde_json::json!({ "url": url, "room_id": room_id }).to_string(); + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 0)); + Request::builder() + .method("POST") + .uri("/api/ingest") + .header(header::CONTENT_TYPE, "application/json") + .extension(ConnectInfo(addr)) + .body(Body::from(body)) + .unwrap() + } + + async fn response_body(res: axum::response::Response) -> String { + let bytes = to_bytes(res.into_body(), 4096).await.unwrap(); + String::from_utf8_lossy(&bytes).into_owned() + } + + #[tokio::test] + async fn ingest_accepts_uuid_room_id() { + let reg = test_registry(); + let room_id = reg.create().await.to_string(); + let app = test_app(reg); + + let res = app + .oneshot(ingest_request(&room_id, "https://example.com/track.mp3")) + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + assert!(response_body(res).await.contains(r#""ok":true"#)); + } + + #[tokio::test] + async fn ingest_rejects_non_uuid_room_id() { + let app = test_app(test_registry()); + + for bad_id in ["abc", "not-a-uuid"] { + let res = app + .clone() + .oneshot(ingest_request(bad_id, "https://example.com/track.mp3")) + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "room_id={bad_id}"); + assert!( + response_body(res).await.contains("invalid room ID"), + "room_id={bad_id}" + ); + } + } } diff --git a/templates/room.html b/templates/room.html --- a/templates/room.html +++ b/templates/room.html @@ -11,804 +11,7 @@ rel="stylesheet" > @@ -843,6 +46,7 @@ / 0:00 + @@ -894,7 +98,7 @@ id="url-input" placeholder="Paste YouTube / SoundCloud / Bandcamp URL..." > - + @@ -920,6 +124,19 @@ {% endif %} + +
+
Rotation
+
+
+ + +
+
@@ -933,667 +150,7 @@
diff --git a/templates/partials/room_scripts.html b/templates/partials/room_scripts.html new file mode 100644 --- /dev/null +++ b/templates/partials/room_scripts.html @@ -0,0 +1,767 @@ + function encVarint(n) { + if (n < 64) return new Uint8Array([n]); + if (n < 16384) return new Uint8Array([(n >> 8) | 0x40, n & 0xff]); + if (n < 1073741824) + return new Uint8Array([(n >> 24) | 0x80, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]); + const b = new Uint8Array(8); + b[0] = (n >> 56) | 0xc0; + b[1] = (n >> 48) & 0xff; + b[2] = (n >> 40) & 0xff; + b[3] = (n >> 32) & 0xff; + b[4] = (n >> 24) & 0xff; + b[5] = (n >> 16) & 0xff; + b[6] = (n >> 8) & 0xff; + b[7] = n & 0xff; + return b; + } + function encString(s) { + const bytes = new TextEncoder().encode(s); + const len = encVarint(bytes.length); + const out = new Uint8Array(len.length + bytes.length); + out.set(len); + out.set(bytes, len.length); + return out; + } + function concat(arrays) { + const total = arrays.reduce((sum, arr) => sum + arr.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const a of arrays) { + out.set(a, off); + off += a.length; + } + return out; + } + + function decVarint(buf, off) { + const first = buf[off]; + const tag = first >> 6; + if (tag === 0) return [first & 0x3f, off + 1]; + if (tag === 1) return [((first & 0x3f) << 8) | buf[off + 1], off + 2]; + if (tag === 2) + return [ + ((first & 0x3f) << 24) | (buf[off + 1] << 16) | (buf[off + 2] << 8) | buf[off + 3], + off + 4, + ]; + let val = BigInt(first & 0x3f) << 56n; + for (let i = 1; i < 8; i++) val |= BigInt(buf[off + i]) << BigInt(8 * (7 - i)); + return [Number(val), off + 8]; + } + function decString(buf, off) { + const [len, o] = decVarint(buf, off); + const str = new TextDecoder().decode(buf.slice(o, o + len)); + return [str, o + len]; + } + + const DEBUG = + new URLSearchParams(location.search).has("debug") || localStorage.getItem("moqbox_debug"); + function debug(...args) { + if (DEBUG) console.log(...args); + } + + const roomId = "{{ room_id }}"; + let userName = sessionStorage.getItem("moqbox_user"); + if (!userName) { + userName = prompt("Enter your name:", "anonymous") || "anonymous"; + sessionStorage.setItem("moqbox_user", userName); + } + + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + let ws = null; + let reconnectDelay = 1000; + + let mediaSource = null; + let sourceBuffer = null; + const pendingBoxes = []; // buffered while waiting for sourceopen + let currentTrackId = null; // tracks the currently playing track (from state) + let awaitingInit = true; // true until an init segment is processed + let seekInitDone = false; // true after first seek on join or track change + + function initVideo() { + // Guard: don't create a second player if already initialized. + if (document.getElementById("video-player")) return; + debug("initVideo called"); + const container = + document.getElementById("video-container") || document.querySelector(".main-col"); + const video = document.createElement("video"); + video.id = "video-player"; + video.controls = false; + video.autoplay = true; + video.muted = true; + container.insertBefore(video, container.firstChild); + + // Wire up volume controls (element is in HTML, not created in JS) + const volSlider = document.getElementById("vol-slider"); + const volIcon = document.getElementById("vol-icon"); + // Starts muted; user must gesture (click icon or drag slider) to enable audio. + volSlider.addEventListener("input", () => { + const v = parseFloat(volSlider.value); + video.volume = v; + video.muted = v === 0; + volIcon.textContent = v === 0 ? "\u{1F507}" : v < 0.5 ? "\u{1F509}" : "\u{1F50A}"; + }); + volIcon.addEventListener("click", () => { + if (video.muted) { + video.muted = false; + video.volume = 1; + volSlider.value = "1"; + volIcon.textContent = "\u{1F50A}"; + } else { + video.muted = true; + volSlider.value = "0"; + volIcon.textContent = "\u{1F507}"; + } + }); + + mediaSource = new MediaSource(); + mediaSource.addEventListener("sourceopen", () => { + debug("MediaSource sourceopen"); + try { + sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.4d0028,mp4a.40.2"'); + sourceBuffer.mode = "segments"; + sourceBuffer.addEventListener("updateend", () => { + if (pendingBoxes.length > 0) { + const next = pendingBoxes.shift(); + try { + sourceBuffer.appendBuffer(next); + } catch (e) { + console.warn("SourceBuffer append failed:", e); + } + } + if (!seekInitDone) { + const v = document.getElementById("video-player"); + if (v && v.buffered.length > 0) { + seekInitDone = true; + v.currentTime = v.buffered.start(0); + } + } + }); + // Flush boxes that arrived before sourceopen. + while (pendingBoxes.length > 0) { + const box = pendingBoxes.shift(); + if (!sourceBuffer.updating) { + try { + sourceBuffer.appendBuffer(box); + } catch (e) { + console.warn("SourceBuffer flush failed:", e); + } + } else { + pendingBoxes.unshift(box); + break; + } + } + } catch (e) { + console.warn("MediaSource not supported:", e); + } + }); + video.src = URL.createObjectURL(mediaSource); + } + initVideo(); + + function appendVideo(data) { + if (sourceBuffer && !sourceBuffer.updating) { + try { + sourceBuffer.appendBuffer(data); + } catch (e) { + console.warn("SourceBuffer append failed:", e); + } + } else { + pendingBoxes.push(data); + } + } + + function subscribeTracks() { + // Subscribe to chat track. + ws.send( + concat([encVarint(0x01), encString("moqbox/room/" + roomId + "/chat"), encString("chat")]), + ); + // Subscribe to state track for now-playing updates. + ws.send( + concat([ + encVarint(0x01), + encString("moqbox/room/" + roomId + "/state"), + encString("state"), + ]), + ); + } + + function updateConnStatus(connected) { + const el = document.getElementById("conn-status"); + if (!el) return; + el.className = "conn-status " + (connected ? "connected" : "disconnected"); + el.textContent = connected ? "connected" : "disconnected"; + } + + function connect() { + ws = new WebSocket( + proto + "//" + location.host + "/ws/" + roomId + "?name=" + encodeURIComponent(userName), + ); + ws.binaryType = "arraybuffer"; + + ws.onopen = () => { + debug("WS connected"); + updateConnStatus(true); + reconnectDelay = 1000; + if (elapsedInterval) { clearInterval(elapsedInterval); elapsedInterval = null; } + currentTrackId = null; + subscribeTracks(); + }; + + ws.onmessage = (event) => { + const buf = new Uint8Array(event.data); + let off = 0; + while (off < buf.length) { + const [msgType, o1] = decVarint(buf, off); + if (msgType === 0x00) { + const [trackId, o2] = decVarint(buf, o1); + const [groupId, o3] = decVarint(buf, o2); + const [objectId, o4] = decVarint(buf, o3); + if (trackId === 2) { + const payload = new TextDecoder().decode(buf.slice(o4)); + try { + const chat = JSON.parse(payload); + appendChat(chat); + } catch (_) {} + off = buf.length; + } else if (trackId === 3) { + const payload = new TextDecoder().decode(buf.slice(o4)); + try { + const state = JSON.parse(payload); + updateNowPlaying(state); + } catch (_) {} + off = buf.length; + } else { + off = buf.length; + } + } else if (msgType === 0x02) { + const [ns, o2] = decString(buf, o1); + const [tr, o3] = decString(buf, o2); + off = o3; + } else if (msgType === 0x06) { + const [ns, o2] = decString(buf, o1); + const [tr, o3] = decString(buf, o2); + const [reason, o4] = decString(buf, o3); + off = o4; + } else { + break; + } + } + }; + + ws.onclose = () => { + updateConnStatus(false); + setTimeout(() => { + reconnectDelay = Math.min(reconnectDelay * 2, 30000); + connect(); + }, reconnectDelay); + }; + } + + connect(); + + let videoWs = null; + let videoReconnectDelay = 1000; + + function connectVideoWs() { + videoWs = new WebSocket(proto + "//" + location.host + "/ws/" + roomId + "/video"); + videoWs.binaryType = "arraybuffer"; + + videoWs.onopen = () => { + debug("video WS connected"); + videoReconnectDelay = 1000; + awaitingInit = true; + pendingBoxes.length = 0; + seekInitDone = false; + }; + + videoWs.onmessage = (event) => { + const data = new Uint8Array(event.data); + + // Always detect init segments, even when not awaitingInit. + // The video WS and control WS are independent connections with + // no ordering guarantee — init can arrive before the state update. + const isInit = + data.length >= 8 && String.fromCharCode(data[4], data[5], data[6], data[7]) === "ftyp"; + + if (isInit) { + debug("init segment received (video WS)"); + awaitingInit = false; + pendingBoxes.length = 0; + if (sourceBuffer && !sourceBuffer.updating) { + try { + sourceBuffer.abort(); + if (sourceBuffer.buffered.length > 0) { + const end = sourceBuffer.buffered.end(sourceBuffer.buffered.length - 1); + sourceBuffer.remove(0, end); + } + } catch (_) {} + } + appendVideo(data); + return; + } + + if (awaitingInit) { + pendingBoxes.push(data); + return; + } + + appendVideo(data); + }; + + videoWs.onclose = () => { + setTimeout(() => { + videoReconnectDelay = Math.min(videoReconnectDelay * 2, 30000); + connectVideoWs(); + }, videoReconnectDelay); + }; + } + + // Open video connection after a short delay to let the control WS + // establish the room subscription first. + setTimeout(connectVideoWs, 100); + + function copyRoomLink() { + const url = window.location.href; + navigator.clipboard + .writeText(url) + .then(() => { + const btn = document.getElementById("copy-link"); + if (!btn) return; + const original = btn.innerHTML; + btn.innerHTML = '✓'; + setTimeout(() => { + btn.innerHTML = original; + }, 1500); + }) + .catch(() => { + const input = document.createElement("input"); + input.value = url; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + }); + } + + function appendChat(chat) { + const container = document.getElementById("chat-messages"); + const div = document.createElement("div"); + div.className = "chat-msg" + (chat.type === "system_event" ? " system" : ""); + + let ts = ""; + if (chat.ts) { + const d = new Date(chat.ts); + ts = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + + div.innerHTML = + '' + + esc(chat.user || "anonymous") + + "" + + '' + + ts + + "
" + + '' + + esc(chat.content || "") + + ""; + container.appendChild(div); + + // Auto-scroll only if user is near the bottom (within 30px threshold). + const threshold = 30; + if (container.scrollHeight - container.scrollTop - container.clientHeight < threshold) { + container.scrollTop = container.scrollHeight; + } + } + + let elapsedInterval = null; + + function parseDuration(s) { + // Converts "M:SS" or "H:MM:SS" to seconds + const parts = s.split(":").map(Number); + if (parts.length === 2) return parts[0] * 60 + parts[1]; + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + return 0; + } + + function updateNowPlaying(state) { + const container = document.getElementById("video-container"); + if (!container) return; + + // Update room name in top bar from state + if (state.room_name) { + const nameEl = document.getElementById("room-name"); + if (nameEl) nameEl.textContent = state.room_name; + } + + // Update viewer count pill + const pill = document.getElementById("viewer-pill"); + if (pill) { + const c = state.clients || 0; + pill.textContent = c === 1 ? "1 viewer" : c + " viewers"; + } + + if (state.current_track) { + const trackId = state.current_track.id; + const trackChanged = currentTrackId !== trackId; + + // Detect track change — reset video state. + if (currentTrackId !== null && trackChanged) { + debug("track change", currentTrackId, "->", trackId); + awaitingInit = true; + pendingBoxes.length = 0; + seekInitDone = false; + if (elapsedInterval) { + clearInterval(elapsedInterval); + elapsedInterval = null; + } + } + currentTrackId = trackId; + + // Skip overlay DOM work when the track hasn't changed. + // Queue display is still updated below. + if (!trackChanged) { + if (state.current_track) { + const toThumb = document.getElementById("to-thumb"); + const toTitle = document.getElementById("to-title"); + if (toThumb) { + if (state.current_track.thumbnail) { + toThumb.src = state.current_track.thumbnail; + toThumb.style.display = "block"; + } else { + toThumb.style.display = "none"; + } + } + if (toTitle) toTitle.textContent = state.current_track.title; + } + updateQueueDisplay(state); + updatePlaylistDisplay(state); + return; + } + + const startedAt = state.current_track.started_at; + const sourceUrl = state.current_track.url || ""; + + // Show track overlay, hide idle message + container.classList.remove("idle"); + + const toThumb = document.getElementById("to-thumb"); + const toTitle = document.getElementById("to-title"); + const toElapsed = document.getElementById("to-elapsed"); + const toDuration = document.getElementById("to-duration"); + const toViewers = document.getElementById("to-viewers"); + const toSource = document.getElementById("to-source"); + const toFill = document.getElementById("to-progress-fill"); + const toOverlay = document.getElementById("track-overlay"); + + // Thumbnail + if (state.current_track.thumbnail) { + toThumb.src = state.current_track.thumbnail; + toThumb.style.display = "block"; + } else { + toThumb.style.display = "none"; + } + + // Title + toTitle.textContent = state.current_track.title; + + // Duration + toDuration.textContent = " / " + state.current_track.duration; + + // Source link + if (sourceUrl) { + toSource.href = sourceUrl; + toSource.textContent = "source"; + toSource.style.display = "inline"; + } else { + toSource.style.display = "none"; + } + + // Rotation add button + const toRot = document.getElementById("to-rot"); + if (toRot) { + toRot.style.display = sourceUrl ? "inline-flex" : "none"; + toRot.onclick = () => addToRotation(sourceUrl); + } + + // Viewers + toViewers.textContent = state.clients + ? " · " + state.clients + " viewer" + (state.clients !== 1 ? "s" : "") + : ""; + + // Elapsed (initial) + toElapsed.textContent = formatTime((Date.now() - startedAt) / 1000); + + // Ensure overlay is visible on first play + toOverlay.classList.add("show"); + setTimeout(() => toOverlay.classList.remove("show"), 3000); + + // Update elapsed every second from local clock. + elapsedInterval = setInterval(() => { + const elapsed = (Date.now() - startedAt) / 1000; + const elSpan = document.getElementById("to-elapsed"); + if (elSpan) elSpan.textContent = formatTime(elapsed); + + const fill = document.getElementById("to-progress-fill"); + if (fill && state.current_track) { + const total = parseDuration(state.current_track.duration); + if (total > 0) { + fill.style.width = Math.min(100, (elapsed / total) * 100) + "%"; + } + } + }, 1000); + + // Forward-only sync: if video is more than 3 seconds behind + // the live edge, seek forward to catch up. Never seek backward. + const video = document.getElementById("video-player"); + if (video && video.buffered.length > 0 && startedAt) { + const target = (Date.now() - startedAt) / 1000; + if (target > 0) { + const behind = target - video.currentTime; + if (behind > 3) { + video.currentTime = target; + } + } + } + } else { + if (elapsedInterval) { + clearInterval(elapsedInterval); + elapsedInterval = null; + } + currentTrackId = null; + container.classList.add("idle"); + } + + // Update queue and playlist from state. + updateQueueDisplay(state); + updatePlaylistDisplay(state); + } + + function formatTime(secs) { + const m = Math.floor(secs / 60); + const s = Math.floor(secs % 60); + return m + ":" + String(s).padStart(2, "0"); + } + + function esc(s) { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; + } + + function safeUrl(url) { + if (!url) return ""; + if (url.startsWith("http://") || url.startsWith("https://")) { + return esc(url); + } + return ""; + } + + function renderTrackItem(track, actions, extraCls) { + const cls = extraCls || ""; + const thumb = track.thumbnail + ? '' + : '
'; + let btns = ""; + for (const a of actions) { + if (a.cls === "source-link") { + btns += + 'source'; + } else { + btns += + '"; + } + } + return ( + '
  • ' + + thumb + + '
    ' + + esc(track.title || "Loading...") + + "
    " + + '
    ' + + esc(track.duration || "--:--") + + "
    " + + btns + + "
  • " + ); + } + + function updateQueueDisplay(state) { + const qSection = document.getElementById("queue-section"); + const hSection = document.getElementById("history-section"); + if (!qSection) return; + + const rotAction = { cls: "rot-add", label: "+", title: "Add to Rotation" }; + const linkAction = { cls: "source-link", label: "source", title: "Open source" }; + + if (state.queue && state.queue.length > 0) { + let html = '
      '; + for (const item of state.queue) { + html += renderTrackItem(item, [linkAction, rotAction], item.pending ? "pending" : ""); + } + html += "
    "; + qSection.innerHTML = html; + } else if (state.current_track) { + qSection.innerHTML = + '
    ▶
    No upcoming tracks
    Add another link to keep the queue going.
    '; + } else { + qSection.innerHTML = + '
    🎵
    Queue is empty
    Paste a link from YouTube, SoundCloud, or Bandcamp above to get started.
    '; + } + + if (hSection && state.history && state.history.length > 0) { + let html = '
    Played
      '; + for (const item of state.history) { + html += renderTrackItem(item, [linkAction, rotAction], "history-item"); + } + html += "
    "; + hSection.innerHTML = html; + } else if (hSection) { + hSection.innerHTML = ""; + } + } + + function updatePlaylistDisplay(state) { + const container = document.getElementById("playlist-items"); + if (!container) return; + if (!state.playlist || state.playlist.length === 0) { + container.innerHTML = '
    Rotation is empty
    '; + return; + } + const removeAction = { cls: "playlist-remove", label: "\u2715", title: "Remove from Rotation" }; + const linkAction = { cls: "source-link", label: "source", title: "Open source" }; + let html = '
      '; + for (const entry of state.playlist) { + html += renderTrackItem(entry, [linkAction, removeAction]); + } + html += "
    "; + container.innerHTML = html; + } + + document.addEventListener("click", (e) => { + const btn = e.target.closest(".rot-add, .playlist-remove"); + if (!btn) return; + const url = btn.dataset.trackUrl; + const id = btn.dataset.trackId; + if (btn.classList.contains("rot-add") && url) { + e.preventDefault(); + addToRotation(url); + } else if (btn.classList.contains("playlist-remove") && id) { + e.preventDefault(); + removeFromPlaylist(id); + } + }); + + // Send chat + document.getElementById("chat-send").addEventListener("click", sendChat); + document.getElementById("chat-input").addEventListener("keydown", (e) => { + if (e.key === "Enter") sendChat(); + }); + + function sendChat() { + const input = document.getElementById("chat-input"); + const content = input.value.trim(); + if (!content) return; + const payload = new TextEncoder().encode(JSON.stringify({ content })); + const obj = concat([ + encVarint(0x00), + encVarint(2), // track_id = Chat + encVarint(0), // group_id + encVarint(0), // object_id + payload, + ]); + ws.send(obj); + input.value = ""; + } + + async function ingest() { + const input = document.getElementById("url-input"); + const btn = document.getElementById("queue-add-btn"); + if (!input.value.trim()) return; + btn.disabled = true; + btn.textContent = "Adding…"; + try { + const resp = await fetch("/api/ingest", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: input.value, room_id: roomId }), + }); + if (resp.ok) { + input.value = ""; + } else { + const err = await resp.json(); + alert(err.error || "ingest failed"); + } + } finally { + btn.disabled = false; + btn.textContent = "Add"; + } + } + + async function skip() { + await fetch("/api/skip", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ room_id: roomId }), + }); + } + + async function addToPlaylist() { + const input = document.getElementById("playlist-input"); + const btn = document.querySelector("#playlist-section .btn-primary"); + if (!input.value.trim()) return; + btn.disabled = true; + btn.textContent = "Adding…"; + try { + const resp = await fetch("/api/playlist/add", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: input.value, room_id: roomId }), + }); + if (resp.ok) { + input.value = ""; + } else { + const err = await resp.json(); + alert(err.error || "add to playlist failed"); + } + } finally { + btn.disabled = false; + btn.textContent = "Add"; + } + } + + async function removeFromPlaylist(id) { + await fetch("/api/playlist/remove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ room_id: roomId, id }), + }); + } + + async function addToRotation(url) { + await fetch("/api/playlist/add", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, room_id: roomId }), + }); + } + + document.getElementById("playlist-input").addEventListener("keydown", (e) => { + if (e.key === "Enter") addToPlaylist(); + }); + document.getElementById("url-input").addEventListener("keydown", (e) => { + if (e.key === "Enter") ingest(); + }); diff --git a/templates/partials/room_styles.html b/templates/partials/room_styles.html new file mode 100644 --- /dev/null +++ b/templates/partials/room_styles.html @@ -0,0 +1,874 @@ + :root { + --base: #24273a; + --mantle: #1e2030; + --crust: #181926; + --surface0: #363a4f; + --surface1: #494d64; + --surface2: #5b6078; + --overlay0: #6c7086; + --overlay1: #7f849c; + --overlay2: #939ab7; + --subtext0: #a5adcb; + --subtext1: #b8c0e0; + --text: #cad3f5; + --lavender: #b7bdf8; + --blue: #8aadf4; + --sapphire: #7dc4e4; + --sky: #91d7e3; + --teal: #8bd5ca; + --green: #a6da95; + --yellow: #eed49f; + --peach: #f5a97f; + --maroon: #ee99a0; + --red: #ed8796; + --mauve: #c6a0f6; + --pink: #f5bde6; + --flamingo: #f0c6c6; + --rosewater: #f4dbd6; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --font-sans: "Space Grotesk", system-ui, -apple-system, sans-serif; + --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(198, 160, 246, 0.04); + --shadow-card-hover: 0 4px 16px rgba(0, 0, 0, 0.35), 0 1px 4px rgba(198, 160, 246, 0.06); + --shadow-elevated: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.3); + } + *, + *::before, + *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + html, + body { + height: 100%; + } + body { + font-family: var(--font-sans); + background: var(--base); + color: var(--text); + padding: 1rem 1.5rem; + display: flex; + flex-direction: column; + } + + /* Top bar */ + .top-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + gap: 1rem; + } + .top-bar-home { + color: var(--overlay1); + text-decoration: none; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + flex-shrink: 0; + transition: color 150ms ease-out; + } + .top-bar-home:hover { + color: var(--text); + } + .top-bar-center { + flex: 1; + text-align: center; + min-width: 0; + } + .room-name { + display: block; + font-weight: 700; + font-size: 1.125rem; + color: var(--text); + line-height: 1.3; + text-wrap: balance; + } + .room-id { + display: block; + font-size: 0.75rem; + color: var(--overlay0); + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; + } + .top-bar-actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + } + .btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + border: none; + background: var(--surface0); + color: var(--subtext0); + cursor: pointer; + font-size: 1rem; + line-height: 1; + transition: + background 150ms ease-out, + color 150ms ease-out; + } + .btn-icon:hover { + background: var(--surface1); + color: var(--text); + } + .btn-icon:active { + transform: scale(0.92); + } + .viewer-pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.625rem; + border-radius: 999px; + background: var(--surface0); + color: var(--overlay1); + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + + /* Layout */ + .layout { + display: flex; + gap: 1rem; + flex: 1; + min-height: 0; + } + .main-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + } + .chat-col { + width: 320px; + flex-shrink: 0; + display: flex; + flex-direction: column; + min-height: 0; + border-left: 1px solid var(--surface2); + padding-left: 1rem; + } + + /* Video */ + #video-container { + position: relative; + width: 100%; + min-height: 200px; + margin-bottom: 1rem; + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--crust); + box-shadow: var(--shadow-card); + } + #video-player { + width: 100%; + aspect-ratio: 16 / 9; + max-height: 60vh; + object-fit: contain; + display: block; + background: var(--crust); + } + /* Video idle state (no track playing) */ + #video-container.idle { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + } + #video-container.idle #video-player { + display: none; + } + #video-container.idle .idle-msg { + display: block; + } + .idle-msg { + display: none; + color: var(--overlay0); + font-size: 0.9375rem; + text-align: center; + padding: 3rem 1.5rem; + } + .idle-msg .icon { + font-size: 2rem; + display: block; + margin-bottom: 0.5rem; + opacity: 0.5; + } + + /* Track info overlay on video */ + #track-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); + padding: 2rem 1rem 0.75rem; + opacity: 0; + transition: opacity 200ms ease-out; + pointer-events: none; + z-index: 5; + } + #video-container:hover #track-overlay, + #track-overlay.show { + opacity: 1; + pointer-events: auto; + } + .to-row { + display: flex; + align-items: center; + gap: 0.75rem; + } + .to-thumb { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; + outline: 1px solid rgba(0, 0, 0, 0.3); + outline-offset: -1px; + } + .to-info { + flex: 1; + min-width: 0; + } + .to-title { + display: block; + color: #fff; + font-weight: 600; + font-size: 0.9375rem; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .to-meta { + display: block; + color: rgba(255, 255, 255, 0.6); + font-size: 0.75rem; + line-height: 1.4; + } + .to-meta a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; + } + .to-viewers { + font-variant-numeric: tabular-nums; + } + #to-progress-wrap { + width: 100%; + height: 2px; + background: rgba(255, 255, 255, 0.15); + border-radius: 1px; + overflow: hidden; + margin-top: 0.5rem; + } + #to-progress-fill { + height: 100%; + width: 0%; + background: var(--mauve); + border-radius: 1px; + transition: width 1s linear; + } + + .volume-overlay { + position: absolute; + bottom: 0.75rem; + right: 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(0, 0, 0, 0.6); + border-radius: var(--radius-md); + padding: 0.375rem 0.625rem; + opacity: 0; + transition: opacity 150ms ease-out; + pointer-events: none; + z-index: 10; + } + #video-container:hover .volume-overlay { + opacity: 1; + pointer-events: auto; + } + .volume-overlay .icon { + font-size: 1.125rem; + line-height: 1; + color: var(--text); + cursor: pointer; + user-select: none; + } + .volume-overlay input[type="range"] { + width: 80px; + height: 4px; + accent-color: var(--blue); + cursor: pointer; + background: transparent; + } + + /* Input Row */ + .queue-group { + margin-bottom: 0.5rem; + } + .input-row { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + } + .input-row input { + flex: 1; + font: inherit; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + border: none; + background: var(--mantle); + color: var(--text); + font-size: 0.9375rem; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); + transition: box-shadow 150ms ease-out; + } + .input-row input:focus { + outline: none; + box-shadow: + inset 0 2px 4px rgba(0, 0, 0, 0.2), + 0 0 0 2px var(--blue); + } + .input-row input::placeholder { + color: var(--overlay0); + } + + /* Buttons */ + button { + font: inherit; + padding: 0.75rem 1.25rem; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + font-weight: 600; + font-size: 0.9375rem; + transition: + transform 150ms ease-out, + box-shadow 150ms ease-out, + filter 150ms ease-out; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + } + button:active { + transform: scale(0.96); + } + button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + filter: none; + } + button.btn-primary { + background: var(--green); + color: var(--crust); + box-shadow: var(--shadow-card); + } + button.btn-primary:hover { + filter: brightness(1.12); + box-shadow: var(--shadow-card-hover); + } + button.btn-secondary { + background: var(--surface1); + color: var(--text); + box-shadow: var(--shadow-card); + } + button.btn-secondary:hover { + background: var(--surface2); + filter: brightness(1.08); + box-shadow: var(--shadow-card-hover); + } + button.btn-accent { + background: var(--mauve); + color: var(--crust); + box-shadow: var(--shadow-card); + } + button.btn-accent:hover { + filter: brightness(1.12); + box-shadow: var(--shadow-card-hover); + } + + /* Queue */ + .queue { + list-style: none; + } + .queue li { + background: var(--surface0); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + padding: 0.75rem; + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; + transition: + background 150ms ease-out, + box-shadow 150ms ease-out; + animation: queueFadeIn 0.12s ease-out both; + } + .queue li:hover { + background: var(--surface1); + box-shadow: var(--shadow-card-hover); + } + .queue li .thumb, + .queue li .thumb-img { + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + background: var(--mantle); + flex-shrink: 0; + object-fit: cover; + outline: 1px solid var(--surface2); + outline-offset: -1px; + } + .queue li .title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-wrap: pretty; + } + .queue li .duration { + color: var(--subtext0); + font-size: 0.875rem; + flex-shrink: 0; + font-variant-numeric: tabular-nums; + } + + /* Source link */ + .source-link { + color: var(--blue); + text-decoration: none; + font-size: 0.8rem; + padding: 6px 10px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--blue) 15%, transparent); + transition: background 150ms ease-out; + flex-shrink: 0; + min-height: 40px; + display: inline-flex; + align-items: center; + } + .source-link:hover { + background: color-mix(in srgb, var(--blue) 25%, transparent); + } + + .history-item { + opacity: 1; + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem; + margin-bottom: 0.25rem; + background: transparent; + box-shadow: none; + } + .history-item:hover { + background: transparent; + box-shadow: none; + } + .history-item .thumb, + .history-item .thumb-img { + width: 28px; + height: 28px; + } + .history-item .title { + color: var(--overlay1); + font-size: 0.8125rem; + } + .history-item .duration { + color: var(--overlay0); + font-size: 0.75rem; + } + + /* Connection status */ + .conn-status { + font-size: 0.75rem; + color: var(--overlay0); + margin-bottom: 0.5rem; + text-align: right; + transition: color 300ms ease-out; + } + .conn-status.connected { + color: var(--green); + } + .conn-status.disconnected { + color: var(--red); + } + + /* History header */ + .history-header { + margin-top: 1.5rem; + margin-bottom: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--surface2); + font-size: 0.8125rem; + color: var(--overlay0); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + /* Empty state */ + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 3rem 1.5rem; + text-align: center; + color: var(--overlay1); + font-size: 0.875rem; + line-height: 1.5; + background: var(--surface0); + border-radius: var(--radius-lg); + border: 1px dashed var(--surface2); + margin-bottom: 0.5rem; + } + .empty-state .icon { + font-size: 1.75rem; + line-height: 1; + opacity: 0.5; + } + .empty-state .label { + color: var(--subtext0); + font-weight: 500; + } + .empty-state .hint { + color: var(--overlay0); + font-size: 0.8125rem; + max-width: 24em; + } + + /* Chat */ + .chat-messages { + flex: 1; + overflow-y: auto; + min-height: 0; + background: var(--surface0); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 0.5rem; + } + .chat-messages:empty::after { + content: "No messages yet"; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 80px; + color: var(--overlay0); + font-size: 0.875rem; + font-style: italic; + } + .chat-msg { + padding: 6px 10px; + border-left: 3px solid var(--blue); + padding-left: 10px; + font-size: 0.875rem; + line-height: 1.4; + } + .chat-msg .user { + color: var(--blue); + font-weight: 600; + font-size: 0.8rem; + } + .chat-msg .time { + color: var(--overlay0); + font-size: 0.7rem; + float: right; + margin-left: 8px; + } + .chat-msg .text { + color: var(--text); + } + .chat-msg.system { + border-left-color: var(--overlay0); + } + .chat-msg.system .user { + color: var(--overlay0); + } + .chat-msg.system .text { + color: var(--subtext0); + font-style: italic; + } + + /* Chat input */ + .chat-input-row { + display: flex; + gap: 0.5rem; + } + .chat-input-row input { + flex: 1; + font: inherit; + padding: 0.625rem 0.875rem; + border-radius: var(--radius-md); + border: none; + background: var(--mantle); + color: var(--text); + font-size: 0.875rem; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); + transition: box-shadow 150ms ease-out; + } + .chat-input-row input:focus { + outline: none; + box-shadow: + inset 0 2px 4px rgba(0, 0, 0, 0.2), + 0 0 0 2px var(--blue); + } + .chat-input-row input::placeholder { + color: var(--overlay0); + } + .chat-input-row button { + padding: 0.625rem 1rem; + min-height: 40px; + background: var(--mauve); + color: var(--crust); + box-shadow: var(--shadow-card); + } + .chat-input-row button:hover { + filter: brightness(1.12); + box-shadow: var(--shadow-card-hover); + } + + /* Skeleton loading state for pending queue items */ + @keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } + } + .queue li.pending { + position: relative; + } + .queue li.pending .title, + .queue li.pending .duration { + display: inline-block; + border-radius: var(--radius-sm); + background: linear-gradient( + 90deg, + var(--surface1) 25%, + var(--surface2) 37%, + var(--surface1) 63% + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + color: transparent !important; + user-select: none; + } + .queue li.pending .title { + min-width: 16ch; + max-width: 24ch; + } + .queue li.pending .duration { + min-width: 5ch; + } + .queue li.pending .source-link { + visibility: hidden; + } + + /* Stagger animation */ + @keyframes queueFadeIn { + from { + opacity: 0.3; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .queue li:nth-child(1) { + animation-delay: 0s; + } + .queue li:nth-child(2) { + animation-delay: 0.01s; + } + .queue li:nth-child(3) { + animation-delay: 0.02s; + } + .queue li:nth-child(4) { + animation-delay: 0.03s; + } + .queue li:nth-child(5) { + animation-delay: 0.04s; + } + .queue li:nth-child(6) { + animation-delay: 0.05s; + } + .queue li:nth-child(n + 7) { + animation-delay: 0.05s; + } + + /* Playlist */ + .playlist-section { + margin-bottom: 0.5rem; + } + .playlist-section .playlist-header { + margin-top: 1.5rem; + margin-bottom: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--surface2); + font-size: 0.8125rem; + color: var(--overlay0); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .playlist-section .playlist-empty { + color: var(--overlay0); + font-size: 0.8125rem; + text-align: center; + padding: 1rem; + font-style: italic; + } + .playlist-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + margin-bottom: 0.175rem; + transition: background 150ms ease-out; + font-size: 0.875rem; + } + .playlist-item:hover { + background: var(--surface0); + } + .playlist-item .title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--subtext1); + } + .playlist-item .duration { + color: var(--overlay0); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + } + .playlist-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--overlay0); + cursor: pointer; + font-size: 0.875rem; + line-height: 1; + flex-shrink: 0; + transition: color 150ms ease-out, background 150ms ease-out; + } + .playlist-remove:hover { + color: var(--red); + background: color-mix(in srgb, var(--red) 15%, transparent); + } + .rot-add { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--overlay0); + cursor: pointer; + font-size: 0.875rem; + line-height: 1; + flex-shrink: 0; + transition: color 150ms ease-out, background 150ms ease-out; + } + .rot-add:hover { + color: var(--teal); + background: color-mix(in srgb, var(--teal) 15%, transparent); + } + + /* Mobile responsive */ + @media (max-width: 768px) { + html, + body { + height: auto; + } + body { + min-height: 100vh; + } + .layout { + flex-direction: column; + overflow-y: visible; + flex: none; + } + .main-col { + overflow-y: visible; + flex: none; + } + .chat-col { + width: 100%; + padding-left: 0; + border-left: none; + border-top: 1px solid var(--surface2); + padding-top: 1rem; + flex: none; + } + #video-player { + max-height: 40vh; + } + #video-container.idle { + min-height: 120px; + } + .top-bar { + flex-wrap: wrap; + gap: 0.5rem; + } + .top-bar-center { + order: 3; + width: 100%; + text-align: left; + } + /* Touch devices: always show overlays (no hover dependency) */ + @media (hover: none) { + #track-overlay { + opacity: 1; + pointer-events: auto; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); + padding-bottom: 1rem; + } + .volume-overlay { + opacity: 1; + pointer-events: auto; + } + } + } -- tangled.sh