experiments of a tiny cytube-like player with yt-dlp
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: rotation playlist

karitham (May 19, 2026, 11:46 PM +0200) 1741e7be cca54fde

+5574 -1826
+2685
Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "aho-corasick" 7 + version = "1.1.4" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 + dependencies = [ 11 + "memchr", 12 + ] 13 + 14 + [[package]] 15 + name = "allocator-api2" 16 + version = "0.2.21" 17 + source = "registry+https://github.com/rust-lang/crates.io-index" 18 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 19 + 20 + [[package]] 21 + name = "android_system_properties" 22 + version = "0.1.5" 23 + source = "registry+https://github.com/rust-lang/crates.io-index" 24 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 25 + dependencies = [ 26 + "libc", 27 + ] 28 + 29 + [[package]] 30 + name = "anstream" 31 + version = "1.0.0" 32 + source = "registry+https://github.com/rust-lang/crates.io-index" 33 + checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" 34 + dependencies = [ 35 + "anstyle", 36 + "anstyle-parse", 37 + "anstyle-query", 38 + "anstyle-wincon", 39 + "colorchoice", 40 + "is_terminal_polyfill", 41 + "utf8parse", 42 + ] 43 + 44 + [[package]] 45 + name = "anstyle" 46 + version = "1.0.14" 47 + source = "registry+https://github.com/rust-lang/crates.io-index" 48 + checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" 49 + 50 + [[package]] 51 + name = "anstyle-parse" 52 + version = "1.0.0" 53 + source = "registry+https://github.com/rust-lang/crates.io-index" 54 + checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" 55 + dependencies = [ 56 + "utf8parse", 57 + ] 58 + 59 + [[package]] 60 + name = "anstyle-query" 61 + version = "1.1.5" 62 + source = "registry+https://github.com/rust-lang/crates.io-index" 63 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 64 + dependencies = [ 65 + "windows-sys 0.61.2", 66 + ] 67 + 68 + [[package]] 69 + name = "anstyle-wincon" 70 + version = "3.0.11" 71 + source = "registry+https://github.com/rust-lang/crates.io-index" 72 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 73 + dependencies = [ 74 + "anstyle", 75 + "once_cell_polyfill", 76 + "windows-sys 0.61.2", 77 + ] 78 + 79 + [[package]] 80 + name = "anyhow" 81 + version = "1.0.102" 82 + source = "registry+https://github.com/rust-lang/crates.io-index" 83 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 84 + 85 + [[package]] 86 + name = "askama" 87 + version = "0.16.0" 88 + source = "registry+https://github.com/rust-lang/crates.io-index" 89 + checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" 90 + dependencies = [ 91 + "askama_macros", 92 + "itoa", 93 + "percent-encoding", 94 + "serde", 95 + "serde_json", 96 + ] 97 + 98 + [[package]] 99 + name = "askama_derive" 100 + version = "0.16.0" 101 + source = "registry+https://github.com/rust-lang/crates.io-index" 102 + checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" 103 + dependencies = [ 104 + "askama_parser", 105 + "basic-toml", 106 + "glob", 107 + "memchr", 108 + "proc-macro2", 109 + "quote", 110 + "rustc-hash", 111 + "serde", 112 + "serde_derive", 113 + "syn", 114 + ] 115 + 116 + [[package]] 117 + name = "askama_macros" 118 + version = "0.16.0" 119 + source = "registry+https://github.com/rust-lang/crates.io-index" 120 + checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" 121 + dependencies = [ 122 + "askama_derive", 123 + ] 124 + 125 + [[package]] 126 + name = "askama_parser" 127 + version = "0.16.0" 128 + source = "registry+https://github.com/rust-lang/crates.io-index" 129 + checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" 130 + dependencies = [ 131 + "rustc-hash", 132 + "serde", 133 + "serde_derive", 134 + "unicode-ident", 135 + "winnow", 136 + ] 137 + 138 + [[package]] 139 + name = "async-trait" 140 + version = "0.1.89" 141 + source = "registry+https://github.com/rust-lang/crates.io-index" 142 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 143 + dependencies = [ 144 + "proc-macro2", 145 + "quote", 146 + "syn", 147 + ] 148 + 149 + [[package]] 150 + name = "atoi" 151 + version = "2.0.0" 152 + source = "registry+https://github.com/rust-lang/crates.io-index" 153 + checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" 154 + dependencies = [ 155 + "num-traits", 156 + ] 157 + 158 + [[package]] 159 + name = "atomic-waker" 160 + version = "1.1.2" 161 + source = "registry+https://github.com/rust-lang/crates.io-index" 162 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 163 + 164 + [[package]] 165 + name = "autocfg" 166 + version = "1.5.0" 167 + source = "registry+https://github.com/rust-lang/crates.io-index" 168 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 169 + 170 + [[package]] 171 + name = "axum" 172 + version = "0.8.9" 173 + source = "registry+https://github.com/rust-lang/crates.io-index" 174 + checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" 175 + dependencies = [ 176 + "axum-core", 177 + "base64", 178 + "bytes", 179 + "form_urlencoded", 180 + "futures-util", 181 + "http", 182 + "http-body", 183 + "http-body-util", 184 + "hyper", 185 + "hyper-util", 186 + "itoa", 187 + "matchit", 188 + "memchr", 189 + "mime", 190 + "percent-encoding", 191 + "pin-project-lite", 192 + "serde_core", 193 + "serde_json", 194 + "serde_path_to_error", 195 + "serde_urlencoded", 196 + "sha1", 197 + "sync_wrapper", 198 + "tokio", 199 + "tokio-tungstenite", 200 + "tower", 201 + "tower-layer", 202 + "tower-service", 203 + "tracing", 204 + ] 205 + 206 + [[package]] 207 + name = "axum-core" 208 + version = "0.5.6" 209 + source = "registry+https://github.com/rust-lang/crates.io-index" 210 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" 211 + dependencies = [ 212 + "bytes", 213 + "futures-core", 214 + "http", 215 + "http-body", 216 + "http-body-util", 217 + "mime", 218 + "pin-project-lite", 219 + "sync_wrapper", 220 + "tower-layer", 221 + "tower-service", 222 + "tracing", 223 + ] 224 + 225 + [[package]] 226 + name = "base64" 227 + version = "0.22.1" 228 + source = "registry+https://github.com/rust-lang/crates.io-index" 229 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 230 + 231 + [[package]] 232 + name = "base64ct" 233 + version = "1.8.3" 234 + source = "registry+https://github.com/rust-lang/crates.io-index" 235 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" 236 + 237 + [[package]] 238 + name = "basic-toml" 239 + version = "0.1.10" 240 + source = "registry+https://github.com/rust-lang/crates.io-index" 241 + checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" 242 + dependencies = [ 243 + "serde", 244 + ] 245 + 246 + [[package]] 247 + name = "bitflags" 248 + version = "2.11.1" 249 + source = "registry+https://github.com/rust-lang/crates.io-index" 250 + checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" 251 + dependencies = [ 252 + "serde_core", 253 + ] 254 + 255 + [[package]] 256 + name = "block-buffer" 257 + version = "0.10.4" 258 + source = "registry+https://github.com/rust-lang/crates.io-index" 259 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 260 + dependencies = [ 261 + "generic-array", 262 + ] 263 + 264 + [[package]] 265 + name = "bumpalo" 266 + version = "3.20.2" 267 + source = "registry+https://github.com/rust-lang/crates.io-index" 268 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 269 + 270 + [[package]] 271 + name = "byteorder" 272 + version = "1.5.0" 273 + source = "registry+https://github.com/rust-lang/crates.io-index" 274 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 275 + 276 + [[package]] 277 + name = "bytes" 278 + version = "1.11.1" 279 + source = "registry+https://github.com/rust-lang/crates.io-index" 280 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" 281 + 282 + [[package]] 283 + name = "cc" 284 + version = "1.2.62" 285 + source = "registry+https://github.com/rust-lang/crates.io-index" 286 + checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" 287 + dependencies = [ 288 + "find-msvc-tools", 289 + "shlex", 290 + ] 291 + 292 + [[package]] 293 + name = "cfg-if" 294 + version = "1.0.4" 295 + source = "registry+https://github.com/rust-lang/crates.io-index" 296 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 297 + 298 + [[package]] 299 + name = "chrono" 300 + version = "0.4.44" 301 + source = "registry+https://github.com/rust-lang/crates.io-index" 302 + checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" 303 + dependencies = [ 304 + "iana-time-zone", 305 + "js-sys", 306 + "num-traits", 307 + "serde", 308 + "wasm-bindgen", 309 + "windows-link", 310 + ] 311 + 312 + [[package]] 313 + name = "clap" 314 + version = "4.6.1" 315 + source = "registry+https://github.com/rust-lang/crates.io-index" 316 + checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" 317 + dependencies = [ 318 + "clap_builder", 319 + "clap_derive", 320 + ] 321 + 322 + [[package]] 323 + name = "clap_builder" 324 + version = "4.6.0" 325 + source = "registry+https://github.com/rust-lang/crates.io-index" 326 + checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" 327 + dependencies = [ 328 + "anstream", 329 + "anstyle", 330 + "clap_lex", 331 + "strsim", 332 + ] 333 + 334 + [[package]] 335 + name = "clap_derive" 336 + version = "4.6.1" 337 + source = "registry+https://github.com/rust-lang/crates.io-index" 338 + checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" 339 + dependencies = [ 340 + "heck", 341 + "proc-macro2", 342 + "quote", 343 + "syn", 344 + ] 345 + 346 + [[package]] 347 + name = "clap_lex" 348 + version = "1.1.0" 349 + source = "registry+https://github.com/rust-lang/crates.io-index" 350 + checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 351 + 352 + [[package]] 353 + name = "colorchoice" 354 + version = "1.0.5" 355 + source = "registry+https://github.com/rust-lang/crates.io-index" 356 + checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" 357 + 358 + [[package]] 359 + name = "concurrent-queue" 360 + version = "2.5.0" 361 + source = "registry+https://github.com/rust-lang/crates.io-index" 362 + checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" 363 + dependencies = [ 364 + "crossbeam-utils", 365 + ] 366 + 367 + [[package]] 368 + name = "const-oid" 369 + version = "0.9.6" 370 + source = "registry+https://github.com/rust-lang/crates.io-index" 371 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 372 + 373 + [[package]] 374 + name = "core-foundation-sys" 375 + version = "0.8.7" 376 + source = "registry+https://github.com/rust-lang/crates.io-index" 377 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 378 + 379 + [[package]] 380 + name = "cpufeatures" 381 + version = "0.2.17" 382 + source = "registry+https://github.com/rust-lang/crates.io-index" 383 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 384 + dependencies = [ 385 + "libc", 386 + ] 387 + 388 + [[package]] 389 + name = "crc" 390 + version = "3.4.0" 391 + source = "registry+https://github.com/rust-lang/crates.io-index" 392 + checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" 393 + dependencies = [ 394 + "crc-catalog", 395 + ] 396 + 397 + [[package]] 398 + name = "crc-catalog" 399 + version = "2.5.0" 400 + source = "registry+https://github.com/rust-lang/crates.io-index" 401 + checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" 402 + 403 + [[package]] 404 + name = "crossbeam-queue" 405 + version = "0.3.12" 406 + source = "registry+https://github.com/rust-lang/crates.io-index" 407 + checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" 408 + dependencies = [ 409 + "crossbeam-utils", 410 + ] 411 + 412 + [[package]] 413 + name = "crossbeam-utils" 414 + version = "0.8.21" 415 + source = "registry+https://github.com/rust-lang/crates.io-index" 416 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 417 + 418 + [[package]] 419 + name = "crypto-common" 420 + version = "0.1.7" 421 + source = "registry+https://github.com/rust-lang/crates.io-index" 422 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" 423 + dependencies = [ 424 + "generic-array", 425 + "typenum", 426 + ] 427 + 428 + [[package]] 429 + name = "data-encoding" 430 + version = "2.11.0" 431 + source = "registry+https://github.com/rust-lang/crates.io-index" 432 + checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" 433 + 434 + [[package]] 435 + name = "der" 436 + version = "0.7.10" 437 + source = "registry+https://github.com/rust-lang/crates.io-index" 438 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 439 + dependencies = [ 440 + "const-oid", 441 + "pem-rfc7468", 442 + "zeroize", 443 + ] 444 + 445 + [[package]] 446 + name = "digest" 447 + version = "0.10.7" 448 + source = "registry+https://github.com/rust-lang/crates.io-index" 449 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 450 + dependencies = [ 451 + "block-buffer", 452 + "const-oid", 453 + "crypto-common", 454 + "subtle", 455 + ] 456 + 457 + [[package]] 458 + name = "displaydoc" 459 + version = "0.2.5" 460 + source = "registry+https://github.com/rust-lang/crates.io-index" 461 + checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 462 + dependencies = [ 463 + "proc-macro2", 464 + "quote", 465 + "syn", 466 + ] 467 + 468 + [[package]] 469 + name = "dotenvy" 470 + version = "0.15.7" 471 + source = "registry+https://github.com/rust-lang/crates.io-index" 472 + checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" 473 + 474 + [[package]] 475 + name = "either" 476 + version = "1.15.0" 477 + source = "registry+https://github.com/rust-lang/crates.io-index" 478 + checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 479 + dependencies = [ 480 + "serde", 481 + ] 482 + 483 + [[package]] 484 + name = "equivalent" 485 + version = "1.0.2" 486 + source = "registry+https://github.com/rust-lang/crates.io-index" 487 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 488 + 489 + [[package]] 490 + name = "errno" 491 + version = "0.3.14" 492 + source = "registry+https://github.com/rust-lang/crates.io-index" 493 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 494 + dependencies = [ 495 + "libc", 496 + "windows-sys 0.61.2", 497 + ] 498 + 499 + [[package]] 500 + name = "etcetera" 501 + version = "0.8.0" 502 + source = "registry+https://github.com/rust-lang/crates.io-index" 503 + checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" 504 + dependencies = [ 505 + "cfg-if", 506 + "home", 507 + "windows-sys 0.48.0", 508 + ] 509 + 510 + [[package]] 511 + name = "event-listener" 512 + version = "5.4.1" 513 + source = "registry+https://github.com/rust-lang/crates.io-index" 514 + checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" 515 + dependencies = [ 516 + "concurrent-queue", 517 + "parking", 518 + "pin-project-lite", 519 + ] 520 + 521 + [[package]] 522 + name = "find-msvc-tools" 523 + version = "0.1.9" 524 + source = "registry+https://github.com/rust-lang/crates.io-index" 525 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 526 + 527 + [[package]] 528 + name = "flume" 529 + version = "0.11.1" 530 + source = "registry+https://github.com/rust-lang/crates.io-index" 531 + checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" 532 + dependencies = [ 533 + "futures-core", 534 + "futures-sink", 535 + "spin", 536 + ] 537 + 538 + [[package]] 539 + name = "foldhash" 540 + version = "0.1.5" 541 + source = "registry+https://github.com/rust-lang/crates.io-index" 542 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 543 + 544 + [[package]] 545 + name = "form_urlencoded" 546 + version = "1.2.2" 547 + source = "registry+https://github.com/rust-lang/crates.io-index" 548 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 549 + dependencies = [ 550 + "percent-encoding", 551 + ] 552 + 553 + [[package]] 554 + name = "futures-channel" 555 + version = "0.3.32" 556 + source = "registry+https://github.com/rust-lang/crates.io-index" 557 + checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 558 + dependencies = [ 559 + "futures-core", 560 + "futures-sink", 561 + ] 562 + 563 + [[package]] 564 + name = "futures-core" 565 + version = "0.3.32" 566 + source = "registry+https://github.com/rust-lang/crates.io-index" 567 + checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 568 + 569 + [[package]] 570 + name = "futures-executor" 571 + version = "0.3.32" 572 + source = "registry+https://github.com/rust-lang/crates.io-index" 573 + checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" 574 + dependencies = [ 575 + "futures-core", 576 + "futures-task", 577 + "futures-util", 578 + ] 579 + 580 + [[package]] 581 + name = "futures-intrusive" 582 + version = "0.5.0" 583 + source = "registry+https://github.com/rust-lang/crates.io-index" 584 + checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" 585 + dependencies = [ 586 + "futures-core", 587 + "lock_api", 588 + "parking_lot", 589 + ] 590 + 591 + [[package]] 592 + name = "futures-io" 593 + version = "0.3.32" 594 + source = "registry+https://github.com/rust-lang/crates.io-index" 595 + checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" 596 + 597 + [[package]] 598 + name = "futures-macro" 599 + version = "0.3.32" 600 + source = "registry+https://github.com/rust-lang/crates.io-index" 601 + checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" 602 + dependencies = [ 603 + "proc-macro2", 604 + "quote", 605 + "syn", 606 + ] 607 + 608 + [[package]] 609 + name = "futures-sink" 610 + version = "0.3.32" 611 + source = "registry+https://github.com/rust-lang/crates.io-index" 612 + checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" 613 + 614 + [[package]] 615 + name = "futures-task" 616 + version = "0.3.32" 617 + source = "registry+https://github.com/rust-lang/crates.io-index" 618 + checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" 619 + 620 + [[package]] 621 + name = "futures-util" 622 + version = "0.3.32" 623 + source = "registry+https://github.com/rust-lang/crates.io-index" 624 + checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 625 + dependencies = [ 626 + "futures-core", 627 + "futures-io", 628 + "futures-macro", 629 + "futures-sink", 630 + "futures-task", 631 + "memchr", 632 + "pin-project-lite", 633 + "slab", 634 + ] 635 + 636 + [[package]] 637 + name = "generic-array" 638 + version = "0.14.7" 639 + source = "registry+https://github.com/rust-lang/crates.io-index" 640 + checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 641 + dependencies = [ 642 + "typenum", 643 + "version_check", 644 + ] 645 + 646 + [[package]] 647 + name = "getrandom" 648 + version = "0.2.17" 649 + source = "registry+https://github.com/rust-lang/crates.io-index" 650 + checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" 651 + dependencies = [ 652 + "cfg-if", 653 + "libc", 654 + "wasi", 655 + ] 656 + 657 + [[package]] 658 + name = "getrandom" 659 + version = "0.3.4" 660 + source = "registry+https://github.com/rust-lang/crates.io-index" 661 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 662 + dependencies = [ 663 + "cfg-if", 664 + "libc", 665 + "r-efi 5.3.0", 666 + "wasip2", 667 + ] 668 + 669 + [[package]] 670 + name = "getrandom" 671 + version = "0.4.2" 672 + source = "registry+https://github.com/rust-lang/crates.io-index" 673 + checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" 674 + dependencies = [ 675 + "cfg-if", 676 + "libc", 677 + "r-efi 6.0.0", 678 + "wasip2", 679 + "wasip3", 680 + ] 681 + 682 + [[package]] 683 + name = "glob" 684 + version = "0.3.3" 685 + source = "registry+https://github.com/rust-lang/crates.io-index" 686 + checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 687 + 688 + [[package]] 689 + name = "hashbrown" 690 + version = "0.15.5" 691 + source = "registry+https://github.com/rust-lang/crates.io-index" 692 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 693 + dependencies = [ 694 + "allocator-api2", 695 + "equivalent", 696 + "foldhash", 697 + ] 698 + 699 + [[package]] 700 + name = "hashbrown" 701 + version = "0.17.1" 702 + source = "registry+https://github.com/rust-lang/crates.io-index" 703 + checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 704 + 705 + [[package]] 706 + name = "hashlink" 707 + version = "0.10.0" 708 + source = "registry+https://github.com/rust-lang/crates.io-index" 709 + checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" 710 + dependencies = [ 711 + "hashbrown 0.15.5", 712 + ] 713 + 714 + [[package]] 715 + name = "heck" 716 + version = "0.5.0" 717 + source = "registry+https://github.com/rust-lang/crates.io-index" 718 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 719 + 720 + [[package]] 721 + name = "hex" 722 + version = "0.4.3" 723 + source = "registry+https://github.com/rust-lang/crates.io-index" 724 + checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 725 + 726 + [[package]] 727 + name = "hkdf" 728 + version = "0.12.4" 729 + source = "registry+https://github.com/rust-lang/crates.io-index" 730 + checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" 731 + dependencies = [ 732 + "hmac", 733 + ] 734 + 735 + [[package]] 736 + name = "hmac" 737 + version = "0.12.1" 738 + source = "registry+https://github.com/rust-lang/crates.io-index" 739 + checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 740 + dependencies = [ 741 + "digest", 742 + ] 743 + 744 + [[package]] 745 + name = "home" 746 + version = "0.5.12" 747 + source = "registry+https://github.com/rust-lang/crates.io-index" 748 + checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" 749 + dependencies = [ 750 + "windows-sys 0.61.2", 751 + ] 752 + 753 + [[package]] 754 + name = "http" 755 + version = "1.4.0" 756 + source = "registry+https://github.com/rust-lang/crates.io-index" 757 + checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" 758 + dependencies = [ 759 + "bytes", 760 + "itoa", 761 + ] 762 + 763 + [[package]] 764 + name = "http-body" 765 + version = "1.0.1" 766 + source = "registry+https://github.com/rust-lang/crates.io-index" 767 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 768 + dependencies = [ 769 + "bytes", 770 + "http", 771 + ] 772 + 773 + [[package]] 774 + name = "http-body-util" 775 + version = "0.1.3" 776 + source = "registry+https://github.com/rust-lang/crates.io-index" 777 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 778 + dependencies = [ 779 + "bytes", 780 + "futures-core", 781 + "http", 782 + "http-body", 783 + "pin-project-lite", 784 + ] 785 + 786 + [[package]] 787 + name = "httparse" 788 + version = "1.10.1" 789 + source = "registry+https://github.com/rust-lang/crates.io-index" 790 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 791 + 792 + [[package]] 793 + name = "httpdate" 794 + version = "1.0.3" 795 + source = "registry+https://github.com/rust-lang/crates.io-index" 796 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 797 + 798 + [[package]] 799 + name = "hyper" 800 + version = "1.9.0" 801 + source = "registry+https://github.com/rust-lang/crates.io-index" 802 + checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" 803 + dependencies = [ 804 + "atomic-waker", 805 + "bytes", 806 + "futures-channel", 807 + "futures-core", 808 + "http", 809 + "http-body", 810 + "httparse", 811 + "httpdate", 812 + "itoa", 813 + "pin-project-lite", 814 + "smallvec", 815 + "tokio", 816 + ] 817 + 818 + [[package]] 819 + name = "hyper-util" 820 + version = "0.1.20" 821 + source = "registry+https://github.com/rust-lang/crates.io-index" 822 + checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" 823 + dependencies = [ 824 + "bytes", 825 + "http", 826 + "http-body", 827 + "hyper", 828 + "pin-project-lite", 829 + "tokio", 830 + "tower-service", 831 + ] 832 + 833 + [[package]] 834 + name = "iana-time-zone" 835 + version = "0.1.65" 836 + source = "registry+https://github.com/rust-lang/crates.io-index" 837 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" 838 + dependencies = [ 839 + "android_system_properties", 840 + "core-foundation-sys", 841 + "iana-time-zone-haiku", 842 + "js-sys", 843 + "log", 844 + "wasm-bindgen", 845 + "windows-core", 846 + ] 847 + 848 + [[package]] 849 + name = "iana-time-zone-haiku" 850 + version = "0.1.2" 851 + source = "registry+https://github.com/rust-lang/crates.io-index" 852 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 853 + dependencies = [ 854 + "cc", 855 + ] 856 + 857 + [[package]] 858 + name = "icu_collections" 859 + version = "2.2.0" 860 + source = "registry+https://github.com/rust-lang/crates.io-index" 861 + checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" 862 + dependencies = [ 863 + "displaydoc", 864 + "potential_utf", 865 + "utf8_iter", 866 + "yoke", 867 + "zerofrom", 868 + "zerovec", 869 + ] 870 + 871 + [[package]] 872 + name = "icu_locale_core" 873 + version = "2.2.0" 874 + source = "registry+https://github.com/rust-lang/crates.io-index" 875 + checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" 876 + dependencies = [ 877 + "displaydoc", 878 + "litemap", 879 + "tinystr", 880 + "writeable", 881 + "zerovec", 882 + ] 883 + 884 + [[package]] 885 + name = "icu_normalizer" 886 + version = "2.2.0" 887 + source = "registry+https://github.com/rust-lang/crates.io-index" 888 + checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" 889 + dependencies = [ 890 + "icu_collections", 891 + "icu_normalizer_data", 892 + "icu_properties", 893 + "icu_provider", 894 + "smallvec", 895 + "zerovec", 896 + ] 897 + 898 + [[package]] 899 + name = "icu_normalizer_data" 900 + version = "2.2.0" 901 + source = "registry+https://github.com/rust-lang/crates.io-index" 902 + checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" 903 + 904 + [[package]] 905 + name = "icu_properties" 906 + version = "2.2.0" 907 + source = "registry+https://github.com/rust-lang/crates.io-index" 908 + checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" 909 + dependencies = [ 910 + "icu_collections", 911 + "icu_locale_core", 912 + "icu_properties_data", 913 + "icu_provider", 914 + "zerotrie", 915 + "zerovec", 916 + ] 917 + 918 + [[package]] 919 + name = "icu_properties_data" 920 + version = "2.2.0" 921 + source = "registry+https://github.com/rust-lang/crates.io-index" 922 + checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" 923 + 924 + [[package]] 925 + name = "icu_provider" 926 + version = "2.2.0" 927 + source = "registry+https://github.com/rust-lang/crates.io-index" 928 + checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" 929 + dependencies = [ 930 + "displaydoc", 931 + "icu_locale_core", 932 + "writeable", 933 + "yoke", 934 + "zerofrom", 935 + "zerotrie", 936 + "zerovec", 937 + ] 938 + 939 + [[package]] 940 + name = "id-arena" 941 + version = "2.3.0" 942 + source = "registry+https://github.com/rust-lang/crates.io-index" 943 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" 944 + 945 + [[package]] 946 + name = "idna" 947 + version = "1.1.0" 948 + source = "registry+https://github.com/rust-lang/crates.io-index" 949 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 950 + dependencies = [ 951 + "idna_adapter", 952 + "smallvec", 953 + "utf8_iter", 954 + ] 955 + 956 + [[package]] 957 + name = "idna_adapter" 958 + version = "1.2.2" 959 + source = "registry+https://github.com/rust-lang/crates.io-index" 960 + checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" 961 + dependencies = [ 962 + "icu_normalizer", 963 + "icu_properties", 964 + ] 965 + 966 + [[package]] 967 + name = "indexmap" 968 + version = "2.14.0" 969 + source = "registry+https://github.com/rust-lang/crates.io-index" 970 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" 971 + dependencies = [ 972 + "equivalent", 973 + "hashbrown 0.17.1", 974 + "serde", 975 + "serde_core", 976 + ] 977 + 978 + [[package]] 979 + name = "is_terminal_polyfill" 980 + version = "1.70.2" 981 + source = "registry+https://github.com/rust-lang/crates.io-index" 982 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 983 + 984 + [[package]] 985 + name = "itoa" 986 + version = "1.0.18" 987 + source = "registry+https://github.com/rust-lang/crates.io-index" 988 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 989 + 990 + [[package]] 991 + name = "js-sys" 992 + version = "0.3.98" 993 + source = "registry+https://github.com/rust-lang/crates.io-index" 994 + checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" 995 + dependencies = [ 996 + "cfg-if", 997 + "futures-util", 998 + "once_cell", 999 + "wasm-bindgen", 1000 + ] 1001 + 1002 + [[package]] 1003 + name = "lazy_static" 1004 + version = "1.5.0" 1005 + source = "registry+https://github.com/rust-lang/crates.io-index" 1006 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1007 + dependencies = [ 1008 + "spin", 1009 + ] 1010 + 1011 + [[package]] 1012 + name = "leb128fmt" 1013 + version = "0.1.0" 1014 + source = "registry+https://github.com/rust-lang/crates.io-index" 1015 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 1016 + 1017 + [[package]] 1018 + name = "libc" 1019 + version = "0.2.186" 1020 + source = "registry+https://github.com/rust-lang/crates.io-index" 1021 + checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" 1022 + 1023 + [[package]] 1024 + name = "libm" 1025 + version = "0.2.16" 1026 + source = "registry+https://github.com/rust-lang/crates.io-index" 1027 + checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" 1028 + 1029 + [[package]] 1030 + name = "libredox" 1031 + version = "0.1.16" 1032 + source = "registry+https://github.com/rust-lang/crates.io-index" 1033 + checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" 1034 + dependencies = [ 1035 + "bitflags", 1036 + "libc", 1037 + "plain", 1038 + "redox_syscall 0.7.5", 1039 + ] 1040 + 1041 + [[package]] 1042 + name = "libsqlite3-sys" 1043 + version = "0.30.1" 1044 + source = "registry+https://github.com/rust-lang/crates.io-index" 1045 + checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" 1046 + dependencies = [ 1047 + "cc", 1048 + "pkg-config", 1049 + "vcpkg", 1050 + ] 1051 + 1052 + [[package]] 1053 + name = "litemap" 1054 + version = "0.8.2" 1055 + source = "registry+https://github.com/rust-lang/crates.io-index" 1056 + checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" 1057 + 1058 + [[package]] 1059 + name = "lock_api" 1060 + version = "0.4.14" 1061 + source = "registry+https://github.com/rust-lang/crates.io-index" 1062 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 1063 + dependencies = [ 1064 + "scopeguard", 1065 + ] 1066 + 1067 + [[package]] 1068 + name = "log" 1069 + version = "0.4.29" 1070 + source = "registry+https://github.com/rust-lang/crates.io-index" 1071 + checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 1072 + 1073 + [[package]] 1074 + name = "matchers" 1075 + version = "0.2.0" 1076 + source = "registry+https://github.com/rust-lang/crates.io-index" 1077 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 1078 + dependencies = [ 1079 + "regex-automata", 1080 + ] 1081 + 1082 + [[package]] 1083 + name = "matchit" 1084 + version = "0.8.4" 1085 + source = "registry+https://github.com/rust-lang/crates.io-index" 1086 + checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 1087 + 1088 + [[package]] 1089 + name = "md-5" 1090 + version = "0.10.6" 1091 + source = "registry+https://github.com/rust-lang/crates.io-index" 1092 + checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" 1093 + dependencies = [ 1094 + "cfg-if", 1095 + "digest", 1096 + ] 1097 + 1098 + [[package]] 1099 + name = "memchr" 1100 + version = "2.8.0" 1101 + source = "registry+https://github.com/rust-lang/crates.io-index" 1102 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 1103 + 1104 + [[package]] 1105 + name = "mime" 1106 + version = "0.3.17" 1107 + source = "registry+https://github.com/rust-lang/crates.io-index" 1108 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1109 + 1110 + [[package]] 1111 + name = "mio" 1112 + version = "1.2.0" 1113 + source = "registry+https://github.com/rust-lang/crates.io-index" 1114 + checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" 1115 + dependencies = [ 1116 + "libc", 1117 + "wasi", 1118 + "windows-sys 0.61.2", 1119 + ] 1120 + 1121 + [[package]] 1122 + name = "moqbox" 1123 + version = "0.1.0" 1124 + dependencies = [ 1125 + "anyhow", 1126 + "askama", 1127 + "async-trait", 1128 + "axum", 1129 + "bytes", 1130 + "chrono", 1131 + "clap", 1132 + "futures-util", 1133 + "serde", 1134 + "serde_json", 1135 + "sqlx", 1136 + "thiserror", 1137 + "tokio", 1138 + "tower", 1139 + "tracing", 1140 + "tracing-subscriber", 1141 + "uuid", 1142 + ] 1143 + 1144 + [[package]] 1145 + name = "nu-ansi-term" 1146 + version = "0.50.3" 1147 + source = "registry+https://github.com/rust-lang/crates.io-index" 1148 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 1149 + dependencies = [ 1150 + "windows-sys 0.61.2", 1151 + ] 1152 + 1153 + [[package]] 1154 + name = "num-bigint-dig" 1155 + version = "0.8.6" 1156 + source = "registry+https://github.com/rust-lang/crates.io-index" 1157 + checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" 1158 + dependencies = [ 1159 + "lazy_static", 1160 + "libm", 1161 + "num-integer", 1162 + "num-iter", 1163 + "num-traits", 1164 + "rand 0.8.6", 1165 + "smallvec", 1166 + "zeroize", 1167 + ] 1168 + 1169 + [[package]] 1170 + name = "num-integer" 1171 + version = "0.1.46" 1172 + source = "registry+https://github.com/rust-lang/crates.io-index" 1173 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 1174 + dependencies = [ 1175 + "num-traits", 1176 + ] 1177 + 1178 + [[package]] 1179 + name = "num-iter" 1180 + version = "0.1.45" 1181 + source = "registry+https://github.com/rust-lang/crates.io-index" 1182 + checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" 1183 + dependencies = [ 1184 + "autocfg", 1185 + "num-integer", 1186 + "num-traits", 1187 + ] 1188 + 1189 + [[package]] 1190 + name = "num-traits" 1191 + version = "0.2.19" 1192 + source = "registry+https://github.com/rust-lang/crates.io-index" 1193 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1194 + dependencies = [ 1195 + "autocfg", 1196 + "libm", 1197 + ] 1198 + 1199 + [[package]] 1200 + name = "once_cell" 1201 + version = "1.21.4" 1202 + source = "registry+https://github.com/rust-lang/crates.io-index" 1203 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 1204 + 1205 + [[package]] 1206 + name = "once_cell_polyfill" 1207 + version = "1.70.2" 1208 + source = "registry+https://github.com/rust-lang/crates.io-index" 1209 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 1210 + 1211 + [[package]] 1212 + name = "parking" 1213 + version = "2.2.1" 1214 + source = "registry+https://github.com/rust-lang/crates.io-index" 1215 + checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 1216 + 1217 + [[package]] 1218 + name = "parking_lot" 1219 + version = "0.12.5" 1220 + source = "registry+https://github.com/rust-lang/crates.io-index" 1221 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 1222 + dependencies = [ 1223 + "lock_api", 1224 + "parking_lot_core", 1225 + ] 1226 + 1227 + [[package]] 1228 + name = "parking_lot_core" 1229 + version = "0.9.12" 1230 + source = "registry+https://github.com/rust-lang/crates.io-index" 1231 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 1232 + dependencies = [ 1233 + "cfg-if", 1234 + "libc", 1235 + "redox_syscall 0.5.18", 1236 + "smallvec", 1237 + "windows-link", 1238 + ] 1239 + 1240 + [[package]] 1241 + name = "pem-rfc7468" 1242 + version = "0.7.0" 1243 + source = "registry+https://github.com/rust-lang/crates.io-index" 1244 + checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" 1245 + dependencies = [ 1246 + "base64ct", 1247 + ] 1248 + 1249 + [[package]] 1250 + name = "percent-encoding" 1251 + version = "2.3.2" 1252 + source = "registry+https://github.com/rust-lang/crates.io-index" 1253 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 1254 + 1255 + [[package]] 1256 + name = "pin-project-lite" 1257 + version = "0.2.17" 1258 + source = "registry+https://github.com/rust-lang/crates.io-index" 1259 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 1260 + 1261 + [[package]] 1262 + name = "pkcs1" 1263 + version = "0.7.5" 1264 + source = "registry+https://github.com/rust-lang/crates.io-index" 1265 + checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" 1266 + dependencies = [ 1267 + "der", 1268 + "pkcs8", 1269 + "spki", 1270 + ] 1271 + 1272 + [[package]] 1273 + name = "pkcs8" 1274 + version = "0.10.2" 1275 + source = "registry+https://github.com/rust-lang/crates.io-index" 1276 + checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" 1277 + dependencies = [ 1278 + "der", 1279 + "spki", 1280 + ] 1281 + 1282 + [[package]] 1283 + name = "pkg-config" 1284 + version = "0.3.33" 1285 + source = "registry+https://github.com/rust-lang/crates.io-index" 1286 + checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" 1287 + 1288 + [[package]] 1289 + name = "plain" 1290 + version = "0.2.3" 1291 + source = "registry+https://github.com/rust-lang/crates.io-index" 1292 + checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" 1293 + 1294 + [[package]] 1295 + name = "potential_utf" 1296 + version = "0.1.5" 1297 + source = "registry+https://github.com/rust-lang/crates.io-index" 1298 + checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" 1299 + dependencies = [ 1300 + "zerovec", 1301 + ] 1302 + 1303 + [[package]] 1304 + name = "ppv-lite86" 1305 + version = "0.2.21" 1306 + source = "registry+https://github.com/rust-lang/crates.io-index" 1307 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 1308 + dependencies = [ 1309 + "zerocopy", 1310 + ] 1311 + 1312 + [[package]] 1313 + name = "prettyplease" 1314 + version = "0.2.37" 1315 + source = "registry+https://github.com/rust-lang/crates.io-index" 1316 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 1317 + dependencies = [ 1318 + "proc-macro2", 1319 + "syn", 1320 + ] 1321 + 1322 + [[package]] 1323 + name = "proc-macro2" 1324 + version = "1.0.106" 1325 + source = "registry+https://github.com/rust-lang/crates.io-index" 1326 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 1327 + dependencies = [ 1328 + "unicode-ident", 1329 + ] 1330 + 1331 + [[package]] 1332 + name = "quote" 1333 + version = "1.0.45" 1334 + source = "registry+https://github.com/rust-lang/crates.io-index" 1335 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 1336 + dependencies = [ 1337 + "proc-macro2", 1338 + ] 1339 + 1340 + [[package]] 1341 + name = "r-efi" 1342 + version = "5.3.0" 1343 + source = "registry+https://github.com/rust-lang/crates.io-index" 1344 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 1345 + 1346 + [[package]] 1347 + name = "r-efi" 1348 + version = "6.0.0" 1349 + source = "registry+https://github.com/rust-lang/crates.io-index" 1350 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 1351 + 1352 + [[package]] 1353 + name = "rand" 1354 + version = "0.8.6" 1355 + source = "registry+https://github.com/rust-lang/crates.io-index" 1356 + checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" 1357 + dependencies = [ 1358 + "libc", 1359 + "rand_chacha 0.3.1", 1360 + "rand_core 0.6.4", 1361 + ] 1362 + 1363 + [[package]] 1364 + name = "rand" 1365 + version = "0.9.4" 1366 + source = "registry+https://github.com/rust-lang/crates.io-index" 1367 + checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" 1368 + dependencies = [ 1369 + "rand_chacha 0.9.0", 1370 + "rand_core 0.9.5", 1371 + ] 1372 + 1373 + [[package]] 1374 + name = "rand_chacha" 1375 + version = "0.3.1" 1376 + source = "registry+https://github.com/rust-lang/crates.io-index" 1377 + checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1378 + dependencies = [ 1379 + "ppv-lite86", 1380 + "rand_core 0.6.4", 1381 + ] 1382 + 1383 + [[package]] 1384 + name = "rand_chacha" 1385 + version = "0.9.0" 1386 + source = "registry+https://github.com/rust-lang/crates.io-index" 1387 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1388 + dependencies = [ 1389 + "ppv-lite86", 1390 + "rand_core 0.9.5", 1391 + ] 1392 + 1393 + [[package]] 1394 + name = "rand_core" 1395 + version = "0.6.4" 1396 + source = "registry+https://github.com/rust-lang/crates.io-index" 1397 + checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1398 + dependencies = [ 1399 + "getrandom 0.2.17", 1400 + ] 1401 + 1402 + [[package]] 1403 + name = "rand_core" 1404 + version = "0.9.5" 1405 + source = "registry+https://github.com/rust-lang/crates.io-index" 1406 + checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" 1407 + dependencies = [ 1408 + "getrandom 0.3.4", 1409 + ] 1410 + 1411 + [[package]] 1412 + name = "redox_syscall" 1413 + version = "0.5.18" 1414 + source = "registry+https://github.com/rust-lang/crates.io-index" 1415 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 1416 + dependencies = [ 1417 + "bitflags", 1418 + ] 1419 + 1420 + [[package]] 1421 + name = "redox_syscall" 1422 + version = "0.7.5" 1423 + source = "registry+https://github.com/rust-lang/crates.io-index" 1424 + checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" 1425 + dependencies = [ 1426 + "bitflags", 1427 + ] 1428 + 1429 + [[package]] 1430 + name = "regex-automata" 1431 + version = "0.4.14" 1432 + source = "registry+https://github.com/rust-lang/crates.io-index" 1433 + checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" 1434 + dependencies = [ 1435 + "aho-corasick", 1436 + "memchr", 1437 + "regex-syntax", 1438 + ] 1439 + 1440 + [[package]] 1441 + name = "regex-syntax" 1442 + version = "0.8.10" 1443 + source = "registry+https://github.com/rust-lang/crates.io-index" 1444 + checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" 1445 + 1446 + [[package]] 1447 + name = "rsa" 1448 + version = "0.9.10" 1449 + source = "registry+https://github.com/rust-lang/crates.io-index" 1450 + checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" 1451 + dependencies = [ 1452 + "const-oid", 1453 + "digest", 1454 + "num-bigint-dig", 1455 + "num-integer", 1456 + "num-traits", 1457 + "pkcs1", 1458 + "pkcs8", 1459 + "rand_core 0.6.4", 1460 + "signature", 1461 + "spki", 1462 + "subtle", 1463 + "zeroize", 1464 + ] 1465 + 1466 + [[package]] 1467 + name = "rustc-hash" 1468 + version = "2.1.2" 1469 + source = "registry+https://github.com/rust-lang/crates.io-index" 1470 + checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" 1471 + 1472 + [[package]] 1473 + name = "rustversion" 1474 + version = "1.0.22" 1475 + source = "registry+https://github.com/rust-lang/crates.io-index" 1476 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 1477 + 1478 + [[package]] 1479 + name = "ryu" 1480 + version = "1.0.23" 1481 + source = "registry+https://github.com/rust-lang/crates.io-index" 1482 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 1483 + 1484 + [[package]] 1485 + name = "scopeguard" 1486 + version = "1.2.0" 1487 + source = "registry+https://github.com/rust-lang/crates.io-index" 1488 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1489 + 1490 + [[package]] 1491 + name = "semver" 1492 + version = "1.0.28" 1493 + source = "registry+https://github.com/rust-lang/crates.io-index" 1494 + checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" 1495 + 1496 + [[package]] 1497 + name = "serde" 1498 + version = "1.0.228" 1499 + source = "registry+https://github.com/rust-lang/crates.io-index" 1500 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 1501 + dependencies = [ 1502 + "serde_core", 1503 + "serde_derive", 1504 + ] 1505 + 1506 + [[package]] 1507 + name = "serde_core" 1508 + version = "1.0.228" 1509 + source = "registry+https://github.com/rust-lang/crates.io-index" 1510 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 1511 + dependencies = [ 1512 + "serde_derive", 1513 + ] 1514 + 1515 + [[package]] 1516 + name = "serde_derive" 1517 + version = "1.0.228" 1518 + source = "registry+https://github.com/rust-lang/crates.io-index" 1519 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 1520 + dependencies = [ 1521 + "proc-macro2", 1522 + "quote", 1523 + "syn", 1524 + ] 1525 + 1526 + [[package]] 1527 + name = "serde_json" 1528 + version = "1.0.149" 1529 + source = "registry+https://github.com/rust-lang/crates.io-index" 1530 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 1531 + dependencies = [ 1532 + "itoa", 1533 + "memchr", 1534 + "serde", 1535 + "serde_core", 1536 + "zmij", 1537 + ] 1538 + 1539 + [[package]] 1540 + name = "serde_path_to_error" 1541 + version = "0.1.20" 1542 + source = "registry+https://github.com/rust-lang/crates.io-index" 1543 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 1544 + dependencies = [ 1545 + "itoa", 1546 + "serde", 1547 + "serde_core", 1548 + ] 1549 + 1550 + [[package]] 1551 + name = "serde_urlencoded" 1552 + version = "0.7.1" 1553 + source = "registry+https://github.com/rust-lang/crates.io-index" 1554 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1555 + dependencies = [ 1556 + "form_urlencoded", 1557 + "itoa", 1558 + "ryu", 1559 + "serde", 1560 + ] 1561 + 1562 + [[package]] 1563 + name = "sha1" 1564 + version = "0.10.6" 1565 + source = "registry+https://github.com/rust-lang/crates.io-index" 1566 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 1567 + dependencies = [ 1568 + "cfg-if", 1569 + "cpufeatures", 1570 + "digest", 1571 + ] 1572 + 1573 + [[package]] 1574 + name = "sha2" 1575 + version = "0.10.9" 1576 + source = "registry+https://github.com/rust-lang/crates.io-index" 1577 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 1578 + dependencies = [ 1579 + "cfg-if", 1580 + "cpufeatures", 1581 + "digest", 1582 + ] 1583 + 1584 + [[package]] 1585 + name = "sharded-slab" 1586 + version = "0.1.7" 1587 + source = "registry+https://github.com/rust-lang/crates.io-index" 1588 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 1589 + dependencies = [ 1590 + "lazy_static", 1591 + ] 1592 + 1593 + [[package]] 1594 + name = "shlex" 1595 + version = "1.3.0" 1596 + source = "registry+https://github.com/rust-lang/crates.io-index" 1597 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1598 + 1599 + [[package]] 1600 + name = "signal-hook-registry" 1601 + version = "1.4.8" 1602 + source = "registry+https://github.com/rust-lang/crates.io-index" 1603 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 1604 + dependencies = [ 1605 + "errno", 1606 + "libc", 1607 + ] 1608 + 1609 + [[package]] 1610 + name = "signature" 1611 + version = "2.2.0" 1612 + source = "registry+https://github.com/rust-lang/crates.io-index" 1613 + checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 1614 + dependencies = [ 1615 + "digest", 1616 + "rand_core 0.6.4", 1617 + ] 1618 + 1619 + [[package]] 1620 + name = "slab" 1621 + version = "0.4.12" 1622 + source = "registry+https://github.com/rust-lang/crates.io-index" 1623 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 1624 + 1625 + [[package]] 1626 + name = "smallvec" 1627 + version = "1.15.1" 1628 + source = "registry+https://github.com/rust-lang/crates.io-index" 1629 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 1630 + dependencies = [ 1631 + "serde", 1632 + ] 1633 + 1634 + [[package]] 1635 + name = "socket2" 1636 + version = "0.6.3" 1637 + source = "registry+https://github.com/rust-lang/crates.io-index" 1638 + checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" 1639 + dependencies = [ 1640 + "libc", 1641 + "windows-sys 0.61.2", 1642 + ] 1643 + 1644 + [[package]] 1645 + name = "spin" 1646 + version = "0.9.8" 1647 + source = "registry+https://github.com/rust-lang/crates.io-index" 1648 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 1649 + dependencies = [ 1650 + "lock_api", 1651 + ] 1652 + 1653 + [[package]] 1654 + name = "spki" 1655 + version = "0.7.3" 1656 + source = "registry+https://github.com/rust-lang/crates.io-index" 1657 + checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 1658 + dependencies = [ 1659 + "base64ct", 1660 + "der", 1661 + ] 1662 + 1663 + [[package]] 1664 + name = "sqlx" 1665 + version = "0.8.6" 1666 + source = "registry+https://github.com/rust-lang/crates.io-index" 1667 + checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" 1668 + dependencies = [ 1669 + "sqlx-core", 1670 + "sqlx-macros", 1671 + "sqlx-mysql", 1672 + "sqlx-postgres", 1673 + "sqlx-sqlite", 1674 + ] 1675 + 1676 + [[package]] 1677 + name = "sqlx-core" 1678 + version = "0.8.6" 1679 + source = "registry+https://github.com/rust-lang/crates.io-index" 1680 + checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" 1681 + dependencies = [ 1682 + "base64", 1683 + "bytes", 1684 + "chrono", 1685 + "crc", 1686 + "crossbeam-queue", 1687 + "either", 1688 + "event-listener", 1689 + "futures-core", 1690 + "futures-intrusive", 1691 + "futures-io", 1692 + "futures-util", 1693 + "hashbrown 0.15.5", 1694 + "hashlink", 1695 + "indexmap", 1696 + "log", 1697 + "memchr", 1698 + "once_cell", 1699 + "percent-encoding", 1700 + "serde", 1701 + "serde_json", 1702 + "sha2", 1703 + "smallvec", 1704 + "thiserror", 1705 + "tokio", 1706 + "tokio-stream", 1707 + "tracing", 1708 + "url", 1709 + ] 1710 + 1711 + [[package]] 1712 + name = "sqlx-macros" 1713 + version = "0.8.6" 1714 + source = "registry+https://github.com/rust-lang/crates.io-index" 1715 + checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" 1716 + dependencies = [ 1717 + "proc-macro2", 1718 + "quote", 1719 + "sqlx-core", 1720 + "sqlx-macros-core", 1721 + "syn", 1722 + ] 1723 + 1724 + [[package]] 1725 + name = "sqlx-macros-core" 1726 + version = "0.8.6" 1727 + source = "registry+https://github.com/rust-lang/crates.io-index" 1728 + checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" 1729 + dependencies = [ 1730 + "dotenvy", 1731 + "either", 1732 + "heck", 1733 + "hex", 1734 + "once_cell", 1735 + "proc-macro2", 1736 + "quote", 1737 + "serde", 1738 + "serde_json", 1739 + "sha2", 1740 + "sqlx-core", 1741 + "sqlx-mysql", 1742 + "sqlx-postgres", 1743 + "sqlx-sqlite", 1744 + "syn", 1745 + "tokio", 1746 + "url", 1747 + ] 1748 + 1749 + [[package]] 1750 + name = "sqlx-mysql" 1751 + version = "0.8.6" 1752 + source = "registry+https://github.com/rust-lang/crates.io-index" 1753 + checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" 1754 + dependencies = [ 1755 + "atoi", 1756 + "base64", 1757 + "bitflags", 1758 + "byteorder", 1759 + "bytes", 1760 + "chrono", 1761 + "crc", 1762 + "digest", 1763 + "dotenvy", 1764 + "either", 1765 + "futures-channel", 1766 + "futures-core", 1767 + "futures-io", 1768 + "futures-util", 1769 + "generic-array", 1770 + "hex", 1771 + "hkdf", 1772 + "hmac", 1773 + "itoa", 1774 + "log", 1775 + "md-5", 1776 + "memchr", 1777 + "once_cell", 1778 + "percent-encoding", 1779 + "rand 0.8.6", 1780 + "rsa", 1781 + "serde", 1782 + "sha1", 1783 + "sha2", 1784 + "smallvec", 1785 + "sqlx-core", 1786 + "stringprep", 1787 + "thiserror", 1788 + "tracing", 1789 + "whoami", 1790 + ] 1791 + 1792 + [[package]] 1793 + name = "sqlx-postgres" 1794 + version = "0.8.6" 1795 + source = "registry+https://github.com/rust-lang/crates.io-index" 1796 + checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" 1797 + dependencies = [ 1798 + "atoi", 1799 + "base64", 1800 + "bitflags", 1801 + "byteorder", 1802 + "chrono", 1803 + "crc", 1804 + "dotenvy", 1805 + "etcetera", 1806 + "futures-channel", 1807 + "futures-core", 1808 + "futures-util", 1809 + "hex", 1810 + "hkdf", 1811 + "hmac", 1812 + "home", 1813 + "itoa", 1814 + "log", 1815 + "md-5", 1816 + "memchr", 1817 + "once_cell", 1818 + "rand 0.8.6", 1819 + "serde", 1820 + "serde_json", 1821 + "sha2", 1822 + "smallvec", 1823 + "sqlx-core", 1824 + "stringprep", 1825 + "thiserror", 1826 + "tracing", 1827 + "whoami", 1828 + ] 1829 + 1830 + [[package]] 1831 + name = "sqlx-sqlite" 1832 + version = "0.8.6" 1833 + source = "registry+https://github.com/rust-lang/crates.io-index" 1834 + checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" 1835 + dependencies = [ 1836 + "atoi", 1837 + "chrono", 1838 + "flume", 1839 + "futures-channel", 1840 + "futures-core", 1841 + "futures-executor", 1842 + "futures-intrusive", 1843 + "futures-util", 1844 + "libsqlite3-sys", 1845 + "log", 1846 + "percent-encoding", 1847 + "serde", 1848 + "serde_urlencoded", 1849 + "sqlx-core", 1850 + "thiserror", 1851 + "tracing", 1852 + "url", 1853 + ] 1854 + 1855 + [[package]] 1856 + name = "stable_deref_trait" 1857 + version = "1.2.1" 1858 + source = "registry+https://github.com/rust-lang/crates.io-index" 1859 + checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 1860 + 1861 + [[package]] 1862 + name = "stringprep" 1863 + version = "0.1.5" 1864 + source = "registry+https://github.com/rust-lang/crates.io-index" 1865 + checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" 1866 + dependencies = [ 1867 + "unicode-bidi", 1868 + "unicode-normalization", 1869 + "unicode-properties", 1870 + ] 1871 + 1872 + [[package]] 1873 + name = "strsim" 1874 + version = "0.11.1" 1875 + source = "registry+https://github.com/rust-lang/crates.io-index" 1876 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1877 + 1878 + [[package]] 1879 + name = "subtle" 1880 + version = "2.6.1" 1881 + source = "registry+https://github.com/rust-lang/crates.io-index" 1882 + checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 1883 + 1884 + [[package]] 1885 + name = "syn" 1886 + version = "2.0.117" 1887 + source = "registry+https://github.com/rust-lang/crates.io-index" 1888 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 1889 + dependencies = [ 1890 + "proc-macro2", 1891 + "quote", 1892 + "unicode-ident", 1893 + ] 1894 + 1895 + [[package]] 1896 + name = "sync_wrapper" 1897 + version = "1.0.2" 1898 + source = "registry+https://github.com/rust-lang/crates.io-index" 1899 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 1900 + 1901 + [[package]] 1902 + name = "synstructure" 1903 + version = "0.13.2" 1904 + source = "registry+https://github.com/rust-lang/crates.io-index" 1905 + checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 1906 + dependencies = [ 1907 + "proc-macro2", 1908 + "quote", 1909 + "syn", 1910 + ] 1911 + 1912 + [[package]] 1913 + name = "thiserror" 1914 + version = "2.0.18" 1915 + source = "registry+https://github.com/rust-lang/crates.io-index" 1916 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 1917 + dependencies = [ 1918 + "thiserror-impl", 1919 + ] 1920 + 1921 + [[package]] 1922 + name = "thiserror-impl" 1923 + version = "2.0.18" 1924 + source = "registry+https://github.com/rust-lang/crates.io-index" 1925 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 1926 + dependencies = [ 1927 + "proc-macro2", 1928 + "quote", 1929 + "syn", 1930 + ] 1931 + 1932 + [[package]] 1933 + name = "thread_local" 1934 + version = "1.1.9" 1935 + source = "registry+https://github.com/rust-lang/crates.io-index" 1936 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 1937 + dependencies = [ 1938 + "cfg-if", 1939 + ] 1940 + 1941 + [[package]] 1942 + name = "tinystr" 1943 + version = "0.8.3" 1944 + source = "registry+https://github.com/rust-lang/crates.io-index" 1945 + checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" 1946 + dependencies = [ 1947 + "displaydoc", 1948 + "zerovec", 1949 + ] 1950 + 1951 + [[package]] 1952 + name = "tinyvec" 1953 + version = "1.11.0" 1954 + source = "registry+https://github.com/rust-lang/crates.io-index" 1955 + checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" 1956 + dependencies = [ 1957 + "tinyvec_macros", 1958 + ] 1959 + 1960 + [[package]] 1961 + name = "tinyvec_macros" 1962 + version = "0.1.1" 1963 + source = "registry+https://github.com/rust-lang/crates.io-index" 1964 + checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 1965 + 1966 + [[package]] 1967 + name = "tokio" 1968 + version = "1.52.3" 1969 + source = "registry+https://github.com/rust-lang/crates.io-index" 1970 + checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" 1971 + dependencies = [ 1972 + "bytes", 1973 + "libc", 1974 + "mio", 1975 + "parking_lot", 1976 + "pin-project-lite", 1977 + "signal-hook-registry", 1978 + "socket2", 1979 + "tokio-macros", 1980 + "windows-sys 0.61.2", 1981 + ] 1982 + 1983 + [[package]] 1984 + name = "tokio-macros" 1985 + version = "2.7.0" 1986 + source = "registry+https://github.com/rust-lang/crates.io-index" 1987 + checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" 1988 + dependencies = [ 1989 + "proc-macro2", 1990 + "quote", 1991 + "syn", 1992 + ] 1993 + 1994 + [[package]] 1995 + name = "tokio-stream" 1996 + version = "0.1.18" 1997 + source = "registry+https://github.com/rust-lang/crates.io-index" 1998 + checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" 1999 + dependencies = [ 2000 + "futures-core", 2001 + "pin-project-lite", 2002 + "tokio", 2003 + ] 2004 + 2005 + [[package]] 2006 + name = "tokio-tungstenite" 2007 + version = "0.29.0" 2008 + source = "registry+https://github.com/rust-lang/crates.io-index" 2009 + checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" 2010 + dependencies = [ 2011 + "futures-util", 2012 + "log", 2013 + "tokio", 2014 + "tungstenite", 2015 + ] 2016 + 2017 + [[package]] 2018 + name = "tower" 2019 + version = "0.5.3" 2020 + source = "registry+https://github.com/rust-lang/crates.io-index" 2021 + checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" 2022 + dependencies = [ 2023 + "futures-core", 2024 + "futures-util", 2025 + "pin-project-lite", 2026 + "sync_wrapper", 2027 + "tokio", 2028 + "tower-layer", 2029 + "tower-service", 2030 + "tracing", 2031 + ] 2032 + 2033 + [[package]] 2034 + name = "tower-layer" 2035 + version = "0.3.3" 2036 + source = "registry+https://github.com/rust-lang/crates.io-index" 2037 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 2038 + 2039 + [[package]] 2040 + name = "tower-service" 2041 + version = "0.3.3" 2042 + source = "registry+https://github.com/rust-lang/crates.io-index" 2043 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 2044 + 2045 + [[package]] 2046 + name = "tracing" 2047 + version = "0.1.44" 2048 + source = "registry+https://github.com/rust-lang/crates.io-index" 2049 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 2050 + dependencies = [ 2051 + "log", 2052 + "pin-project-lite", 2053 + "tracing-attributes", 2054 + "tracing-core", 2055 + ] 2056 + 2057 + [[package]] 2058 + name = "tracing-attributes" 2059 + version = "0.1.31" 2060 + source = "registry+https://github.com/rust-lang/crates.io-index" 2061 + checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" 2062 + dependencies = [ 2063 + "proc-macro2", 2064 + "quote", 2065 + "syn", 2066 + ] 2067 + 2068 + [[package]] 2069 + name = "tracing-core" 2070 + version = "0.1.36" 2071 + source = "registry+https://github.com/rust-lang/crates.io-index" 2072 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 2073 + dependencies = [ 2074 + "once_cell", 2075 + "valuable", 2076 + ] 2077 + 2078 + [[package]] 2079 + name = "tracing-log" 2080 + version = "0.2.0" 2081 + source = "registry+https://github.com/rust-lang/crates.io-index" 2082 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 2083 + dependencies = [ 2084 + "log", 2085 + "once_cell", 2086 + "tracing-core", 2087 + ] 2088 + 2089 + [[package]] 2090 + name = "tracing-subscriber" 2091 + version = "0.3.23" 2092 + source = "registry+https://github.com/rust-lang/crates.io-index" 2093 + checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" 2094 + dependencies = [ 2095 + "matchers", 2096 + "nu-ansi-term", 2097 + "once_cell", 2098 + "regex-automata", 2099 + "sharded-slab", 2100 + "smallvec", 2101 + "thread_local", 2102 + "tracing", 2103 + "tracing-core", 2104 + "tracing-log", 2105 + ] 2106 + 2107 + [[package]] 2108 + name = "tungstenite" 2109 + version = "0.29.0" 2110 + source = "registry+https://github.com/rust-lang/crates.io-index" 2111 + checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" 2112 + dependencies = [ 2113 + "bytes", 2114 + "data-encoding", 2115 + "http", 2116 + "httparse", 2117 + "log", 2118 + "rand 0.9.4", 2119 + "sha1", 2120 + "thiserror", 2121 + ] 2122 + 2123 + [[package]] 2124 + name = "typenum" 2125 + version = "1.20.0" 2126 + source = "registry+https://github.com/rust-lang/crates.io-index" 2127 + checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" 2128 + 2129 + [[package]] 2130 + name = "unicode-bidi" 2131 + version = "0.3.18" 2132 + source = "registry+https://github.com/rust-lang/crates.io-index" 2133 + checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" 2134 + 2135 + [[package]] 2136 + name = "unicode-ident" 2137 + version = "1.0.24" 2138 + source = "registry+https://github.com/rust-lang/crates.io-index" 2139 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 2140 + 2141 + [[package]] 2142 + name = "unicode-normalization" 2143 + version = "0.1.25" 2144 + source = "registry+https://github.com/rust-lang/crates.io-index" 2145 + checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" 2146 + dependencies = [ 2147 + "tinyvec", 2148 + ] 2149 + 2150 + [[package]] 2151 + name = "unicode-properties" 2152 + version = "0.1.4" 2153 + source = "registry+https://github.com/rust-lang/crates.io-index" 2154 + checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" 2155 + 2156 + [[package]] 2157 + name = "unicode-xid" 2158 + version = "0.2.6" 2159 + source = "registry+https://github.com/rust-lang/crates.io-index" 2160 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 2161 + 2162 + [[package]] 2163 + name = "url" 2164 + version = "2.5.8" 2165 + source = "registry+https://github.com/rust-lang/crates.io-index" 2166 + checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" 2167 + dependencies = [ 2168 + "form_urlencoded", 2169 + "idna", 2170 + "percent-encoding", 2171 + "serde", 2172 + ] 2173 + 2174 + [[package]] 2175 + name = "utf8_iter" 2176 + version = "1.0.4" 2177 + source = "registry+https://github.com/rust-lang/crates.io-index" 2178 + checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 2179 + 2180 + [[package]] 2181 + name = "utf8parse" 2182 + version = "0.2.2" 2183 + source = "registry+https://github.com/rust-lang/crates.io-index" 2184 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 2185 + 2186 + [[package]] 2187 + name = "uuid" 2188 + version = "1.23.1" 2189 + source = "registry+https://github.com/rust-lang/crates.io-index" 2190 + checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" 2191 + dependencies = [ 2192 + "getrandom 0.4.2", 2193 + "js-sys", 2194 + "serde_core", 2195 + "wasm-bindgen", 2196 + ] 2197 + 2198 + [[package]] 2199 + name = "valuable" 2200 + version = "0.1.1" 2201 + source = "registry+https://github.com/rust-lang/crates.io-index" 2202 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 2203 + 2204 + [[package]] 2205 + name = "vcpkg" 2206 + version = "0.2.15" 2207 + source = "registry+https://github.com/rust-lang/crates.io-index" 2208 + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 2209 + 2210 + [[package]] 2211 + name = "version_check" 2212 + version = "0.9.5" 2213 + source = "registry+https://github.com/rust-lang/crates.io-index" 2214 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2215 + 2216 + [[package]] 2217 + name = "wasi" 2218 + version = "0.11.1+wasi-snapshot-preview1" 2219 + source = "registry+https://github.com/rust-lang/crates.io-index" 2220 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 2221 + 2222 + [[package]] 2223 + name = "wasip2" 2224 + version = "1.0.3+wasi-0.2.9" 2225 + source = "registry+https://github.com/rust-lang/crates.io-index" 2226 + checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" 2227 + dependencies = [ 2228 + "wit-bindgen 0.57.1", 2229 + ] 2230 + 2231 + [[package]] 2232 + name = "wasip3" 2233 + version = "0.4.0+wasi-0.3.0-rc-2026-01-06" 2234 + source = "registry+https://github.com/rust-lang/crates.io-index" 2235 + checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" 2236 + dependencies = [ 2237 + "wit-bindgen 0.51.0", 2238 + ] 2239 + 2240 + [[package]] 2241 + name = "wasite" 2242 + version = "0.1.0" 2243 + source = "registry+https://github.com/rust-lang/crates.io-index" 2244 + checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" 2245 + 2246 + [[package]] 2247 + name = "wasm-bindgen" 2248 + version = "0.2.121" 2249 + source = "registry+https://github.com/rust-lang/crates.io-index" 2250 + checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" 2251 + dependencies = [ 2252 + "cfg-if", 2253 + "once_cell", 2254 + "rustversion", 2255 + "wasm-bindgen-macro", 2256 + "wasm-bindgen-shared", 2257 + ] 2258 + 2259 + [[package]] 2260 + name = "wasm-bindgen-macro" 2261 + version = "0.2.121" 2262 + source = "registry+https://github.com/rust-lang/crates.io-index" 2263 + checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" 2264 + dependencies = [ 2265 + "quote", 2266 + "wasm-bindgen-macro-support", 2267 + ] 2268 + 2269 + [[package]] 2270 + name = "wasm-bindgen-macro-support" 2271 + version = "0.2.121" 2272 + source = "registry+https://github.com/rust-lang/crates.io-index" 2273 + checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" 2274 + dependencies = [ 2275 + "bumpalo", 2276 + "proc-macro2", 2277 + "quote", 2278 + "syn", 2279 + "wasm-bindgen-shared", 2280 + ] 2281 + 2282 + [[package]] 2283 + name = "wasm-bindgen-shared" 2284 + version = "0.2.121" 2285 + source = "registry+https://github.com/rust-lang/crates.io-index" 2286 + checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" 2287 + dependencies = [ 2288 + "unicode-ident", 2289 + ] 2290 + 2291 + [[package]] 2292 + name = "wasm-encoder" 2293 + version = "0.244.0" 2294 + source = "registry+https://github.com/rust-lang/crates.io-index" 2295 + checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" 2296 + dependencies = [ 2297 + "leb128fmt", 2298 + "wasmparser", 2299 + ] 2300 + 2301 + [[package]] 2302 + name = "wasm-metadata" 2303 + version = "0.244.0" 2304 + source = "registry+https://github.com/rust-lang/crates.io-index" 2305 + checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" 2306 + dependencies = [ 2307 + "anyhow", 2308 + "indexmap", 2309 + "wasm-encoder", 2310 + "wasmparser", 2311 + ] 2312 + 2313 + [[package]] 2314 + name = "wasmparser" 2315 + version = "0.244.0" 2316 + source = "registry+https://github.com/rust-lang/crates.io-index" 2317 + checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 2318 + dependencies = [ 2319 + "bitflags", 2320 + "hashbrown 0.15.5", 2321 + "indexmap", 2322 + "semver", 2323 + ] 2324 + 2325 + [[package]] 2326 + name = "whoami" 2327 + version = "1.6.1" 2328 + source = "registry+https://github.com/rust-lang/crates.io-index" 2329 + checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" 2330 + dependencies = [ 2331 + "libredox", 2332 + "wasite", 2333 + ] 2334 + 2335 + [[package]] 2336 + name = "windows-core" 2337 + version = "0.62.2" 2338 + source = "registry+https://github.com/rust-lang/crates.io-index" 2339 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 2340 + dependencies = [ 2341 + "windows-implement", 2342 + "windows-interface", 2343 + "windows-link", 2344 + "windows-result", 2345 + "windows-strings", 2346 + ] 2347 + 2348 + [[package]] 2349 + name = "windows-implement" 2350 + version = "0.60.2" 2351 + source = "registry+https://github.com/rust-lang/crates.io-index" 2352 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 2353 + dependencies = [ 2354 + "proc-macro2", 2355 + "quote", 2356 + "syn", 2357 + ] 2358 + 2359 + [[package]] 2360 + name = "windows-interface" 2361 + version = "0.59.3" 2362 + source = "registry+https://github.com/rust-lang/crates.io-index" 2363 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 2364 + dependencies = [ 2365 + "proc-macro2", 2366 + "quote", 2367 + "syn", 2368 + ] 2369 + 2370 + [[package]] 2371 + name = "windows-link" 2372 + version = "0.2.1" 2373 + source = "registry+https://github.com/rust-lang/crates.io-index" 2374 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 2375 + 2376 + [[package]] 2377 + name = "windows-result" 2378 + version = "0.4.1" 2379 + source = "registry+https://github.com/rust-lang/crates.io-index" 2380 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 2381 + dependencies = [ 2382 + "windows-link", 2383 + ] 2384 + 2385 + [[package]] 2386 + name = "windows-strings" 2387 + version = "0.5.1" 2388 + source = "registry+https://github.com/rust-lang/crates.io-index" 2389 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 2390 + dependencies = [ 2391 + "windows-link", 2392 + ] 2393 + 2394 + [[package]] 2395 + name = "windows-sys" 2396 + version = "0.48.0" 2397 + source = "registry+https://github.com/rust-lang/crates.io-index" 2398 + checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 2399 + dependencies = [ 2400 + "windows-targets", 2401 + ] 2402 + 2403 + [[package]] 2404 + name = "windows-sys" 2405 + version = "0.61.2" 2406 + source = "registry+https://github.com/rust-lang/crates.io-index" 2407 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 2408 + dependencies = [ 2409 + "windows-link", 2410 + ] 2411 + 2412 + [[package]] 2413 + name = "windows-targets" 2414 + version = "0.48.5" 2415 + source = "registry+https://github.com/rust-lang/crates.io-index" 2416 + checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 2417 + dependencies = [ 2418 + "windows_aarch64_gnullvm", 2419 + "windows_aarch64_msvc", 2420 + "windows_i686_gnu", 2421 + "windows_i686_msvc", 2422 + "windows_x86_64_gnu", 2423 + "windows_x86_64_gnullvm", 2424 + "windows_x86_64_msvc", 2425 + ] 2426 + 2427 + [[package]] 2428 + name = "windows_aarch64_gnullvm" 2429 + version = "0.48.5" 2430 + source = "registry+https://github.com/rust-lang/crates.io-index" 2431 + checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 2432 + 2433 + [[package]] 2434 + name = "windows_aarch64_msvc" 2435 + version = "0.48.5" 2436 + source = "registry+https://github.com/rust-lang/crates.io-index" 2437 + checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 2438 + 2439 + [[package]] 2440 + name = "windows_i686_gnu" 2441 + version = "0.48.5" 2442 + source = "registry+https://github.com/rust-lang/crates.io-index" 2443 + checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 2444 + 2445 + [[package]] 2446 + name = "windows_i686_msvc" 2447 + version = "0.48.5" 2448 + source = "registry+https://github.com/rust-lang/crates.io-index" 2449 + checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 2450 + 2451 + [[package]] 2452 + name = "windows_x86_64_gnu" 2453 + version = "0.48.5" 2454 + source = "registry+https://github.com/rust-lang/crates.io-index" 2455 + checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 2456 + 2457 + [[package]] 2458 + name = "windows_x86_64_gnullvm" 2459 + version = "0.48.5" 2460 + source = "registry+https://github.com/rust-lang/crates.io-index" 2461 + checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 2462 + 2463 + [[package]] 2464 + name = "windows_x86_64_msvc" 2465 + version = "0.48.5" 2466 + source = "registry+https://github.com/rust-lang/crates.io-index" 2467 + checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 2468 + 2469 + [[package]] 2470 + name = "winnow" 2471 + version = "1.0.3" 2472 + source = "registry+https://github.com/rust-lang/crates.io-index" 2473 + checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" 2474 + dependencies = [ 2475 + "memchr", 2476 + ] 2477 + 2478 + [[package]] 2479 + name = "wit-bindgen" 2480 + version = "0.51.0" 2481 + source = "registry+https://github.com/rust-lang/crates.io-index" 2482 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 2483 + dependencies = [ 2484 + "wit-bindgen-rust-macro", 2485 + ] 2486 + 2487 + [[package]] 2488 + name = "wit-bindgen" 2489 + version = "0.57.1" 2490 + source = "registry+https://github.com/rust-lang/crates.io-index" 2491 + checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" 2492 + 2493 + [[package]] 2494 + name = "wit-bindgen-core" 2495 + version = "0.51.0" 2496 + source = "registry+https://github.com/rust-lang/crates.io-index" 2497 + checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" 2498 + dependencies = [ 2499 + "anyhow", 2500 + "heck", 2501 + "wit-parser", 2502 + ] 2503 + 2504 + [[package]] 2505 + name = "wit-bindgen-rust" 2506 + version = "0.51.0" 2507 + source = "registry+https://github.com/rust-lang/crates.io-index" 2508 + checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" 2509 + dependencies = [ 2510 + "anyhow", 2511 + "heck", 2512 + "indexmap", 2513 + "prettyplease", 2514 + "syn", 2515 + "wasm-metadata", 2516 + "wit-bindgen-core", 2517 + "wit-component", 2518 + ] 2519 + 2520 + [[package]] 2521 + name = "wit-bindgen-rust-macro" 2522 + version = "0.51.0" 2523 + source = "registry+https://github.com/rust-lang/crates.io-index" 2524 + checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" 2525 + dependencies = [ 2526 + "anyhow", 2527 + "prettyplease", 2528 + "proc-macro2", 2529 + "quote", 2530 + "syn", 2531 + "wit-bindgen-core", 2532 + "wit-bindgen-rust", 2533 + ] 2534 + 2535 + [[package]] 2536 + name = "wit-component" 2537 + version = "0.244.0" 2538 + source = "registry+https://github.com/rust-lang/crates.io-index" 2539 + checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 2540 + dependencies = [ 2541 + "anyhow", 2542 + "bitflags", 2543 + "indexmap", 2544 + "log", 2545 + "serde", 2546 + "serde_derive", 2547 + "serde_json", 2548 + "wasm-encoder", 2549 + "wasm-metadata", 2550 + "wasmparser", 2551 + "wit-parser", 2552 + ] 2553 + 2554 + [[package]] 2555 + name = "wit-parser" 2556 + version = "0.244.0" 2557 + source = "registry+https://github.com/rust-lang/crates.io-index" 2558 + checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" 2559 + dependencies = [ 2560 + "anyhow", 2561 + "id-arena", 2562 + "indexmap", 2563 + "log", 2564 + "semver", 2565 + "serde", 2566 + "serde_derive", 2567 + "serde_json", 2568 + "unicode-xid", 2569 + "wasmparser", 2570 + ] 2571 + 2572 + [[package]] 2573 + name = "writeable" 2574 + version = "0.6.3" 2575 + source = "registry+https://github.com/rust-lang/crates.io-index" 2576 + checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" 2577 + 2578 + [[package]] 2579 + name = "yoke" 2580 + version = "0.8.2" 2581 + source = "registry+https://github.com/rust-lang/crates.io-index" 2582 + checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" 2583 + dependencies = [ 2584 + "stable_deref_trait", 2585 + "yoke-derive", 2586 + "zerofrom", 2587 + ] 2588 + 2589 + [[package]] 2590 + name = "yoke-derive" 2591 + version = "0.8.2" 2592 + source = "registry+https://github.com/rust-lang/crates.io-index" 2593 + checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" 2594 + dependencies = [ 2595 + "proc-macro2", 2596 + "quote", 2597 + "syn", 2598 + "synstructure", 2599 + ] 2600 + 2601 + [[package]] 2602 + name = "zerocopy" 2603 + version = "0.8.48" 2604 + source = "registry+https://github.com/rust-lang/crates.io-index" 2605 + checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" 2606 + dependencies = [ 2607 + "zerocopy-derive", 2608 + ] 2609 + 2610 + [[package]] 2611 + name = "zerocopy-derive" 2612 + version = "0.8.48" 2613 + source = "registry+https://github.com/rust-lang/crates.io-index" 2614 + checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" 2615 + dependencies = [ 2616 + "proc-macro2", 2617 + "quote", 2618 + "syn", 2619 + ] 2620 + 2621 + [[package]] 2622 + name = "zerofrom" 2623 + version = "0.1.8" 2624 + source = "registry+https://github.com/rust-lang/crates.io-index" 2625 + checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" 2626 + dependencies = [ 2627 + "zerofrom-derive", 2628 + ] 2629 + 2630 + [[package]] 2631 + name = "zerofrom-derive" 2632 + version = "0.1.7" 2633 + source = "registry+https://github.com/rust-lang/crates.io-index" 2634 + checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" 2635 + dependencies = [ 2636 + "proc-macro2", 2637 + "quote", 2638 + "syn", 2639 + "synstructure", 2640 + ] 2641 + 2642 + [[package]] 2643 + name = "zeroize" 2644 + version = "1.8.2" 2645 + source = "registry+https://github.com/rust-lang/crates.io-index" 2646 + checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 2647 + 2648 + [[package]] 2649 + name = "zerotrie" 2650 + version = "0.2.4" 2651 + source = "registry+https://github.com/rust-lang/crates.io-index" 2652 + checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" 2653 + dependencies = [ 2654 + "displaydoc", 2655 + "yoke", 2656 + "zerofrom", 2657 + ] 2658 + 2659 + [[package]] 2660 + name = "zerovec" 2661 + version = "0.11.6" 2662 + source = "registry+https://github.com/rust-lang/crates.io-index" 2663 + checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" 2664 + dependencies = [ 2665 + "yoke", 2666 + "zerofrom", 2667 + "zerovec-derive", 2668 + ] 2669 + 2670 + [[package]] 2671 + name = "zerovec-derive" 2672 + version = "0.11.3" 2673 + source = "registry+https://github.com/rust-lang/crates.io-index" 2674 + checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" 2675 + dependencies = [ 2676 + "proc-macro2", 2677 + "quote", 2678 + "syn", 2679 + ] 2680 + 2681 + [[package]] 2682 + name = "zmij" 2683 + version = "1.0.21" 2684 + source = "registry+https://github.com/rust-lang/crates.io-index" 2685 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+3 -2
Cargo.toml
··· 14 14 tracing-subscriber = { version = "0.3", features = ["env-filter"] } 15 15 serde = { version = "1", features = ["derive"] } 16 16 serde_json = "1" 17 - askama = "0.13" 17 + askama = "0.16" 18 18 clap = { version = "4", features = ["derive", "env"] } 19 19 futures-util = "0.3" 20 20 async-trait = "0.1" 21 21 sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } 22 22 chrono = { version = "0.4", features = ["serde"] } 23 23 bytes = "1" 24 + uuid = { version = "1", features = ["v7", "serde"] } 24 25 25 26 [dev-dependencies] 26 - reqwest = { version = "0.12", features = ["json"] } 27 + tower = { version = "0.5", features = ["util"] } 27 28 28 29 [profile.release] 29 30 lto = "fat"
+3 -3
flake.lock
··· 93 93 "nixpkgs": "nixpkgs_2" 94 94 }, 95 95 "locked": { 96 - "lastModified": 1779074409, 97 - "narHash": "sha256-6aXy8Ga41iLVM8ibddFU1O5+wYWcBGNEfZzZuL91eIc=", 96 + "lastModified": 1778987862, 97 + "narHash": "sha256-V3qGt9P1eJP/r/1ONablphfGiH0RP4agQhrRANpDYx8=", 98 98 "owner": "oxalica", 99 99 "repo": "rust-overlay", 100 - "rev": "2a77b5b1dc952f214e8102acdef1622b68515560", 100 + "rev": "6f44d8874ac29806c8d5cae42bf8e19ebb5ce0d3", 101 101 "type": "github" 102 102 }, 103 103 "original": {
+22 -16
src/playback.rs
··· 13 13 use crate::media; 14 14 use crate::source::SourceRegistry; 15 15 use crate::transport::TrackPublishers; 16 - use crate::types::{ActiveTrackInfo, RoomCommand}; 16 + use crate::types::{ActiveTrackInfo, RoomCommand, TrackId}; 17 17 18 18 /// Manages the abort handle for the currently-running transcoding pipeline. 19 19 /// ··· 25 25 cache_dir: PathBuf, 26 26 pipeline_tx: mpsc::Sender<RoomCommand>, 27 27 /// The id of the currently-spawned pipeline, if any. 28 - track_id: Option<i64>, 28 + track_id: Option<TrackId>, 29 29 /// Signal handle to abort the current pipeline. 30 30 abort_tx: Option<watch::Sender<bool>>, 31 + /// Monotonically incrementing MoQ group ID. 32 + group_seq: u64, 31 33 } 32 34 33 35 impl Player { ··· 42 44 pipeline_tx, 43 45 track_id: None, 44 46 abort_tx: None, 47 + group_seq: 0, 45 48 } 46 49 } 47 50 ··· 53 56 /// If a pipeline is already running, call [`abort`](Self::abort) first. 54 57 pub(crate) fn start(&mut self, info: &ActiveTrackInfo, publishers: TrackPublishers) { 55 58 let (abort_tx, abort_rx) = watch::channel(false); 59 + self.group_seq += 1; 60 + let group_id = self.group_seq; 56 61 let track_id = info.id; 57 62 let source_registry = self.source_registry.clone(); 58 63 let url = info.url.clone(); ··· 69 74 } 70 75 }; 71 76 72 - let group_id = track_id as u64; 73 77 let result = 74 78 media::transcode_to_fmp4(&stream_url, publishers, abort_rx, group_id).await; 75 79 ··· 82 86 send_track_ended(&pipeline_tx, track_id).await; 83 87 }); 84 88 85 - self.track_id = Some(track_id); 89 + self.track_id = Some(info.id); 86 90 self.abort_tx = Some(abort_tx); 87 91 } 88 92 ··· 99 103 /// Returns `true` if `item_id` matched the currently-tracked pipeline, 100 104 /// `false` if it was a stale event from an older pipeline. 101 105 #[must_use] 102 - pub(crate) fn on_track_ended(&mut self, item_id: i64) -> bool { 106 + pub(crate) fn on_track_ended(&mut self, item_id: TrackId) -> bool { 103 107 if self.track_id == Some(item_id) { 104 108 self.track_id = None; 105 109 self.abort_tx = None; ··· 110 114 } 111 115 } 112 116 113 - async fn send_track_ended(tx: &mpsc::Sender<RoomCommand>, item_id: i64) { 117 + async fn send_track_ended(tx: &mpsc::Sender<RoomCommand>, item_id: TrackId) { 114 118 if let Err(e) = tx.send(RoomCommand::TrackEnded { item_id }).await { 115 - tracing::warn!(item_id, error = %e, "player: failed to send TrackEnded"); 119 + tracing::warn!(error = %e, "player: failed to send TrackEnded"); 116 120 } 117 121 } 118 122 ··· 121 125 use super::*; 122 126 use std::path::Path; 123 127 use std::sync::Arc; 124 - use std::sync::atomic::{AtomicI64, Ordering}; 125 128 126 129 struct MockPlayerSource; 127 130 ··· 150 153 Arc::new(reg) 151 154 } 152 155 153 - fn make_item_id() -> i64 { 154 - static NEXT_ID: AtomicI64 = AtomicI64::new(1); 155 - NEXT_ID.fetch_add(1, Ordering::Relaxed) 156 + fn make_item_id() -> TrackId { 157 + use std::sync::atomic::{AtomicU64, Ordering}; 158 + static NEXT: AtomicU64 = AtomicU64::new(1); 159 + TrackId(uuid::Uuid::from_u128( 160 + NEXT.fetch_add(1, Ordering::Relaxed) as u128 161 + )) 156 162 } 157 163 158 - fn test_info(id: i64) -> ActiveTrackInfo { 164 + fn test_info(id: TrackId) -> ActiveTrackInfo { 159 165 ActiveTrackInfo { 160 166 id, 161 167 title: "Test".into(), ··· 176 182 177 183 player.abort(); 178 184 179 - assert!(!player.on_track_ended(99999)); 185 + assert!(!player.on_track_ended(TrackId(uuid::Uuid::from_u128(99999)))); 180 186 } 181 187 182 188 #[tokio::test] ··· 189 195 190 196 player.abort(); 191 197 player.abort(); 192 - assert!(!player.on_track_ended(99999)); 198 + assert!(!player.on_track_ended(TrackId(uuid::Uuid::from_u128(99999)))); 193 199 } 194 200 195 201 #[tokio::test] ··· 197 203 let (tx, _rx) = mpsc::channel(256); 198 204 let mut player = Player::new(test_registry(), PathBuf::from("/tmp"), tx); 199 205 player.abort(); 200 - assert!(!player.on_track_ended(99999)); 206 + assert!(!player.on_track_ended(TrackId(uuid::Uuid::from_u128(99999)))); 201 207 } 202 208 203 209 #[tokio::test] ··· 219 225 let mut player = Player::new(test_registry(), PathBuf::from("/tmp"), tx); 220 226 player.start(&test_info(item_id), TrackPublishers::new()); 221 227 222 - assert!(!player.on_track_ended(99999)); 228 + assert!(!player.on_track_ended(TrackId(uuid::Uuid::from_u128(99999)))); 223 229 } 224 230 }
+552 -167
src/room.rs
··· 1 1 //! Room state management. 2 2 //! 3 - //! Each room is identified by a snowflake ID, persists metadata in 3 + //! Each room is identified by a UUIDv7, persists metadata in 4 4 //! SQLite, and holds ephemeral playback state in memory via a pure 5 5 //! [`PlaybackState`] machine. 6 6 //! ··· 13 13 use std::collections::HashMap; 14 14 use std::path::PathBuf; 15 15 use std::sync::Arc; 16 - use std::sync::atomic::{AtomicU64, Ordering}; 17 - use std::time::{Duration, SystemTime, UNIX_EPOCH}; 16 + use std::time::Duration; 18 17 19 18 use bytes::Bytes; 20 19 use chrono::{DateTime, Utc}; ··· 26 25 use crate::playback::Player; 27 26 use crate::source::SourceRegistry; 28 27 use crate::state::{Effect, Event, PlaybackState, QueuedTrack}; 29 - use crate::store::RoomStore; 28 + use crate::store::{PlaylistEntry, RoomStore}; 30 29 use crate::transport::TrackPublishers; 31 30 use crate::types::{ 32 - ActiveTrackInfo, ChatMessage, HistoryEntry, QueueSummary, RoomCommand, RoomHandle, RoomId, 33 - TrackMeta, TrackState, 31 + ActiveTrackInfo, ChatMessage, HistoryEntry, PlaylistEntryId, QueueSummary, RoomCommand, 32 + RoomHandle, RoomId, TrackId, TrackMeta, TrackState, 34 33 }; 35 34 36 - /// Distributed unique ID generator (Discord-style snowflake). 37 - /// 38 - /// Bit layout: 39 - /// 1 bit unused (always 0) 40 - /// 41 bits timestamp in milliseconds since custom epoch 41 - /// 10 bits node ID (0-1023) 42 - /// 12 bits sequence number (0-4095) 43 - /// 44 - /// Thread-safety is provided by a single `AtomicU64` that packs 45 - /// `(timestamp_ms << 12) | sequence`, allowing lock-free CAS updates. 46 - pub(crate) struct SnowflakeIdGen { 47 - node_id: u16, 48 - state: AtomicU64, // (timestamp_ms << 12) | sequence (12 bits) 49 - } 50 - 51 - const CUSTOM_EPOCH_MS: u64 = 1_735_689_600_000; // 2025-01-01T00:00:00Z 52 - 53 - impl SnowflakeIdGen { 54 - pub(crate) fn new(node_id: u16) -> Self { 55 - assert!(node_id < 1024, "node_id must be in 0..1024"); 56 - Self { 57 - node_id, 58 - state: AtomicU64::new(0), 59 - } 60 - } 61 - 62 - /// Generate a unique 64-bit ID. 63 - /// 64 - /// Thread-safe. Under extreme load (≥4096 IDs in the same 65 - /// millisecond) the generator yields to the tokio runtime until 66 - /// the next millisecond tick. 67 - #[must_use] 68 - pub(crate) async fn next_id(&self) -> u64 { 69 - loop { 70 - let now = SystemTime::now() 71 - .duration_since(UNIX_EPOCH) 72 - .expect("system clock before Unix epoch") 73 - .as_millis() as u64; 74 - let ts = now.wrapping_sub(CUSTOM_EPOCH_MS); 75 - 76 - let current = self.state.load(Ordering::Acquire); 77 - let last_ts = current >> 12; 78 - let last_seq = (current & 0xFFF) as u16; 79 - 80 - if ts > last_ts { 81 - // Advance to a new millisecond, resetting the sequence to 0. 82 - let new_val = ts << 12; 83 - if self 84 - .state 85 - .compare_exchange_weak(current, new_val, Ordering::Release, Ordering::Relaxed) 86 - .is_ok() 87 - { 88 - return (ts << 22) | ((self.node_id as u64) << 12); 89 - } 90 - continue; 91 - } 92 - 93 - // Same millisecond: try to claim the next sequence number. 94 - let seq = last_seq + 1; 95 - if seq < 4096 { 96 - let new_val = current + 1; // increment the 12-bit sequence field 97 - if self 98 - .state 99 - .compare_exchange_weak(current, new_val, Ordering::Release, Ordering::Relaxed) 100 - .is_ok() 101 - { 102 - return (last_ts << 22) | ((self.node_id as u64) << 12) | seq as u64; 103 - } 104 - continue; 105 - } 106 - 107 - // Per-millisecond sequence exhausted; yield and retry. 108 - tokio::time::sleep(std::time::Duration::from_millis(1)).await; 109 - } 110 - } 111 - } 35 + #[cfg(test)] 36 + use crate::types::ChatMessageId; 112 37 113 38 /// Per-room event-loop actor. Owns all mutable state. 114 39 struct RoomActor { ··· 117 42 room_name: String, 118 43 publishers: TrackPublishers, 119 44 store: Arc<dyn RoomStore>, 120 - id_gen: Arc<SnowflakeIdGen>, 121 45 source_registry: Arc<SourceRegistry>, 122 46 cache_dir: PathBuf, 123 47 /// Clone of the command sender, used by spawned extraction tasks to ··· 130 54 client_count: u64, 131 55 next_user_id: u64, 132 56 last_active_tx: watch::Sender<DateTime<Utc>>, 57 + 58 + /// Perpetual playlist entries for auto-fill. 59 + playlist: Vec<PlaylistEntry>, 60 + /// Index into the playlist for round-robin auto-fill. 61 + playlist_index: usize, 133 62 } 134 63 135 64 /// Thread-safe registry of all active rooms with persistent store. 136 65 #[derive(Clone)] 137 66 pub(crate) struct Registry { 138 67 store: Arc<dyn RoomStore>, 139 - id_gen: Arc<SnowflakeIdGen>, 140 68 cache_dir: PathBuf, 141 69 sources: Arc<SourceRegistry>, 142 70 inner: Arc<RwLock<Inner>>, ··· 154 82 ) -> Self { 155 83 Self { 156 84 store, 157 - id_gen: Arc::new(SnowflakeIdGen::new(1)), 158 85 inner: Arc::new(RwLock::new(Inner { 159 86 rooms: HashMap::new(), 160 87 })), ··· 163 90 } 164 91 } 165 92 166 - /// Create a new room with a snowflake ID, persist to store. 93 + /// Create a new room with a UUIDv7 ID, persist to store. 167 94 pub(crate) async fn create(&self) -> RoomId { 168 - let id = RoomId(self.id_gen.next_id().await.to_string()); 169 - let room_name = generate_room_name(&id.0); 95 + let id = RoomId::new(); 96 + let room_name = generate_room_name(&id.as_str_buf()); 170 97 let now = Utc::now(); 171 - let _ = self.store.create_room(&id.0, &room_name).await; 98 + let _ = self.store.create_room(&id.as_str_buf(), &room_name).await; 172 99 173 100 let publishers = TrackPublishers::new(); 174 101 let (tx, rx) = mpsc::channel(256); ··· 177 104 let player = Player::new(self.sources.clone(), self.cache_dir.clone(), tx.clone()); 178 105 let actor = RoomActor { 179 106 rx, 180 - room_id: id.clone(), 107 + room_id: id, 181 108 room_name: room_name.clone(), 182 109 publishers: publishers.clone(), 183 110 store: self.store.clone(), 184 - id_gen: self.id_gen.clone(), 185 111 source_registry: self.sources.clone(), 186 112 cache_dir: self.cache_dir.clone(), 187 113 cmd_tx: tx.clone(), ··· 190 116 client_count: 0, 191 117 next_user_id: 1, 192 118 last_active_tx, 119 + playlist: Vec::new(), 120 + playlist_index: 0, 193 121 }; 194 122 tokio::spawn(actor.run()); 195 123 ··· 214 142 } 215 143 216 144 // Slow path: try to rehydrate from store 217 - let snapshot = self.store.load(&id.0).await.ok()??; 145 + let snapshot = self.store.load(&id.as_str_buf()).await.ok()??; 218 146 219 - let (state, _effects) = PlaybackState::from_snapshot(snapshot.clone()); 147 + let (state, effects) = PlaybackState::from_snapshot(snapshot.clone()); 220 148 221 149 let publishers = TrackPublishers::new(); 222 150 let (tx, rx) = mpsc::channel(256); ··· 229 157 room_name: snapshot.room_name, 230 158 publishers: publishers.clone(), 231 159 store: self.store.clone(), 232 - id_gen: self.id_gen.clone(), 233 160 source_registry: self.sources.clone(), 234 161 cache_dir: self.cache_dir.clone(), 235 162 cmd_tx: tx.clone(), ··· 238 165 client_count: 0, 239 166 next_user_id: 1, 240 167 last_active_tx, 168 + playlist: snapshot.playlist, 169 + playlist_index: 0, 241 170 }; 242 171 tokio::spawn(actor.run()); 172 + 173 + // Persist any stale-active effects before returning the handle. 174 + if !effects.is_empty() { 175 + if let Err(e) = self.store.persist(&id.as_str_buf(), &effects).await { 176 + tracing::warn!(room = %id, error = %e, "rehydration: failed to persist stale effects"); 177 + } 178 + } 243 179 244 180 let handle = RoomHandle { 245 181 cmd_tx: tx, ··· 260 196 if self.inner.read().await.rooms.contains_key(id) { 261 197 return true; 262 198 } 263 - self.store.load(&id.0).await.ok().flatten().is_some() 199 + self.store 200 + .load(&id.as_str_buf()) 201 + .await 202 + .ok() 203 + .flatten() 204 + .is_some() 264 205 } 265 206 266 207 /// Load room name + history (for SSR rendering). 267 208 pub(crate) async fn load_room_data(&self, id: &RoomId) -> Option<(String, Vec<TrackMeta>)> { 268 - let snapshot = self.store.load(&id.0).await.ok()??; 209 + let snapshot = self.store.load(&id.as_str_buf()).await.ok()??; 269 210 let name = snapshot.room_name; 270 211 let history = snapshot 271 212 .history ··· 283 224 284 225 /// Get the upcoming queue for a room from the store. 285 226 pub(crate) async fn queue(&self, id: &RoomId) -> Vec<TrackMeta> { 286 - let snapshot = match self.store.load(&id.0).await { 227 + let snapshot = match self.store.load(&id.as_str_buf()).await { 287 228 Ok(Some(s)) => s, 288 229 _ => return Vec::new(), 289 230 }; ··· 321 262 .await; 322 263 } 323 264 self.inner.write().await.rooms.remove(id); 324 - let _ = self.store.delete_room(&id.0).await; 265 + let _ = self.store.delete_room(&id.as_str_buf()).await; 325 266 } 326 267 327 268 /// Send a chat message: validate, send to actor, return best-effort ChatMessage. ··· 356 297 .await; 357 298 } 358 299 Ok(ChatMessage { 359 - id: 0, 300 + id: ChatMessageId::new(), 360 301 user_name: user_name.to_string(), 361 302 content: content.to_string(), 362 303 msg_type: msg_type.to_string(), ··· 372 313 room_id: &RoomId, 373 314 limit: i64, 374 315 ) -> Result<Vec<ChatMessage>, sqlx::Error> { 375 - self.store.recent_chat(&room_id.0, limit).await 316 + self.store.recent_chat(&room_id.as_str_buf(), limit).await 376 317 } 377 318 378 319 /// Register a client in the room. ··· 388 329 resp_rx.await.ok() 389 330 } 390 331 391 - /// Unregister a client. 392 - #[cfg(test)] 393 - pub(crate) async fn unregister_client(&self, room_id: &RoomId) { 394 - if let Some(handle) = self.handle(room_id).await { 395 - let _ = handle.cmd_tx.send(RoomCommand::UnregisterClient).await; 396 - } 397 - } 398 - 399 332 /// Skip the current track. 400 333 pub(crate) async fn skip(&self, room_id: &RoomId) { 401 334 if let Some(handle) = self.handle(room_id).await { ··· 441 374 442 375 swept 443 376 } 377 + 378 + pub(crate) async fn add_playlist_entry(&self, id: &RoomId, url: String) { 379 + if let Some(handle) = self.handle(id).await { 380 + let _ = handle 381 + .cmd_tx 382 + .send(RoomCommand::AddPlaylistEntry { 383 + url, 384 + added_by: None, 385 + }) 386 + .await; 387 + } 388 + } 389 + 390 + pub(crate) async fn remove_playlist_entry(&self, id: &RoomId, entry_id: PlaylistEntryId) { 391 + if let Some(handle) = self.handle(id).await { 392 + let _ = handle 393 + .cmd_tx 394 + .send(RoomCommand::RemovePlaylistEntry { id: entry_id }) 395 + .await; 396 + } 397 + } 398 + } 399 + 400 + /// Given the current state and playlist, return the next track to auto-fill 401 + /// and the updated index. Returns `None` if auto-fill should not happen. 402 + fn next_playlist_track<'a>( 403 + state: &PlaybackState, 404 + playlist: &'a [PlaylistEntry], 405 + index: usize, 406 + ) -> Option<(&'a PlaylistEntry, usize)> { 407 + if state.active.is_some() || !state.queue.is_empty() || playlist.is_empty() { 408 + return None; 409 + } 410 + // Only pick entries whose metadata has been resolved. 411 + let ready: Vec<(usize, &PlaylistEntry)> = playlist 412 + .iter() 413 + .enumerate() 414 + .filter(|(_, e)| !e.pending) 415 + .collect(); 416 + if ready.is_empty() { 417 + return None; 418 + } 419 + let pos = index % ready.len(); 420 + let (_, entry) = ready[pos]; 421 + Some((entry, index.wrapping_add(1))) 444 422 } 445 423 446 424 impl RoomActor { ··· 465 443 RoomCommand::QueueUrl(url) => { 466 444 tracing::debug!(room = %self.room_id, %url, "track url queued"); 467 445 468 - let item_id = self.id_gen.next_id().await as i64; 446 + let item_id = TrackId::new(); 469 447 let now = Utc::now().timestamp_millis(); 470 448 471 449 let track = QueuedTrack { ··· 479 457 480 458 let effects = self.state.transition(&Event::TrackQueued(track), now); 481 459 482 - if let Err(e) = self.store.persist(&self.room_id.0, &effects).await { 460 + if let Err(e) = self 461 + .store 462 + .persist(&self.room_id.as_str_buf(), &effects) 463 + .await 464 + { 483 465 tracing::warn!("failed to persist queued track: {e}"); 484 466 } 485 467 ··· 508 490 } 509 491 510 492 RoomCommand::MetadataReady { item_id, meta } => { 511 - tracing::debug!(room = %self.room_id, item_id, title = %meta.title, "metadata ready"); 493 + tracing::debug!(room = %self.room_id, %item_id, title = %meta.title, "metadata ready"); 512 494 513 495 let now = Utc::now().timestamp_millis(); 514 496 ··· 523 505 now, 524 506 ); 525 507 526 - if let Err(e) = self.store.persist(&self.room_id.0, &effects).await { 527 - tracing::warn!(item_id, error = %e, "failed to persist metadata"); 508 + if let Err(e) = self 509 + .store 510 + .persist(&self.room_id.as_str_buf(), &effects) 511 + .await 512 + { 513 + tracing::warn!(error = %e, "failed to persist metadata"); 528 514 } 529 515 530 516 self.execute_effects(effects).await; 531 517 } 532 518 533 519 RoomCommand::MetadataFailed { item_id, error } => { 534 - tracing::debug!(room = %self.room_id, item_id, %error, "metadata extraction failed"); 520 + tracing::debug!(room = %self.room_id, %item_id, %error, "metadata extraction failed"); 535 521 536 522 let now = Utc::now().timestamp_millis(); 537 523 let effects = self.state.transition( 538 524 &Event::MetadataUpdated { 539 - item_id, 525 + item_id: item_id.clone(), 540 526 title: format!("Failed to load: {error}"), 541 527 duration: "--:--".into(), 542 528 thumbnail: None, ··· 545 531 now, 546 532 ); 547 533 548 - if let Err(e) = self.store.persist(&self.room_id.0, &effects).await { 549 - tracing::warn!(item_id, error = %e, "failed to persist metadata failure"); 534 + if let Err(e) = self 535 + .store 536 + .persist(&self.room_id.as_str_buf(), &effects) 537 + .await 538 + { 539 + tracing::warn!(item_id = %item_id, error = %e, "failed to persist metadata failure"); 550 540 } 551 541 552 542 self.execute_effects(effects).await; ··· 559 549 560 550 let effects = self.state.transition(&Event::Skip, now); 561 551 562 - if let Err(e) = self.store.persist(&self.room_id.0, &effects).await { 552 + if let Err(e) = self 553 + .store 554 + .persist(&self.room_id.as_str_buf(), &effects) 555 + .await 556 + { 563 557 tracing::warn!("failed to persist skip effects: {e}"); 564 558 } 565 559 566 560 self.execute_effects(effects).await; 561 + self.maybe_auto_fill().await; 567 562 } 568 563 569 564 RoomCommand::TrackEnded { item_id } => { 570 - tracing::debug!(room = %self.room_id, item_id, "track ended"); 565 + tracing::debug!(room = %self.room_id, %item_id, "track ended"); 571 566 572 567 if !self.player.on_track_ended(item_id) { 573 568 return false; ··· 577 572 578 573 let effects = self.state.transition(&Event::TrackEnded { item_id }, now); 579 574 580 - if let Err(e) = self.store.persist(&self.room_id.0, &effects).await { 575 + if let Err(e) = self 576 + .store 577 + .persist(&self.room_id.as_str_buf(), &effects) 578 + .await 579 + { 581 580 tracing::warn!("failed to persist track ended effects: {e}"); 582 581 } 583 582 584 583 self.execute_effects(effects).await; 584 + self.maybe_auto_fill().await; 585 + } 586 + 587 + RoomCommand::AddPlaylistEntry { url, added_by } => { 588 + let id = PlaylistEntryId::new(); 589 + let entry = PlaylistEntry { 590 + id, 591 + url: url.clone(), 592 + title: "Loading...".into(), 593 + duration: "--:--".into(), 594 + thumbnail: None, 595 + source: "direct".into(), 596 + added_by, 597 + added_at: crate::util::now_iso(), 598 + pending: true, 599 + }; 600 + let effects = vec![Effect::AddPlaylistEntry { 601 + id, 602 + url: url.clone(), 603 + title: "Loading...".into(), 604 + duration: "--:--".into(), 605 + thumbnail: None, 606 + source: "direct".into(), 607 + }]; 608 + if let Err(e) = self 609 + .store 610 + .persist(&self.room_id.as_str_buf(), &effects) 611 + .await 612 + { 613 + tracing::warn!("failed to persist playlist entry: {e}"); 614 + return false; 615 + } 616 + self.playlist.push(entry); 617 + self.publish_state_snapshot().await; 618 + 619 + let cmd_tx = self.cmd_tx.clone(); 620 + let sources = self.source_registry.clone(); 621 + let cache_dir = self.cache_dir.clone(); 622 + tokio::spawn(async move { 623 + match sources.extract(&url, &cache_dir).await { 624 + Ok(meta) => { 625 + let _ = cmd_tx 626 + .send(RoomCommand::PlaylistMetadataReady { id, meta }) 627 + .await; 628 + } 629 + Err(e) => { 630 + let _ = cmd_tx 631 + .send(RoomCommand::PlaylistMetadataFailed { 632 + id, 633 + error: e.to_string(), 634 + }) 635 + .await; 636 + } 637 + } 638 + }); 639 + } 640 + 641 + RoomCommand::RemovePlaylistEntry { id } => { 642 + let effects = vec![Effect::RemovePlaylistEntry(id.clone())]; 643 + if let Err(e) = self 644 + .store 645 + .persist(&self.room_id.as_str_buf(), &effects) 646 + .await 647 + { 648 + tracing::warn!("failed to persist playlist removal: {e}"); 649 + return false; 650 + } 651 + self.playlist.retain(|e| e.id != id); 652 + self.publish_state_snapshot().await; 653 + } 654 + 655 + RoomCommand::PlaylistMetadataReady { id, meta } => { 656 + if let Some(entry) = self.playlist.iter_mut().find(|e| e.id == id) { 657 + entry.title = meta.title.clone(); 658 + entry.duration = meta.duration.clone(); 659 + entry.thumbnail = meta.thumbnail.clone(); 660 + entry.source = meta.source.to_string(); 661 + entry.pending = false; 662 + } 663 + let effects = vec![Effect::UpdatePlaylistEntry { 664 + id: id.clone(), 665 + title: meta.title.clone(), 666 + duration: meta.duration.clone(), 667 + thumbnail: meta.thumbnail.clone(), 668 + source: meta.source.to_string(), 669 + }]; 670 + if let Err(e) = self 671 + .store 672 + .persist(&self.room_id.as_str_buf(), &effects) 673 + .await 674 + { 675 + tracing::warn!("failed to persist playlist metadata: {e}"); 676 + } 677 + self.publish_state_snapshot().await; 678 + self.maybe_auto_fill().await; 679 + } 680 + 681 + RoomCommand::PlaylistMetadataFailed { id, error } => { 682 + tracing::warn!(room = %self.room_id, %id, %error, "playlist metadata extraction failed"); 683 + let effects = vec![Effect::RemovePlaylistEntry(id.clone())]; 684 + if let Err(e) = self 685 + .store 686 + .persist(&self.room_id.as_str_buf(), &effects) 687 + .await 688 + { 689 + tracing::warn!("failed to persist playlist removal: {e}"); 690 + } 691 + self.playlist.retain(|e| e.id != id); 692 + self.publish_state_snapshot().await; 585 693 } 586 694 587 695 RoomCommand::Chat { ··· 598 706 msg_type: msg_type.clone(), 599 707 created_at: created_at.clone(), 600 708 }]; 601 - if let Ok(output) = self.store.persist(&self.room_id.0, &effects).await { 709 + if let Ok(output) = self 710 + .store 711 + .persist(&self.room_id.as_str_buf(), &effects) 712 + .await 713 + { 602 714 if let Some(msg_id) = output.chat_message_id { 603 715 let json = serde_json::json!({ 604 716 "user": user_name, ··· 667 779 } 668 780 } 669 781 782 + /// If the room is idle and has playlist entries, auto-fill the next track. 783 + async fn maybe_auto_fill(&mut self) { 784 + let entry = match next_playlist_track(&self.state, &self.playlist, self.playlist_index) { 785 + Some((e, idx)) => { 786 + self.playlist_index = idx; 787 + e.clone() 788 + } 789 + None => return, 790 + }; 791 + 792 + let id = TrackId::new(); 793 + let now = Utc::now().timestamp_millis(); 794 + let track = QueuedTrack { 795 + id, 796 + title: entry.title, 797 + url: entry.url, 798 + duration: entry.duration, 799 + thumbnail: entry.thumbnail, 800 + pending: false, 801 + }; 802 + let effects = self.state.transition(&Event::TrackQueued(track), now); 803 + if let Err(e) = self 804 + .store 805 + .persist(&self.room_id.as_str_buf(), &effects) 806 + .await 807 + { 808 + tracing::warn!("auto-fill: failed to persist: {e}"); 809 + } 810 + self.execute_effects(effects).await; 811 + } 812 + 670 813 /// Publish the current state snapshot to all connected clients. 671 814 /// 672 815 /// Reads from in-memory [`PlaybackState`], no SQLite queries. 673 816 async fn publish_state_snapshot(&self) { 674 817 let current = self.state.active.as_ref().map(|t| TrackState { 675 - id: t.id, 818 + id: t.id.clone(), 676 819 title: t.title.clone(), 677 820 url: t.url.clone(), 678 821 duration: t.duration.clone(), ··· 685 828 .queue 686 829 .iter() 687 830 .map(|q| QueueSummary { 688 - id: q.id, 831 + id: q.id.clone(), 689 832 title: q.title.clone(), 690 833 url: q.url.clone(), 691 834 duration: q.duration.clone(), ··· 699 842 .history 700 843 .iter() 701 844 .map(|h| HistoryEntry { 702 - id: h.id, 845 + id: h.id.clone(), 703 846 title: h.title.clone(), 704 847 url: h.url.clone(), 705 848 duration: h.duration.clone(), ··· 720 863 "current_track": current, 721 864 "queue": queue, 722 865 "history": history, 866 + "playlist": self.playlist, 723 867 "clients": client_count, 724 868 }); 725 869 let payload = Bytes::from(serde_json::to_vec(&snapshot).unwrap_or_default()); ··· 732 876 use super::*; 733 877 use crate::store::{InMemoryStore, SqliteStore}; 734 878 735 - // ----------------------------------------------------------------------- 736 - // Shared test infrastructure 737 - // ----------------------------------------------------------------------- 738 - 739 879 async fn poll_until<F, Fut>(check: F, timeout: Duration, label: &str) 740 880 where 741 881 F: Fn() -> Fut, ··· 797 937 Arc::new(reg) 798 938 } 799 939 800 - // ----------------------------------------------------------------------- 801 - // Backend-independent test: snowflake generator 802 - // ----------------------------------------------------------------------- 940 + fn tid(n: u128) -> TrackId { 941 + TrackId(uuid::Uuid::from_u128(n)) 942 + } 803 943 804 - #[tokio::test] 805 - async fn test_snowflake_uniqueness() { 806 - let gen_id = Arc::new(SnowflakeIdGen::new(1)); 807 - let mut handles = Vec::new(); 808 - for _ in 0..100 { 809 - let g = gen_id.clone(); 810 - handles.push(tokio::spawn(async move { g.next_id().await })); 811 - } 944 + fn pid(n: u128) -> PlaylistEntryId { 945 + PlaylistEntryId(uuid::Uuid::from_u128(n)) 946 + } 812 947 813 - let mut ids: Vec<u64> = Vec::with_capacity(100); 814 - for h in handles { 815 - ids.push(h.await.unwrap()); 816 - } 948 + #[test] 949 + fn test_next_playlist_track_returns_none_when_active() { 950 + let mut state = PlaybackState::default(); 951 + state.active = Some(ActiveTrackInfo { 952 + id: tid(1), 953 + title: "Active".into(), 954 + url: "https://example.com".into(), 955 + duration: "3:00".into(), 956 + thumbnail: None, 957 + started_at_wall: 0, 958 + }); 959 + let playlist = vec![PlaylistEntry { 960 + id: pid(1), 961 + url: "https://example.com/p".into(), 962 + title: "P".into(), 963 + duration: "3:00".into(), 964 + thumbnail: None, 965 + source: "direct".into(), 966 + added_by: None, 967 + added_at: "now".into(), 968 + pending: false, 969 + }]; 970 + assert!(next_playlist_track(&state, &playlist, 0).is_none()); 971 + } 817 972 818 - ids.sort(); 819 - let original_len = ids.len(); 820 - ids.dedup(); 821 - assert_eq!(ids.len(), original_len, "duplicate snowflake IDs"); 822 - assert!( 823 - ids.windows(2).all(|w| w[0] < w[1]), 824 - "IDs not monotonically increasing" 825 - ); 973 + #[test] 974 + fn test_next_playlist_track_returns_none_when_queue_not_empty() { 975 + let mut state = PlaybackState::default(); 976 + state.queue.push(QueuedTrack { 977 + id: tid(1), 978 + title: "Queued".into(), 979 + url: "https://example.com".into(), 980 + duration: "3:00".into(), 981 + thumbnail: None, 982 + pending: false, 983 + }); 984 + let playlist = vec![PlaylistEntry { 985 + id: pid(1), 986 + url: "https://example.com/p".into(), 987 + title: "P".into(), 988 + duration: "3:00".into(), 989 + thumbnail: None, 990 + source: "direct".into(), 991 + added_by: None, 992 + added_at: "now".into(), 993 + pending: false, 994 + }]; 995 + assert!(next_playlist_track(&state, &playlist, 0).is_none()); 826 996 } 827 997 828 - // ----------------------------------------------------------------------- 829 - // Shared scenario functions — run against any RoomStore backend 830 - // ----------------------------------------------------------------------- 998 + #[test] 999 + fn test_next_playlist_track_returns_none_when_empty_playlist() { 1000 + let state = PlaybackState::default(); 1001 + let playlist: Vec<PlaylistEntry> = Vec::new(); 1002 + assert!(next_playlist_track(&state, &playlist, 0).is_none()); 1003 + } 1004 + 1005 + #[test] 1006 + fn test_next_playlist_track_returns_entry_when_idle() { 1007 + let state = PlaybackState::default(); 1008 + let playlist = vec![PlaylistEntry { 1009 + id: pid(1), 1010 + url: "https://example.com/p".into(), 1011 + title: "P".into(), 1012 + duration: "3:00".into(), 1013 + thumbnail: None, 1014 + source: "direct".into(), 1015 + added_by: None, 1016 + added_at: "now".into(), 1017 + pending: false, 1018 + }]; 1019 + let (entry, index) = next_playlist_track(&state, &playlist, 0).unwrap(); 1020 + assert_eq!(entry.id, pid(1)); 1021 + assert_eq!(index, 1); 1022 + } 1023 + 1024 + #[test] 1025 + fn test_next_playlist_track_wraps_around() { 1026 + let state = PlaybackState::default(); 1027 + let playlist = vec![ 1028 + PlaylistEntry { 1029 + id: pid(1), 1030 + url: "https://example.com/a".into(), 1031 + title: "A".into(), 1032 + duration: "3:00".into(), 1033 + thumbnail: None, 1034 + source: "direct".into(), 1035 + added_by: None, 1036 + added_at: "now".into(), 1037 + pending: false, 1038 + }, 1039 + PlaylistEntry { 1040 + id: pid(2), 1041 + url: "https://example.com/b".into(), 1042 + title: "B".into(), 1043 + duration: "4:00".into(), 1044 + thumbnail: None, 1045 + source: "direct".into(), 1046 + added_by: None, 1047 + added_at: "now".into(), 1048 + pending: false, 1049 + }, 1050 + ]; 1051 + let (entry, index) = next_playlist_track(&state, &playlist, 1).unwrap(); 1052 + assert_eq!(entry.id, pid(2)); 1053 + assert_eq!(index, 2); 1054 + // Wrap around 1055 + let (entry, index) = next_playlist_track(&state, &playlist, 2).unwrap(); 1056 + assert_eq!(entry.id, pid(1)); 1057 + assert_eq!(index, 3); 1058 + } 831 1059 832 1060 mod scenarios { 833 1061 use super::*; ··· 896 1124 897 1125 reg.skip(room_id).await; 898 1126 poll_until( 899 - || async { history_len(&store, &room_id.0).await == 1 }, 1127 + || async { history_len(&store, &room_id.as_str_buf()).await == 1 }, 900 1128 Duration::from_secs(2), 901 1129 "skip should move track to history", 902 1130 ) ··· 954 1182 reg.skip(room_id).await; 955 1183 poll_until( 956 1184 || async { 957 - match reg.store.load(&room_id.0).await.ok().flatten() { 1185 + match reg.store.load(&room_id.as_str_buf()).await.ok().flatten() { 958 1186 Some(s) => s.history.first().map(|h| h.title.as_str()) == Some("b"), 959 1187 None => false, 960 1188 } ··· 964 1192 ) 965 1193 .await; 966 1194 967 - let snapshot = reg.store.load(&room_id.0).await.ok().flatten().unwrap(); 1195 + let snapshot = reg 1196 + .store 1197 + .load(&room_id.as_str_buf()) 1198 + .await 1199 + .ok() 1200 + .flatten() 1201 + .unwrap(); 968 1202 assert!(!snapshot.history.is_empty()); 969 1203 assert_eq!(snapshot.history[0].title, "b"); 970 1204 } ··· 1081 1315 1082 1316 reg.skip(room_id).await; 1083 1317 poll_until( 1084 - || async { history_len(&store, &room_id.0).await > 0 }, 1318 + || async { history_len(&store, &room_id.as_str_buf()).await > 0 }, 1085 1319 Duration::from_secs(2), 1086 1320 "skip should move track to history", 1087 1321 ) ··· 1095 1329 ) 1096 1330 .await; 1097 1331 1098 - assert!(history_len(&store, &room_id.0).await > 0); 1332 + assert!(history_len(&store, &room_id.as_str_buf()).await > 0); 1333 + } 1334 + 1335 + pub async fn add_playlist_entry(reg: &Registry, room_id: &RoomId) { 1336 + reg.add_playlist_entry(room_id, "https://example.com/p1".into()) 1337 + .await; 1338 + 1339 + poll_until( 1340 + || async { 1341 + reg.store 1342 + .load(&room_id.as_str_buf()) 1343 + .await 1344 + .ok() 1345 + .flatten() 1346 + .map(|s| s.playlist.len()) 1347 + .unwrap_or(0) 1348 + == 1 1349 + }, 1350 + Duration::from_secs(2), 1351 + "playlist should have 1 entry", 1352 + ) 1353 + .await; 1354 + 1355 + let snapshot = reg 1356 + .store 1357 + .load(&room_id.as_str_buf()) 1358 + .await 1359 + .ok() 1360 + .flatten() 1361 + .unwrap(); 1362 + assert_eq!(snapshot.playlist.len(), 1); 1363 + assert_eq!(snapshot.playlist[0].url, "https://example.com/p1"); 1364 + } 1365 + 1366 + pub async fn auto_fill_from_playlist(reg: &Registry, room_id: &RoomId) { 1367 + let store = reg.store.clone(); 1368 + 1369 + reg.add_playlist_entry(room_id, "https://example.com/p1".into()) 1370 + .await; 1371 + poll_until( 1372 + || async { 1373 + store 1374 + .load(&room_id.as_str_buf()) 1375 + .await 1376 + .ok() 1377 + .flatten() 1378 + .map(|s| s.playlist.len()) 1379 + .unwrap_or(0) 1380 + == 1 1381 + }, 1382 + Duration::from_secs(2), 1383 + "playlist should have 1 entry", 1384 + ) 1385 + .await; 1386 + 1387 + reg.push_url(room_id, "https://example.com/a".into()).await; 1388 + poll_until( 1389 + || async { reg.queue(room_id).await.is_empty() }, 1390 + Duration::from_secs(2), 1391 + "first push should have started playing", 1392 + ) 1393 + .await; 1394 + 1395 + reg.skip(room_id).await; 1396 + 1397 + poll_until( 1398 + || async { 1399 + store 1400 + .load(&room_id.as_str_buf()) 1401 + .await 1402 + .ok() 1403 + .flatten() 1404 + .map(|s| s.active.is_some()) 1405 + .unwrap_or(false) 1406 + }, 1407 + Duration::from_secs(2), 1408 + "auto-fill should start playlist track", 1409 + ) 1410 + .await; 1411 + } 1412 + 1413 + pub async fn remove_playlist_entry(reg: &Registry, room_id: &RoomId) { 1414 + reg.add_playlist_entry(room_id, "https://example.com/p1".into()) 1415 + .await; 1416 + poll_until( 1417 + || async { 1418 + reg.store 1419 + .load(&room_id.as_str_buf()) 1420 + .await 1421 + .ok() 1422 + .flatten() 1423 + .map(|s| s.playlist.len()) 1424 + .unwrap_or(0) 1425 + == 1 1426 + }, 1427 + Duration::from_secs(2), 1428 + "playlist should have 1 entry", 1429 + ) 1430 + .await; 1431 + 1432 + let snapshot = reg 1433 + .store 1434 + .load(&room_id.as_str_buf()) 1435 + .await 1436 + .ok() 1437 + .flatten() 1438 + .unwrap(); 1439 + let entry_id = snapshot.playlist[0].id.clone(); 1440 + 1441 + reg.remove_playlist_entry(room_id, entry_id).await; 1442 + poll_until( 1443 + || async { 1444 + reg.store 1445 + .load(&room_id.as_str_buf()) 1446 + .await 1447 + .ok() 1448 + .flatten() 1449 + .map(|s| s.playlist.is_empty()) 1450 + .unwrap_or(false) 1451 + }, 1452 + Duration::from_secs(2), 1453 + "playlist should be empty after removal", 1454 + ) 1455 + .await; 1099 1456 } 1100 1457 } 1101 1458 1102 - // ----------------------------------------------------------------------- 1103 - // InMemoryStore backend 1104 - // ----------------------------------------------------------------------- 1105 - 1106 1459 mod inmem { 1107 1460 use super::*; 1108 1461 ··· 1208 1561 let (reg, id) = setup().await; 1209 1562 scenarios::push_after_skip_works(&reg, &id).await; 1210 1563 } 1211 - } 1212 1564 1213 - // ----------------------------------------------------------------------- 1214 - // SqliteStore backend — runs same scenarios via :memory: SQLite 1215 - // ----------------------------------------------------------------------- 1565 + #[tokio::test] 1566 + async fn test_add_playlist_entry() { 1567 + let (reg, id) = setup().await; 1568 + scenarios::add_playlist_entry(&reg, &id).await; 1569 + } 1570 + 1571 + #[tokio::test] 1572 + async fn test_auto_fill_from_playlist() { 1573 + let (reg, id) = setup().await; 1574 + scenarios::auto_fill_from_playlist(&reg, &id).await; 1575 + } 1576 + 1577 + #[tokio::test] 1578 + async fn test_remove_playlist_entry() { 1579 + let (reg, id) = setup().await; 1580 + scenarios::remove_playlist_entry(&reg, &id).await; 1581 + } 1582 + } 1216 1583 1217 1584 mod sqlite { 1218 1585 use super::*; ··· 1323 1690 async fn test_push_after_skip_works() { 1324 1691 let (reg, id) = setup().await; 1325 1692 scenarios::push_after_skip_works(&reg, &id).await; 1693 + } 1694 + 1695 + #[tokio::test] 1696 + async fn test_add_playlist_entry() { 1697 + let (reg, id) = setup().await; 1698 + scenarios::add_playlist_entry(&reg, &id).await; 1699 + } 1700 + 1701 + #[tokio::test] 1702 + async fn test_auto_fill_from_playlist() { 1703 + let (reg, id) = setup().await; 1704 + scenarios::auto_fill_from_playlist(&reg, &id).await; 1705 + } 1706 + 1707 + #[tokio::test] 1708 + async fn test_remove_playlist_entry() { 1709 + let (reg, id) = setup().await; 1710 + scenarios::remove_playlist_entry(&reg, &id).await; 1326 1711 } 1327 1712 } 1328 1713 }
+94 -34
src/state.rs
··· 3 3 //! Owns the in-memory queue, active track, and history. All transitions 4 4 //! are pure functions returning [`Effect`]s that the caller executes. 5 5 6 - use crate::types::ActiveTrackInfo; 6 + use crate::types::{ActiveTrackInfo, PlaylistEntryId, TrackId}; 7 7 8 8 /// A track waiting in the queue (has a DB id). 9 9 #[derive(Debug, Clone, PartialEq, Eq)] 10 10 pub(crate) struct QueuedTrack { 11 - pub id: i64, 11 + pub id: TrackId, 12 12 pub title: String, 13 13 pub url: String, 14 14 pub duration: String, ··· 20 20 /// A finished track in history. 21 21 #[derive(Debug, Clone, PartialEq, Eq)] 22 22 pub(crate) struct FinishedTrack { 23 - pub id: i64, 23 + pub id: TrackId, 24 24 pub title: String, 25 25 pub url: String, 26 26 pub duration: String, ··· 36 36 /// User requested to skip the current track. 37 37 Skip, 38 38 /// The transcoding pipeline finished (naturally or via abort). 39 - TrackEnded { item_id: i64 }, 39 + TrackEnded { item_id: TrackId }, 40 40 /// Asynchronous metadata extraction completed for a queued/playing track. 41 41 MetadataUpdated { 42 - item_id: i64, 42 + item_id: TrackId, 43 43 title: String, 44 44 duration: String, 45 45 thumbnail: Option<String>, ··· 57 57 /// Start transcoding a new track. RoomActor fills in the timestamp. 58 58 StartPipeline(QueuedTrack), 59 59 /// Mark a track as started in the DB (set started_playing_at). 60 - PersistStarted(i64), 60 + PersistStarted(TrackId), 61 61 /// Mark a track as finished in the DB (set played=1, played_at). 62 - PersistFinished(i64), 62 + PersistFinished(TrackId), 63 63 /// Broadcast the current state snapshot to all clients. 64 64 PublishSnapshot, 65 65 /// Persist a newly queued track to the store. 66 66 PersistQueuedTrack(QueuedTrack), 67 67 /// Persist metadata update after async extraction completes. 68 68 PersistMetadata { 69 - item_id: i64, 69 + item_id: TrackId, 70 70 title: String, 71 71 duration: String, 72 72 thumbnail: Option<String>, ··· 79 79 msg_type: String, 80 80 created_at: String, 81 81 }, 82 + /// Persist a new perpetual playlist entry. 83 + AddPlaylistEntry { 84 + id: PlaylistEntryId, 85 + url: String, 86 + title: String, 87 + duration: String, 88 + thumbnail: Option<String>, 89 + source: String, 90 + }, 91 + /// Update a playlist entry's metadata after extraction. 92 + UpdatePlaylistEntry { 93 + id: PlaylistEntryId, 94 + title: String, 95 + duration: String, 96 + thumbnail: Option<String>, 97 + source: String, 98 + }, 99 + /// Remove a playlist entry. 100 + RemovePlaylistEntry(PlaylistEntryId), 82 101 } 83 102 84 103 /// Pure playback state: no IO handles, no DB connections. ··· 133 152 fn handle_skip(&mut self, _now: i64) -> Vec<Effect> { 134 153 let mut effects = Vec::new(); 135 154 136 - // Abort any active track and move it to history. 137 155 if let Some(active) = self.active.take() { 138 156 effects.push(Effect::AbortPipeline); 139 157 effects.push(Effect::PersistFinished(active.id)); ··· 153 171 } 154 172 155 173 if effects.is_empty() { 156 - return effects; // nothing was playing, nothing to do 174 + return effects; 157 175 } 158 176 159 - // Advance to next track. 160 177 if let Some(next) = self.queue.first().cloned() { 161 178 effects.extend(self.advance(next)); 162 179 } else if !effects.is_empty() { ··· 167 184 } 168 185 169 186 /// A transcoding pipeline finished (naturally or after abort signal). 170 - fn handle_track_ended(&mut self, item_id: i64, _now: i64) -> Vec<Effect> { 187 + fn handle_track_ended(&mut self, item_id: TrackId, _now: i64) -> Vec<Effect> { 171 188 // Only react if this matches the currently active track. 172 189 let is_current = self 173 190 .active ··· 181 198 182 199 let mut effects = Vec::new(); 183 200 184 - // Move to history. 185 201 if let Some(active) = self.active.take() { 186 202 effects.push(Effect::PersistFinished(active.id)); 187 203 self.history.insert( ··· 199 215 ); 200 216 } 201 217 202 - // Advance to next track. 203 218 if let Some(next) = self.queue.first().cloned() { 204 219 effects.extend(self.advance(next)); 205 220 } else { ··· 214 229 /// state, only updates display fields. 215 230 fn handle_metadata_updated( 216 231 &mut self, 217 - item_id: i64, 232 + item_id: TrackId, 218 233 title: &str, 219 234 duration: &str, 220 235 thumbnail: &Option<String>, ··· 318 333 mod tests { 319 334 use super::*; 320 335 321 - fn queued(title: &str, id: i64) -> QueuedTrack { 336 + fn track_id(n: u128) -> TrackId { 337 + TrackId(uuid::Uuid::from_u128(n)) 338 + } 339 + 340 + fn playlist_id(n: u128) -> PlaylistEntryId { 341 + PlaylistEntryId(uuid::Uuid::from_u128(n)) 342 + } 343 + 344 + fn queued(title: &str, n: u128) -> QueuedTrack { 322 345 QueuedTrack { 323 - id, 346 + id: track_id(n), 324 347 title: title.into(), 325 348 url: format!("https://example.com/{title}"), 326 349 duration: "3:45".into(), ··· 338 361 effects, 339 362 vec![ 340 363 Effect::PersistQueuedTrack(queued("A", 1)), 341 - Effect::PersistStarted(1), 364 + Effect::PersistStarted(track_id(1)), 342 365 Effect::StartPipeline(queued("A", 1)), 343 366 Effect::PublishSnapshot, 344 367 ], ··· 373 396 &effects[..3], 374 397 &[ 375 398 Effect::AbortPipeline, 376 - Effect::PersistFinished(1), 377 - Effect::PersistStarted(2), 399 + Effect::PersistFinished(track_id(1)), 400 + Effect::PersistStarted(track_id(2)), 378 401 ], 379 402 "skip with next: abort current, finalize A, start B" 380 403 ); ··· 400 423 effects, 401 424 vec![ 402 425 Effect::AbortPipeline, 403 - Effect::PersistFinished(1), 426 + Effect::PersistFinished(track_id(1)), 404 427 Effect::PublishSnapshot, 405 428 ], 406 429 "skip last: abort, finalize, notify, no StartPipeline" ··· 411 434 fn test_track_ended_last_goes_idle_effect_order() { 412 435 let mut s = PlaybackState::default(); 413 436 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 414 - let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); 437 + let effects = s.transition( 438 + &Event::TrackEnded { 439 + item_id: track_id(1), 440 + }, 441 + 0, 442 + ); 415 443 416 444 assert_eq!( 417 445 effects, 418 - vec![Effect::PersistFinished(1), Effect::PublishSnapshot,], 446 + vec![ 447 + Effect::PersistFinished(track_id(1)), 448 + Effect::PublishSnapshot, 449 + ], 419 450 "track ended last: finalize, notify, no StartPipeline, no AbortPipeline" 420 451 ); 421 452 } ··· 479 510 let mut s = PlaybackState::default(); 480 511 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 481 512 s.transition(&Event::TrackQueued(queued("B", 2)), 0); 482 - let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); 513 + let effects = s.transition( 514 + &Event::TrackEnded { 515 + item_id: track_id(1), 516 + }, 517 + 0, 518 + ); 483 519 484 520 assert_eq!( 485 521 &effects[..2], 486 - &[Effect::PersistFinished(1), Effect::PersistStarted(2),], 522 + &[ 523 + Effect::PersistFinished(track_id(1)), 524 + Effect::PersistStarted(track_id(2)), 525 + ], 487 526 "track ended (next): finalize A, start B, no AbortPipeline, no re-persist queued" 488 527 ); 489 528 assert!( ··· 503 542 let mut s = PlaybackState::default(); 504 543 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 505 544 506 - let effects = s.transition(&Event::TrackEnded { item_id: 999 }, 0); 545 + let effects = s.transition( 546 + &Event::TrackEnded { 547 + item_id: track_id(999), 548 + }, 549 + 0, 550 + ); 507 551 508 552 assert!(s.active.is_some()); 509 553 assert_eq!(s.active.as_ref().unwrap().title, "A"); ··· 514 558 fn test_track_ended_last_track_goes_idle() { 515 559 let mut s = PlaybackState::default(); 516 560 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 517 - s.transition(&Event::TrackEnded { item_id: 1 }, 0); 561 + s.transition( 562 + &Event::TrackEnded { 563 + item_id: track_id(1), 564 + }, 565 + 0, 566 + ); 518 567 519 568 assert!(s.active.is_none()); 520 569 assert_eq!(s.history.len(), 1); ··· 537 586 s.transition(&Event::Skip, 0); 538 587 assert_eq!(s.active.as_ref().unwrap().title, "B"); 539 588 540 - let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); 589 + let effects = s.transition( 590 + &Event::TrackEnded { 591 + item_id: track_id(1), 592 + }, 593 + 0, 594 + ); 541 595 assert!(effects.is_empty()); 542 596 assert_eq!(s.active.as_ref().unwrap().title, "B"); 543 597 } ··· 555 609 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 556 610 let effects = s.transition( 557 611 &Event::MetadataUpdated { 558 - item_id: 1, 612 + item_id: track_id(1), 559 613 title: "Track A (Real)".into(), 560 614 duration: "4:20".into(), 561 615 thumbnail: Some("https://img.example/a.jpg".into()), ··· 568 622 effects, 569 623 vec![ 570 624 Effect::PersistMetadata { 571 - item_id: 1, 625 + item_id: track_id(1), 572 626 title: "Track A (Real)".into(), 573 627 duration: "4:20".into(), 574 628 thumbnail: Some("https://img.example/a.jpg".into()), ··· 597 651 // Update the queued item (B, id=2). 598 652 let effects = s.transition( 599 653 &Event::MetadataUpdated { 600 - item_id: 2, 654 + item_id: track_id(2), 601 655 title: "Track B (Real)".into(), 602 656 duration: "5:55".into(), 603 657 thumbnail: None, ··· 609 663 effects, 610 664 vec![ 611 665 Effect::PersistMetadata { 612 - item_id: 2, 666 + item_id: track_id(2), 613 667 title: "Track B (Real)".into(), 614 668 duration: "5:55".into(), 615 669 thumbnail: None, ··· 627 681 let mut s = PlaybackState::default(); 628 682 let effects = s.transition( 629 683 &Event::MetadataUpdated { 630 - item_id: 999, 684 + item_id: track_id(999), 631 685 title: "Ghost".into(), 632 686 duration: "0:00".into(), 633 687 thumbnail: None, ··· 641 695 effects, 642 696 vec![ 643 697 Effect::PersistMetadata { 644 - item_id: 999, 698 + item_id: track_id(999), 645 699 title: "Ghost".into(), 646 700 duration: "0:00".into(), 647 701 thumbnail: None, ··· 652 706 ); 653 707 assert!(s.active.is_none()); 654 708 assert!(s.queue.is_empty()); 709 + } 710 + 711 + // Suppress unused warning for playlist_id helper used for future tests. 712 + #[allow(dead_code)] 713 + fn _use_playlist_id() { 714 + let _ = playlist_id(1); 655 715 } 656 716 }
+252 -54
src/store.rs
··· 14 14 use sqlx::{Pool, Sqlite}; 15 15 16 16 use crate::state::{Effect, FinishedTrack, QueuedTrack}; 17 - use crate::types::ChatMessage; 17 + use crate::types::{ChatMessage, ChatMessageId, PlaylistEntryId, TrackId}; 18 + 19 + /// An entry in a room's perpetual playlist — used for auto-fill when the 20 + /// queue runs dry. Metadata is pre-resolved at add-time so auto-played 21 + /// tracks never show a "Loading..." placeholder. 22 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] 23 + pub(crate) struct PlaylistEntry { 24 + pub id: PlaylistEntryId, 25 + pub url: String, 26 + pub title: String, 27 + pub duration: String, 28 + pub thumbnail: Option<String>, 29 + pub source: String, 30 + pub added_by: Option<String>, 31 + pub added_at: String, 32 + pub pending: bool, 33 + } 18 34 19 35 /// Full snapshot of a room's state loaded from the store. 20 36 /// ··· 31 47 pub active: Option<QueuedTrack>, 32 48 /// Recently finished tracks (newest first). 33 49 pub history: Vec<FinishedTrack>, 50 + /// Pre-resolved perpetual playlist entries. 51 + pub playlist: Vec<PlaylistEntry>, 34 52 pub chat_messages: Vec<ChatMessage>, 35 53 } 36 54 ··· 40 58 #[derive(Debug, Clone, Default)] 41 59 pub(crate) struct PersistOutput { 42 60 /// Set when a [`Effect::PersistChat`] was applied. 43 - pub chat_message_id: Option<i64>, 61 + pub chat_message_id: Option<ChatMessageId>, 44 62 } 45 63 46 64 /// Abstraction over room persistence. ··· 74 92 async fn delete_room(&self, room_id: &str) -> Result<(), sqlx::Error>; 75 93 } 76 94 77 - // --------------------------------------------------------------------------- 78 - // In-memory implementation (for testing) 79 - // --------------------------------------------------------------------------- 95 + /// In-memory variant of [`PlaylistEntry`] that carries extra fields needed 96 + /// for round-trip persistence but not exposed via the public type. 97 + #[cfg_attr(not(test), allow(dead_code))] 98 + #[derive(Debug, Clone)] 99 + struct InMemoryPlaylistEntry { 100 + id: PlaylistEntryId, 101 + url: String, 102 + title: String, 103 + duration: String, 104 + thumbnail: Option<String>, 105 + source: String, 106 + added_by: Option<String>, 107 + added_at: String, 108 + pending: bool, 109 + } 110 + 111 + impl From<InMemoryPlaylistEntry> for PlaylistEntry { 112 + fn from(e: InMemoryPlaylistEntry) -> Self { 113 + PlaylistEntry { 114 + id: e.id, 115 + url: e.url, 116 + title: e.title, 117 + duration: e.duration, 118 + thumbnail: e.thumbnail, 119 + source: e.source, 120 + added_by: e.added_by, 121 + added_at: e.added_at, 122 + pending: e.pending, 123 + } 124 + } 125 + } 80 126 81 127 #[cfg_attr(not(test), allow(dead_code))] 82 128 #[derive(Debug)] ··· 85 131 queue: Vec<QueuedTrack>, 86 132 active: Option<QueuedTrack>, 87 133 history: Vec<FinishedTrack>, 134 + playlist: Vec<InMemoryPlaylistEntry>, 88 135 chat_messages: Vec<ChatMessage>, 89 - next_chat_id: i64, 90 136 } 91 137 92 138 /// Thread-safe in-memory store backed by a [`Mutex`]-protected [`HashMap`]. ··· 125 171 queue: Vec::new(), 126 172 active: None, 127 173 history: Vec::new(), 174 + playlist: Vec::new(), 128 175 chat_messages: Vec::new(), 129 - next_chat_id: 1, 130 176 }, 131 177 ); 132 178 Ok(()) ··· 184 230 thumbnail, 185 231 source: _, 186 232 } => { 187 - // Update in the queue. 188 233 if let Some(track) = room.queue.iter_mut().find(|t| t.id == *item_id) { 189 234 track.title.clone_from(title); 190 235 track.duration.clone_from(duration); 191 236 track.thumbnail.clone_from(thumbnail); 192 237 track.pending = false; 193 238 } 194 - // Update in the active track if it matches. 195 239 if let Some(ref mut active) = room.active { 196 240 if active.id == *item_id { 197 241 active.title.clone_from(title); ··· 207 251 msg_type, 208 252 created_at, 209 253 } => { 210 - let id = room.next_chat_id; 211 - room.next_chat_id += 1; 254 + let id = ChatMessageId::new(); 212 255 room.chat_messages.push(ChatMessage { 213 256 id, 214 257 user_name: user_name.clone(), ··· 218 261 }); 219 262 output.chat_message_id = Some(id); 220 263 } 264 + Effect::AddPlaylistEntry { 265 + id, 266 + url, 267 + title, 268 + duration, 269 + thumbnail, 270 + source, 271 + } => { 272 + room.playlist.push(InMemoryPlaylistEntry { 273 + id: *id, 274 + url: url.clone(), 275 + title: title.clone(), 276 + duration: duration.clone(), 277 + thumbnail: thumbnail.clone(), 278 + source: source.clone(), 279 + added_by: None, 280 + added_at: crate::util::now_iso(), 281 + pending: true, 282 + }); 283 + } 284 + Effect::UpdatePlaylistEntry { 285 + id, 286 + title, 287 + duration, 288 + thumbnail, 289 + source, 290 + } => { 291 + if let Some(entry) = room.playlist.iter_mut().find(|e| e.id == *id) { 292 + entry.title.clone_from(title); 293 + entry.duration.clone_from(duration); 294 + entry.thumbnail.clone_from(thumbnail); 295 + entry.source.clone_from(source); 296 + entry.pending = false; 297 + } 298 + } 299 + Effect::RemovePlaylistEntry(id) => { 300 + room.playlist.retain(|e| e.id != *id); 301 + } 221 302 _ => {} 222 303 } 223 304 } ··· 238 319 queue: room.queue.clone(), 239 320 active: room.active.clone(), 240 321 history: room.history.clone(), 322 + playlist: room.playlist.iter().map(|e| e.clone().into()).collect(), 241 323 chat_messages: room.chat_messages.clone(), 242 324 })) 243 325 } ··· 270 352 } 271 353 } 272 354 273 - // --------------------------------------------------------------------------- 274 - // SQLite-backed implementation (production) 275 - // --------------------------------------------------------------------------- 276 - 277 355 /// Production [`RoomStore`] backed by an [`sqlx`] SQLite pool. 278 356 /// 279 357 /// Schema is isolated from the legacy `db.rs` tables: this module uses ··· 324 402 325 403 sqlx::query( 326 404 "CREATE TABLE IF NOT EXISTS tracks ( 327 - id INTEGER PRIMARY KEY, 405 + id TEXT PRIMARY KEY, 328 406 room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, 329 407 url TEXT NOT NULL, 330 408 title TEXT NOT NULL, ··· 352 430 353 431 sqlx::query( 354 432 "CREATE TABLE IF NOT EXISTS chat_messages ( 355 - id INTEGER PRIMARY KEY AUTOINCREMENT, 433 + id TEXT PRIMARY KEY, 356 434 room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, 357 435 user_name TEXT NOT NULL, 358 436 content TEXT NOT NULL, ··· 363 441 .execute(&pool) 364 442 .await?; 365 443 444 + sqlx::query( 445 + "CREATE TABLE IF NOT EXISTS playlist_entries ( 446 + id TEXT PRIMARY KEY, 447 + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, 448 + url TEXT NOT NULL, 449 + title TEXT NOT NULL DEFAULT '', 450 + duration TEXT NOT NULL DEFAULT '--:--', 451 + thumbnail TEXT, 452 + source TEXT NOT NULL DEFAULT '', 453 + added_by TEXT, 454 + added_at TEXT NOT NULL, 455 + pending INTEGER NOT NULL DEFAULT 1 456 + )", 457 + ) 458 + .execute(&pool) 459 + .await?; 460 + 366 461 // Enable foreign key enforcement (per-connection, best-effort). 367 462 sqlx::query("PRAGMA foreign_keys = ON") 368 463 .execute(&pool) ··· 376 471 #[derive(sqlx::FromRow)] 377 472 #[allow(dead_code)] 378 473 struct TrackRow { 379 - id: i64, 474 + id: String, 380 475 title: String, 381 476 url: String, 382 477 duration: String, ··· 387 482 pending: i64, 388 483 } 389 484 485 + /// Helper row type for deserialising `playlist_entries` rows. 486 + #[derive(sqlx::FromRow)] 487 + struct PlaylistRow { 488 + id: String, 489 + url: String, 490 + title: String, 491 + duration: String, 492 + thumbnail: Option<String>, 493 + source: String, 494 + added_by: Option<String>, 495 + added_at: String, 496 + pending: i64, 497 + } 498 + 390 499 /// Helper row type for deserialising `chat_messages` rows. 391 500 #[derive(sqlx::FromRow)] 392 501 struct ChatMsgRow { 393 - id: i64, 502 + id: String, 394 503 user_name: String, 395 504 content: String, 396 505 msg_type: String, 397 506 created_at: String, 398 507 } 399 508 509 + fn parse_track_id(s: &str) -> TrackId { 510 + TrackId(uuid::Uuid::parse_str(s).expect("valid TrackId UUID in DB")) 511 + } 512 + 513 + fn parse_playlist_id(s: &str) -> PlaylistEntryId { 514 + PlaylistEntryId(uuid::Uuid::parse_str(s).expect("valid PlaylistEntryId UUID in DB")) 515 + } 516 + 517 + fn parse_chat_message_id(s: &str) -> ChatMessageId { 518 + ChatMessageId(uuid::Uuid::parse_str(s).expect("valid ChatMessageId UUID in DB")) 519 + } 520 + 400 521 #[async_trait] 401 522 impl RoomStore for SqliteStore { 402 523 async fn create_room(&self, id: &str, name: &str) -> Result<(), sqlx::Error> { ··· 436 557 position, added_at, pending) 437 558 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", 438 559 ) 439 - .bind(track.id) 560 + .bind(track.id.as_str_buf()) 440 561 .bind(room_id) 441 562 .bind(&track.url) 442 563 .bind(&track.title) ··· 455 576 "UPDATE tracks SET started_at_ms = ?1 WHERE id = ?2 AND played = 0", 456 577 ) 457 578 .bind(now_ms) 458 - .bind(id) 579 + .bind(id.as_str_buf()) 459 580 .execute(&mut *tx) 460 581 .await?; 461 582 } ··· 463 584 let played_at = crate::util::now_iso(); 464 585 sqlx::query("UPDATE tracks SET played = 1, played_at = ?1 WHERE id = ?2") 465 586 .bind(&played_at) 466 - .bind(id) 587 + .bind(id.as_str_buf()) 467 588 .execute(&mut *tx) 468 589 .await?; 469 590 } ··· 485 606 .bind(duration.as_str()) 486 607 .bind(thumbnail.as_deref()) 487 608 .bind(&src_str) 488 - .bind(item_id) 609 + .bind(item_id.as_str_buf()) 489 610 .execute(&mut *tx) 490 611 .await?; 491 612 } ··· 495 616 msg_type, 496 617 created_at, 497 618 } => { 498 - let result = sqlx::query( 619 + let id = ChatMessageId::new(); 620 + sqlx::query( 499 621 "INSERT INTO chat_messages 500 - (room_id, user_name, content, msg_type, created_at) 501 - VALUES (?1, ?2, ?3, ?4, ?5)", 622 + (id, room_id, user_name, content, msg_type, created_at) 623 + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", 502 624 ) 625 + .bind(id.as_str_buf()) 503 626 .bind(room_id) 504 627 .bind(user_name.as_str()) 505 628 .bind(content.as_str()) ··· 507 630 .bind(created_at.as_str()) 508 631 .execute(&mut *tx) 509 632 .await?; 510 - output.chat_message_id = Some(result.last_insert_rowid()); 633 + output.chat_message_id = Some(id); 634 + } 635 + Effect::AddPlaylistEntry { 636 + id, 637 + url, 638 + title, 639 + duration, 640 + thumbnail, 641 + source, 642 + } => { 643 + sqlx::query( 644 + "INSERT INTO playlist_entries (id, room_id, url, title, duration, thumbnail, source, added_at, pending) 645 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)", 646 + ) 647 + .bind(id.as_str_buf()) 648 + .bind(room_id) 649 + .bind(url.as_str()) 650 + .bind(title.as_str()) 651 + .bind(duration.as_str()) 652 + .bind(thumbnail.as_deref()) 653 + .bind(source.as_str()) 654 + .bind(crate::util::now_iso()) 655 + .execute(&mut *tx) 656 + .await?; 657 + } 658 + Effect::UpdatePlaylistEntry { 659 + id, 660 + title, 661 + duration, 662 + thumbnail, 663 + source, 664 + } => { 665 + sqlx::query( 666 + "UPDATE playlist_entries SET title = ?1, duration = ?2, thumbnail = ?3, source = ?4, pending = 0 WHERE id = ?5", 667 + ) 668 + .bind(title.as_str()) 669 + .bind(duration.as_str()) 670 + .bind(thumbnail.as_deref()) 671 + .bind(source.as_str()) 672 + .bind(id.as_str_buf()) 673 + .execute(&mut *tx) 674 + .await?; 675 + } 676 + Effect::RemovePlaylistEntry(id) => { 677 + sqlx::query("DELETE FROM playlist_entries WHERE id = ?") 678 + .bind(id.as_str_buf()) 679 + .execute(&mut *tx) 680 + .await?; 511 681 } 512 682 // Non-persistence effects are ignored. 513 683 _ => {} ··· 579 749 .fetch_all(&self.pool) 580 750 .await?; 581 751 752 + // 6. Playlist entries, ordered by id. 753 + let playlist_rows: Vec<PlaylistRow> = sqlx::query_as( 754 + "SELECT id, url, title, duration, thumbnail, source, added_by, added_at, pending 755 + FROM playlist_entries 756 + WHERE room_id = ? 757 + ORDER BY id", 758 + ) 759 + .bind(room_id) 760 + .fetch_all(&self.pool) 761 + .await?; 762 + 582 763 let queue: Vec<QueuedTrack> = queue_rows 583 764 .into_iter() 584 765 .map(|r| QueuedTrack { 585 - id: r.id, 766 + id: parse_track_id(&r.id), 586 767 title: r.title, 587 768 url: r.url, 588 769 duration: r.duration, ··· 592 773 .collect(); 593 774 594 775 let active = active_row.map(|r| QueuedTrack { 595 - id: r.id, 776 + id: parse_track_id(&r.id), 596 777 title: r.title, 597 778 url: r.url, 598 779 duration: r.duration, ··· 603 784 let history: Vec<FinishedTrack> = history_rows 604 785 .into_iter() 605 786 .map(|r| FinishedTrack { 606 - id: r.id, 787 + id: parse_track_id(&r.id), 607 788 title: r.title, 608 789 url: r.url, 609 790 duration: r.duration, ··· 615 796 let chat_messages: Vec<ChatMessage> = chat_rows 616 797 .into_iter() 617 798 .map(|r| ChatMessage { 618 - id: r.id, 799 + id: parse_chat_message_id(&r.id), 619 800 user_name: r.user_name, 620 801 content: r.content, 621 802 msg_type: r.msg_type, ··· 623 804 }) 624 805 .collect(); 625 806 807 + let playlist: Vec<PlaylistEntry> = playlist_rows 808 + .into_iter() 809 + .map(|r| PlaylistEntry { 810 + id: parse_playlist_id(&r.id), 811 + url: r.url, 812 + title: r.title, 813 + duration: r.duration, 814 + thumbnail: r.thumbnail, 815 + source: r.source, 816 + added_by: r.added_by, 817 + added_at: r.added_at, 818 + pending: r.pending != 0, 819 + }) 820 + .collect(); 821 + 626 822 Ok(Some(RoomSnapshot { 627 823 room_id: room_id.to_string(), 628 824 room_name, 629 825 queue, 630 826 active, 631 827 history, 828 + playlist, 632 829 chat_messages, 633 830 })) 634 831 } ··· 653 850 Ok(rows 654 851 .into_iter() 655 852 .map(|r| ChatMessage { 656 - id: r.id, 853 + id: parse_chat_message_id(&r.id), 657 854 user_name: r.user_name, 658 855 content: r.content, 659 856 msg_type: r.msg_type, ··· 675 872 mod tests { 676 873 use super::*; 677 874 678 - // ------------------------------------------------------------------ 679 - // InMemoryStore tests 680 - // ------------------------------------------------------------------ 875 + fn tid(n: u128) -> TrackId { 876 + TrackId(uuid::Uuid::from_u128(n)) 877 + } 681 878 682 879 #[tokio::test] 683 880 async fn test_create_and_load_roundtrip() { ··· 749 946 store.create_room("r", "R").await.unwrap(); 750 947 751 948 let track = QueuedTrack { 752 - id: 1, 949 + id: tid(1), 753 950 title: "Loading...".into(), 754 951 url: "https://example.com/t".into(), 755 952 duration: "--:--".into(), ··· 764 961 765 962 let snap = store.load("r").await.unwrap().unwrap(); 766 963 assert_eq!(snap.queue.len(), 1); 767 - assert_eq!(snap.queue[0].id, 1); 964 + assert_eq!(snap.queue[0].id, tid(1)); 768 965 assert!(snap.queue[0].pending); 769 966 assert!(snap.active.is_none()); 770 967 assert!(snap.history.is_empty()); ··· 776 973 store.create_room("r", "R").await.unwrap(); 777 974 778 975 let track = QueuedTrack { 779 - id: 1, 976 + id: tid(1), 780 977 title: "Track A".into(), 781 978 url: "https://example.com/a".into(), 782 979 duration: "3:45".into(), ··· 789 986 .await 790 987 .unwrap(); 791 988 store 792 - .persist("r", &[Effect::PersistStarted(1)]) 989 + .persist("r", &[Effect::PersistStarted(tid(1))]) 793 990 .await 794 991 .unwrap(); 795 992 796 993 let snap = store.load("r").await.unwrap().unwrap(); 797 994 assert!(snap.queue.is_empty()); 798 995 assert!(snap.active.is_some()); 799 - assert_eq!(snap.active.unwrap().id, 1); 996 + assert_eq!(snap.active.unwrap().id, tid(1)); 800 997 } 801 998 802 999 #[tokio::test] ··· 805 1002 store.create_room("r", "R").await.unwrap(); 806 1003 807 1004 store 808 - .persist("r", &[Effect::PersistStarted(999)]) 1005 + .persist("r", &[Effect::PersistStarted(tid(999))]) 809 1006 .await 810 1007 .unwrap(); 811 1008 let snap = store.load("r").await.unwrap().unwrap(); ··· 822 1019 "r", 823 1020 &[ 824 1021 Effect::PersistQueuedTrack(QueuedTrack { 825 - id: 1, 1022 + id: tid(1), 826 1023 title: "A".into(), 827 1024 url: "https://e.com/a".into(), 828 1025 duration: "3:00".into(), 829 1026 thumbnail: None, 830 1027 pending: false, 831 1028 }), 832 - Effect::PersistStarted(1), 1029 + Effect::PersistStarted(tid(1)), 833 1030 ], 834 1031 ) 835 1032 .await 836 1033 .unwrap(); 837 1034 store 838 - .persist("r", &[Effect::PersistFinished(1)]) 1035 + .persist("r", &[Effect::PersistFinished(tid(1))]) 839 1036 .await 840 1037 .unwrap(); 841 1038 842 1039 let snap = store.load("r").await.unwrap().unwrap(); 843 1040 assert!(snap.active.is_none()); 844 1041 assert_eq!(snap.history.len(), 1); 845 - assert_eq!(snap.history[0].id, 1); 1042 + assert_eq!(snap.history[0].id, tid(1)); 846 1043 assert!(!snap.history[0].played_at.is_empty()); 847 1044 } 848 1045 ··· 852 1049 store.create_room("r", "R").await.unwrap(); 853 1050 854 1051 store 855 - .persist("r", &[Effect::PersistFinished(1)]) 1052 + .persist("r", &[Effect::PersistFinished(tid(1))]) 856 1053 .await 857 1054 .unwrap(); 858 1055 let snap = store.load("r").await.unwrap().unwrap(); ··· 865 1062 store.create_room("r", "R").await.unwrap(); 866 1063 867 1064 let track = QueuedTrack { 868 - id: 1, 1065 + id: tid(1), 869 1066 title: "Loading...".into(), 870 1067 url: "https://e.com/t".into(), 871 1068 duration: "--:--".into(), ··· 881 1078 .persist( 882 1079 "r", 883 1080 &[Effect::PersistMetadata { 884 - item_id: 1, 1081 + item_id: tid(1), 885 1082 title: "Real Title".into(), 886 1083 duration: "4:20".into(), 887 1084 thumbnail: Some("https://img.example/t.jpg".into()), ··· 911 1108 "r", 912 1109 &[ 913 1110 Effect::PersistQueuedTrack(QueuedTrack { 914 - id: 1, 1111 + id: tid(1), 915 1112 title: "Loading...".into(), 916 1113 url: "https://e.com/t".into(), 917 1114 duration: "--:--".into(), 918 1115 thumbnail: None, 919 1116 pending: true, 920 1117 }), 921 - Effect::PersistStarted(1), 1118 + Effect::PersistStarted(tid(1)), 922 1119 ], 923 1120 ) 924 1121 .await ··· 927 1124 .persist( 928 1125 "r", 929 1126 &[Effect::PersistMetadata { 930 - item_id: 1, 1127 + item_id: tid(1), 931 1128 title: "Real Title".into(), 932 1129 duration: "4:20".into(), 933 1130 thumbnail: None, ··· 962 1159 .await 963 1160 .unwrap(); 964 1161 965 - assert_eq!(output.chat_message_id, Some(1)); 1162 + assert!(output.chat_message_id.is_some()); 966 1163 967 1164 let snap = store.load("r").await.unwrap().unwrap(); 968 1165 assert_eq!(snap.chat_messages.len(), 1); ··· 1000 1197 .await 1001 1198 .unwrap(); 1002 1199 1003 - assert_eq!(o1.chat_message_id, Some(1)); 1004 - assert_eq!(o2.chat_message_id, Some(2)); 1200 + assert!(o1.chat_message_id.is_some()); 1201 + assert!(o2.chat_message_id.is_some()); 1202 + assert_ne!(o1.chat_message_id, o2.chat_message_id); 1005 1203 1006 1204 let snap = store.load("r").await.unwrap().unwrap(); 1007 1205 assert_eq!(snap.chat_messages.len(), 2);
+21 -21
src/transport.rs
··· 13 13 use tokio::sync::{broadcast, mpsc, watch}; 14 14 15 15 use crate::room; 16 - use crate::types::{MoqObject, RoomCommand, RoomId, StateValue, TrackId}; 16 + use crate::types::{MoqObject, MoqTrackKind, RoomCommand, RoomId, StateValue}; 17 17 18 18 pub(crate) fn encode_varint(value: u64) -> Vec<u8> { 19 19 if value < 64 { ··· 290 290 291 291 pub(crate) fn publish_video(&self, group_id: u64, object_id: u64, payload: Bytes) { 292 292 let _ = self.video.send(MoqObject { 293 - track_id: TrackId::Video, 293 + track_id: MoqTrackKind::Video, 294 294 group_id, 295 295 object_id, 296 296 payload, ··· 301 301 pub(crate) fn publish_chat(&self, group_id: u64, payload: Bytes) { 302 302 let object_id = self.chat_seq.fetch_add(1, Ordering::Relaxed); 303 303 let _ = self.chat.send(MoqObject { 304 - track_id: TrackId::Chat, 304 + track_id: MoqTrackKind::Chat, 305 305 group_id, 306 306 object_id, 307 307 payload, ··· 366 366 let (mut ws_tx, mut ws_rx) = ws.split(); 367 367 368 368 let announce = encode_message(&MoqMessage::Announce { 369 - namespace: room_namespace(&room_id.0), 369 + namespace: room_namespace(&room_id.0.to_string()), 370 370 }); 371 371 if ws_tx.send(Message::Binary(announce)).await.is_err() { 372 372 return; ··· 517 517 ) { 518 518 match msg { 519 519 MoqMessage::Subscribe { namespace, track } => { 520 - let track_id = match TrackId::from_name(&track) { 520 + let track_id = match MoqTrackKind::from_name(&track) { 521 521 Some(id) => id, 522 522 None => { 523 523 let rst = encode_message(&MoqMessage::SubscribeRst { ··· 539 539 } 540 540 541 541 match track_id { 542 - TrackId::Chat => { 542 + MoqTrackKind::Chat => { 543 543 if let Ok(messages) = registry.recent_chat(room_id, 50).await { 544 - for msg in messages.iter().rev() { 544 + for (seq, msg) in messages.iter().rev().enumerate() { 545 545 let json = serde_json::json!({ 546 546 "user": msg.user_name, 547 547 "content": msg.content, ··· 551 551 }); 552 552 let payload = serde_json::to_vec(&json).unwrap_or_default(); 553 553 let obj = encode_message(&MoqMessage::Object { 554 - track_id: TrackId::Chat as u64, 554 + track_id: MoqTrackKind::Chat as u64, 555 555 group_id: 0, 556 - object_id: msg.id as u64, 556 + object_id: seq as u64, 557 557 payload: Bytes::from(payload), 558 558 }); 559 559 if object_tx.send(obj).is_err() { ··· 585 585 } 586 586 })); 587 587 } 588 - TrackId::State => { 588 + MoqTrackKind::State => { 589 589 let initial = publishers.state.borrow().clone(); 590 590 let obj = encode_message(&MoqMessage::Object { 591 - track_id: TrackId::State as u64, 591 + track_id: MoqTrackKind::State as u64, 592 592 group_id: 0, 593 593 object_id: initial.seq, 594 594 payload: initial.payload, ··· 606 606 } 607 607 let val = state_rx.borrow().clone(); 608 608 let obj = encode_message(&MoqMessage::Object { 609 - track_id: TrackId::State as u64, 609 + track_id: MoqTrackKind::State as u64, 610 610 group_id: 0, 611 611 object_id: val.seq, 612 612 payload: val.payload, ··· 627 627 object_id: _, 628 628 payload, 629 629 } => { 630 - if track_id == TrackId::Chat as u64 { 630 + if track_id == MoqTrackKind::Chat as u64 { 631 631 let json: serde_json::Value = 632 632 serde_json::from_slice(&payload).unwrap_or(serde_json::Value::Null); 633 633 let content = json.get("content").and_then(|v| v.as_str()).unwrap_or(""); ··· 802 802 803 803 #[test] 804 804 fn test_track_id_from_name() { 805 - assert_eq!(TrackId::from_name("audio"), Some(TrackId::Audio)); 806 - assert_eq!(TrackId::from_name("video"), Some(TrackId::Video)); 807 - assert_eq!(TrackId::from_name("chat"), Some(TrackId::Chat)); 808 - assert_eq!(TrackId::from_name("state"), Some(TrackId::State)); 809 - assert_eq!(TrackId::from_name("invalid"), None); 810 - assert_eq!(TrackId::from_name(""), None); 805 + assert_eq!(MoqTrackKind::from_name("audio"), Some(MoqTrackKind::Audio)); 806 + assert_eq!(MoqTrackKind::from_name("video"), Some(MoqTrackKind::Video)); 807 + assert_eq!(MoqTrackKind::from_name("chat"), Some(MoqTrackKind::Chat)); 808 + assert_eq!(MoqTrackKind::from_name("state"), Some(MoqTrackKind::State)); 809 + assert_eq!(MoqTrackKind::from_name("invalid"), None); 810 + assert_eq!(MoqTrackKind::from_name(""), None); 811 811 } 812 812 813 813 #[tokio::test] ··· 818 818 publishers.publish_video(5, 99, Bytes::from("video-frame")); 819 819 820 820 let obj = rx.recv().await.unwrap(); 821 - assert_eq!(obj.track_id, TrackId::Video); 821 + assert_eq!(obj.track_id, MoqTrackKind::Video); 822 822 assert_eq!(obj.payload, Bytes::from("video-frame")); 823 823 } 824 824 ··· 830 830 publishers.publish_chat(0, Bytes::from("hello")); 831 831 832 832 let obj = rx.recv().await.unwrap(); 833 - assert_eq!(obj.track_id, TrackId::Chat); 833 + assert_eq!(obj.track_id, MoqTrackKind::Chat); 834 834 assert_eq!(obj.object_id, 0); 835 835 assert_eq!(obj.payload, Bytes::from("hello")); 836 836 }
+132 -27
src/types.rs
··· 9 9 10 10 pub(crate) use crate::transport::TrackPublishers; 11 11 12 - /// Opaque room identifier, human-readable and URL-safe. 13 - #[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize)] 14 - pub(crate) struct RoomId(pub String); 12 + /// Typed identifier for a queued / active / history track. 13 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] 14 + #[serde(transparent)] 15 + pub(crate) struct TrackId(pub(crate) uuid::Uuid); 16 + 17 + impl TrackId { 18 + pub(crate) fn new() -> Self { 19 + Self(uuid::Uuid::now_v7()) 20 + } 21 + 22 + pub(crate) fn as_str_buf(&self) -> String { 23 + self.0.to_string() 24 + } 25 + } 26 + 27 + impl fmt::Display for TrackId { 28 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 29 + self.0.fmt(f) 30 + } 31 + } 32 + 33 + /// Typed identifier for a perpetual playlist entry. 34 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] 35 + #[serde(transparent)] 36 + pub(crate) struct PlaylistEntryId(pub(crate) uuid::Uuid); 37 + 38 + impl PlaylistEntryId { 39 + pub(crate) fn new() -> Self { 40 + Self(uuid::Uuid::now_v7()) 41 + } 42 + 43 + pub(crate) fn parse(s: &str) -> Option<Self> { 44 + uuid::Uuid::parse_str(s).ok().map(Self) 45 + } 46 + 47 + pub(crate) fn as_str_buf(&self) -> String { 48 + self.0.to_string() 49 + } 50 + } 51 + 52 + impl fmt::Display for PlaylistEntryId { 53 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 54 + self.0.fmt(f) 55 + } 56 + } 57 + 58 + /// Typed identifier for a chat message. 59 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] 60 + #[serde(transparent)] 61 + pub(crate) struct ChatMessageId(pub(crate) uuid::Uuid); 62 + 63 + impl ChatMessageId { 64 + pub(crate) fn new() -> Self { 65 + Self(uuid::Uuid::now_v7()) 66 + } 67 + 68 + pub(crate) fn as_str_buf(&self) -> String { 69 + self.0.to_string() 70 + } 71 + } 72 + 73 + impl fmt::Display for ChatMessageId { 74 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 75 + self.0.fmt(f) 76 + } 77 + } 78 + 79 + /// Opaque room identifier backed by a UUIDv7. 80 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] 81 + #[serde(transparent)] 82 + pub(crate) struct RoomId(pub(crate) uuid::Uuid); 83 + 84 + impl RoomId { 85 + pub(crate) fn new() -> Self { 86 + Self(uuid::Uuid::now_v7()) 87 + } 88 + 89 + pub(crate) fn as_str_buf(&self) -> String { 90 + self.0.to_string() 91 + } 92 + 93 + /// Parse a UUID string into a `RoomId`. Returns `None` if invalid. 94 + pub(crate) fn parse(s: &str) -> Option<Self> { 95 + uuid::Uuid::parse_str(s).ok().map(Self) 96 + } 97 + } 15 98 16 99 impl fmt::Display for RoomId { 17 100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ··· 64 147 /// Snapshot of the currently playing track for state publishing. 65 148 #[derive(Debug, Clone, Serialize)] 66 149 pub(crate) struct TrackState { 67 - pub(crate) id: i64, 150 + pub(crate) id: TrackId, 68 151 pub(crate) title: String, 69 152 pub(crate) url: String, 70 153 pub(crate) duration: String, ··· 75 158 /// Summary of a queued track for state publishing. 76 159 #[derive(Debug, Clone, Serialize)] 77 160 pub(crate) struct QueueSummary { 78 - pub(crate) id: i64, 161 + pub(crate) id: TrackId, 79 162 pub(crate) title: String, 80 163 pub(crate) url: String, 81 164 pub(crate) duration: String, ··· 91 174 92 175 #[derive(Debug, Clone, Serialize)] 93 176 pub(crate) struct HistoryEntry { 94 - pub(crate) id: i64, 177 + pub(crate) id: TrackId, 95 178 pub(crate) title: String, 96 179 pub(crate) url: String, 97 180 pub(crate) duration: String, ··· 102 185 /// A chat message returned from send/recent operations. 103 186 #[derive(Debug, Clone, Serialize)] 104 187 pub(crate) struct ChatMessage { 105 - pub(crate) id: i64, 188 + pub(crate) id: ChatMessageId, 106 189 pub(crate) user_name: String, 107 190 pub(crate) content: String, 108 191 pub(crate) msg_type: String, 109 192 pub(crate) created_at: String, 110 193 } 111 194 112 - /// Logical track IDs within a MoQ room. 195 + /// Logical MoQ track kinds within a room (not the same as domain TrackId). 113 196 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 114 - pub(crate) enum TrackId { 197 + pub(crate) enum MoqTrackKind { 115 198 Audio = 0, 116 199 Video = 1, 117 200 Chat = 2, 118 201 State = 3, 119 202 } 120 203 121 - impl TrackId { 204 + impl MoqTrackKind { 122 205 pub(crate) fn from_name(name: &str) -> Option<Self> { 123 206 match name { 124 - "audio" => Some(TrackId::Audio), 125 - "video" => Some(TrackId::Video), 126 - "chat" => Some(TrackId::Chat), 127 - "state" => Some(TrackId::State), 207 + "audio" => Some(MoqTrackKind::Audio), 208 + "video" => Some(MoqTrackKind::Video), 209 + "chat" => Some(MoqTrackKind::Chat), 210 + "state" => Some(MoqTrackKind::State), 128 211 _ => None, 129 212 } 130 213 } ··· 133 216 /// A single MoQ object on a track. 134 217 #[derive(Debug, Clone)] 135 218 pub(crate) struct MoqObject { 136 - pub(crate) track_id: TrackId, 219 + pub(crate) track_id: MoqTrackKind, 137 220 pub(crate) group_id: u64, 138 221 pub(crate) object_id: u64, 139 222 pub(crate) payload: Bytes, ··· 152 235 QueueUrl(String), 153 236 /// Async metadata extraction result. 154 237 MetadataReady { 155 - item_id: i64, 238 + item_id: TrackId, 156 239 meta: TrackMeta, 157 240 }, 158 241 /// Async metadata extraction failed. 159 242 MetadataFailed { 160 - item_id: i64, 243 + item_id: TrackId, 161 244 error: String, 162 245 }, 163 246 Skip, 164 247 /// Sent by the pipeline task when it finishes (or errors, or is aborted). 165 248 TrackEnded { 166 - item_id: i64, 249 + item_id: TrackId, 167 250 }, 168 251 Chat { 169 252 user_name: String, ··· 174 257 resp: oneshot::Sender<u64>, 175 258 }, 176 259 UnregisterClient, 260 + /// Add a URL to the perpetual playlist. 261 + AddPlaylistEntry { 262 + url: String, 263 + added_by: Option<String>, 264 + }, 265 + /// Remove an entry from the perpetual playlist by ID. 266 + RemovePlaylistEntry { 267 + id: PlaylistEntryId, 268 + }, 269 + /// Async metadata extraction completed for a playlist entry. 270 + PlaylistMetadataReady { 271 + id: PlaylistEntryId, 272 + meta: TrackMeta, 273 + }, 274 + /// Async metadata extraction failed for a playlist entry. 275 + PlaylistMetadataFailed { 276 + id: PlaylistEntryId, 277 + error: String, 278 + }, 177 279 /// Graceful shutdown (sent by idle sweeper). 178 280 Shutdown, 179 281 } ··· 181 283 /// Ephemeral info about the active track, stored in-memory only. 182 284 #[derive(Debug, Clone, PartialEq, Eq)] 183 285 pub(crate) struct ActiveTrackInfo { 184 - pub(crate) id: i64, 286 + pub(crate) id: TrackId, 185 287 pub(crate) title: String, 186 288 pub(crate) url: String, 187 289 pub(crate) duration: String, ··· 204 306 205 307 #[test] 206 308 fn test_room_id_display() { 207 - let id = RoomId("12345".into()); 208 - assert_eq!(id.to_string(), "12345"); 309 + let id = RoomId::new(); 310 + assert!( 311 + id.to_string().contains('-'), 312 + "RoomId should be UUID with hyphens" 313 + ); 209 314 } 210 315 211 316 #[test] 212 317 fn test_track_id_from_name() { 213 - assert_eq!(TrackId::from_name("audio"), Some(TrackId::Audio)); 214 - assert_eq!(TrackId::from_name("video"), Some(TrackId::Video)); 215 - assert_eq!(TrackId::from_name("chat"), Some(TrackId::Chat)); 216 - assert_eq!(TrackId::from_name("state"), Some(TrackId::State)); 217 - assert_eq!(TrackId::from_name("invalid"), None); 218 - assert_eq!(TrackId::from_name(""), None); 318 + assert_eq!(MoqTrackKind::from_name("audio"), Some(MoqTrackKind::Audio)); 319 + assert_eq!(MoqTrackKind::from_name("video"), Some(MoqTrackKind::Video)); 320 + assert_eq!(MoqTrackKind::from_name("chat"), Some(MoqTrackKind::Chat)); 321 + assert_eq!(MoqTrackKind::from_name("state"), Some(MoqTrackKind::State)); 322 + assert_eq!(MoqTrackKind::from_name("invalid"), None); 323 + assert_eq!(MoqTrackKind::from_name(""), None); 219 324 } 220 325 }
+152 -42
src/web.rs
··· 15 15 use tokio::sync::Mutex; 16 16 17 17 use crate::room; 18 - use crate::types::{RoomId, TrackMeta}; 18 + use crate::types::{PlaylistEntryId, RoomId, TrackMeta}; 19 19 20 20 /// Per-IP rate limiter for POST /api/ingest (max 1 req/5s per IP). 21 21 #[derive(Clone)] ··· 53 53 #[derive(Clone)] 54 54 pub(crate) struct AppState { 55 55 pub rooms: room::Registry, 56 - pub rate_limiter: RateLimiter, 56 + pub rate_limiter: Option<RateLimiter>, 57 57 } 58 58 59 59 pub(crate) fn router(rooms: room::Registry) -> Router { ··· 70 70 71 71 let state = AppState { 72 72 rooms, 73 - rate_limiter, 73 + rate_limiter: Some(rate_limiter), 74 74 }; 75 75 76 76 Router::new() ··· 79 79 .route("/api/rooms", post(create_room)) 80 80 .route("/api/ingest", post(ingest_url)) 81 81 .route("/api/skip", post(skip_handler)) 82 + .route("/api/playlist/add", post(add_playlist_entry)) 83 + .route("/api/playlist/remove", post(remove_playlist_entry)) 82 84 .route("/ws/{room_id}", get(ws_handler)) 83 85 .route("/ws/{room_id}/video", get(video_ws_handler)) 84 86 .with_state(state) ··· 115 117 State(state): State<AppState>, 116 118 Path(id): Path<String>, 117 119 ) -> Result<Html<String>, StatusCode> { 118 - if !id.chars().all(|c| c.is_ascii_digit()) { 119 - return Err(StatusCode::BAD_REQUEST); 120 - } 121 - let room_id = RoomId(id.clone()); 120 + let room_id = RoomId::parse(&id).ok_or(StatusCode::BAD_REQUEST)?; 122 121 let queue = state.rooms.queue(&room_id).await; 123 122 let queue_empty = queue.is_empty(); 124 123 125 - // Load snapshot once for room name + history. 126 - let (room_name, history) = { 127 - let rid = RoomId(id.clone()); 128 - match state.rooms.load_room_data(&rid).await { 129 - Some((name, hist)) => (name, hist), 130 - None => (String::new(), Vec::new()), 131 - } 124 + let (room_name, history) = match state.rooms.load_room_data(&room_id).await { 125 + Some((name, hist)) => (name, hist), 126 + None => (String::new(), Vec::new()), 132 127 }; 133 128 134 129 let tpl = RoomTemplate { ··· 146 141 147 142 async fn create_room(State(state): State<AppState>) -> Result<impl IntoResponse, StatusCode> { 148 143 let room = state.rooms.create().await; 149 - Ok(Json(serde_json::json!({ "room_id": room.0 }))) 144 + Ok(Json(serde_json::json!({ "room_id": room.to_string() }))) 150 145 } 151 146 152 147 #[derive(serde::Deserialize)] ··· 169 164 return Err(err_response(StatusCode::BAD_REQUEST, "URL too long")); 170 165 } 171 166 172 - if !req.room_id.chars().all(|c| c.is_ascii_digit()) { 173 - return Err(err_response(StatusCode::BAD_REQUEST, "invalid room ID")); 167 + if let Some(limiter) = &state.rate_limiter { 168 + if let Err(retry_after) = limiter.check(addr).await { 169 + let mut res = err_response(StatusCode::TOO_MANY_REQUESTS, "rate limited"); 170 + res.headers_mut().insert( 171 + axum::http::header::RETRY_AFTER, 172 + retry_after 173 + .as_secs() 174 + .to_string() 175 + .parse() 176 + .expect("valid retry-after seconds"), 177 + ); 178 + return Err(res); 179 + } 174 180 } 175 181 176 - if let Err(retry_after) = state.rate_limiter.check(addr).await { 177 - let mut res = err_response(StatusCode::TOO_MANY_REQUESTS, "rate limited"); 178 - res.headers_mut().insert( 179 - axum::http::header::RETRY_AFTER, 180 - retry_after 181 - .as_secs() 182 - .to_string() 183 - .parse() 184 - .expect("valid retry-after seconds"), 185 - ); 186 - return Err(res); 187 - } 188 - 189 - let room_id = RoomId(req.room_id); 182 + let room_id = RoomId::parse(&req.room_id) 183 + .ok_or_else(|| err_response(StatusCode::BAD_REQUEST, "invalid room ID"))?; 190 184 191 185 if !state.rooms.exists(&room_id).await { 192 186 return Err(err_response(StatusCode::NOT_FOUND, "room not found")); ··· 206 200 State(state): State<AppState>, 207 201 axum::Json(req): axum::Json<SkipRequest>, 208 202 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> { 209 - let room_id = RoomId(req.room_id); 203 + let room_id = RoomId::parse(&req.room_id).ok_or_else(|| { 204 + ( 205 + StatusCode::NOT_FOUND, 206 + Json(serde_json::json!({ "error": "room not found" })), 207 + ) 208 + })?; 210 209 211 210 if !state.rooms.exists(&room_id).await { 212 211 return Err(( ··· 220 219 Ok(Json(serde_json::json!({ "ok": true }))) 221 220 } 222 221 222 + #[derive(serde::Deserialize)] 223 + struct AddPlaylistReq { 224 + room_id: String, 225 + url: String, 226 + } 227 + 228 + async fn add_playlist_entry( 229 + State(state): State<AppState>, 230 + axum::Json(req): axum::Json<AddPlaylistReq>, 231 + ) -> Result<Json<serde_json::Value>, axum::response::Response> { 232 + if req.url.len() > 2048 { 233 + return Err(err_response(StatusCode::BAD_REQUEST, "URL too long")); 234 + } 235 + let room_id = RoomId::parse(&req.room_id) 236 + .ok_or_else(|| err_response(StatusCode::BAD_REQUEST, "invalid room ID"))?; 237 + if !state.rooms.exists(&room_id).await { 238 + return Err(err_response(StatusCode::NOT_FOUND, "room not found")); 239 + } 240 + state.rooms.add_playlist_entry(&room_id, req.url).await; 241 + Ok(Json(serde_json::json!({ "ok": true }))) 242 + } 243 + 244 + #[derive(serde::Deserialize)] 245 + struct RemovePlaylistReq { 246 + room_id: String, 247 + id: String, 248 + } 249 + 250 + async fn remove_playlist_entry( 251 + State(state): State<AppState>, 252 + axum::Json(req): axum::Json<RemovePlaylistReq>, 253 + ) -> Result<Json<serde_json::Value>, axum::response::Response> { 254 + let room_id = RoomId::parse(&req.room_id) 255 + .ok_or_else(|| err_response(StatusCode::BAD_REQUEST, "invalid room ID"))?; 256 + let entry_id = PlaylistEntryId::parse(&req.id) 257 + .ok_or_else(|| err_response(StatusCode::BAD_REQUEST, "invalid entry ID"))?; 258 + if !state.rooms.exists(&room_id).await { 259 + return Err(err_response(StatusCode::NOT_FOUND, "room not found")); 260 + } 261 + state.rooms.remove_playlist_entry(&room_id, entry_id).await; 262 + Ok(Json(serde_json::json!({ "ok": true }))) 263 + } 264 + 223 265 async fn ws_handler( 224 266 ws: WebSocketUpgrade, 225 267 Path(room_id): Path<String>, 226 268 Query(params): Query<HashMap<String, String>>, 227 269 State(state): State<AppState>, 228 270 ) -> Result<impl IntoResponse, (StatusCode, &'static str)> { 229 - if !room_id.chars().all(|c| c.is_ascii_digit()) { 230 - return Err((StatusCode::BAD_REQUEST, "invalid room ID")); 231 - } 232 - 233 - let rid = RoomId(room_id); 271 + let rid = RoomId::parse(&room_id).ok_or((StatusCode::BAD_REQUEST, "invalid room ID"))?; 234 272 235 273 if !state.rooms.exists(&rid).await { 236 274 return Err((StatusCode::NOT_FOUND, "room not found")); ··· 270 308 Path(room_id): Path<String>, 271 309 State(state): State<AppState>, 272 310 ) -> Result<impl IntoResponse, (StatusCode, &'static str)> { 273 - if !room_id.chars().all(|c| c.is_ascii_digit()) { 274 - return Err((StatusCode::BAD_REQUEST, "invalid room ID")); 275 - } 276 - 277 - let rid = RoomId(room_id); 311 + let rid = RoomId::parse(&room_id).ok_or((StatusCode::BAD_REQUEST, "invalid room ID"))?; 278 312 279 313 if !state.rooms.exists(&rid).await { 280 314 return Err((StatusCode::NOT_FOUND, "room not found")); ··· 290 324 crate::transport::handle_video_session(socket, handle.publishers).await; 291 325 })) 292 326 } 327 + 328 + #[cfg(test)] 329 + mod tests { 330 + use super::*; 331 + use axum::body::{Body, to_bytes}; 332 + use axum::http::{Request, header}; 333 + use std::path::PathBuf; 334 + use tower::ServiceExt; 335 + 336 + fn test_registry() -> room::Registry { 337 + let store = Arc::new(crate::store::InMemoryStore::new()); 338 + let sources = Arc::new(crate::source::SourceRegistry::new()); 339 + room::Registry::new(store, PathBuf::from("/tmp/moqbox-test"), sources) 340 + } 341 + 342 + fn test_app(registry: room::Registry) -> Router { 343 + let state = AppState { 344 + rooms: registry, 345 + rate_limiter: None, 346 + }; 347 + Router::new() 348 + .route("/api/ingest", post(ingest_url)) 349 + .with_state(state) 350 + } 351 + 352 + fn ingest_request(room_id: &str, url: &str) -> Request<Body> { 353 + let body = serde_json::json!({ "url": url, "room_id": room_id }).to_string(); 354 + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 0)); 355 + Request::builder() 356 + .method("POST") 357 + .uri("/api/ingest") 358 + .header(header::CONTENT_TYPE, "application/json") 359 + .extension(ConnectInfo(addr)) 360 + .body(Body::from(body)) 361 + .unwrap() 362 + } 363 + 364 + async fn response_body(res: axum::response::Response) -> String { 365 + let bytes = to_bytes(res.into_body(), 4096).await.unwrap(); 366 + String::from_utf8_lossy(&bytes).into_owned() 367 + } 368 + 369 + #[tokio::test] 370 + async fn ingest_accepts_uuid_room_id() { 371 + let reg = test_registry(); 372 + let room_id = reg.create().await.to_string(); 373 + let app = test_app(reg); 374 + 375 + let res = app 376 + .oneshot(ingest_request(&room_id, "https://example.com/track.mp3")) 377 + .await 378 + .unwrap(); 379 + 380 + assert_eq!(res.status(), StatusCode::OK); 381 + assert!(response_body(res).await.contains(r#""ok":true"#)); 382 + } 383 + 384 + #[tokio::test] 385 + async fn ingest_rejects_non_uuid_room_id() { 386 + let app = test_app(test_registry()); 387 + 388 + for bad_id in ["abc", "not-a-uuid"] { 389 + let res = app 390 + .clone() 391 + .oneshot(ingest_request(bad_id, "https://example.com/track.mp3")) 392 + .await 393 + .unwrap(); 394 + 395 + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "room_id={bad_id}"); 396 + assert!( 397 + response_body(res).await.contains("invalid room ID"), 398 + "room_id={bad_id}" 399 + ); 400 + } 401 + } 402 + }
+767
templates/partials/room_scripts.html
··· 1 + function encVarint(n) { 2 + if (n < 64) return new Uint8Array([n]); 3 + if (n < 16384) return new Uint8Array([(n >> 8) | 0x40, n & 0xff]); 4 + if (n < 1073741824) 5 + return new Uint8Array([(n >> 24) | 0x80, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]); 6 + const b = new Uint8Array(8); 7 + b[0] = (n >> 56) | 0xc0; 8 + b[1] = (n >> 48) & 0xff; 9 + b[2] = (n >> 40) & 0xff; 10 + b[3] = (n >> 32) & 0xff; 11 + b[4] = (n >> 24) & 0xff; 12 + b[5] = (n >> 16) & 0xff; 13 + b[6] = (n >> 8) & 0xff; 14 + b[7] = n & 0xff; 15 + return b; 16 + } 17 + function encString(s) { 18 + const bytes = new TextEncoder().encode(s); 19 + const len = encVarint(bytes.length); 20 + const out = new Uint8Array(len.length + bytes.length); 21 + out.set(len); 22 + out.set(bytes, len.length); 23 + return out; 24 + } 25 + function concat(arrays) { 26 + const total = arrays.reduce((sum, arr) => sum + arr.length, 0); 27 + const out = new Uint8Array(total); 28 + let off = 0; 29 + for (const a of arrays) { 30 + out.set(a, off); 31 + off += a.length; 32 + } 33 + return out; 34 + } 35 + 36 + function decVarint(buf, off) { 37 + const first = buf[off]; 38 + const tag = first >> 6; 39 + if (tag === 0) return [first & 0x3f, off + 1]; 40 + if (tag === 1) return [((first & 0x3f) << 8) | buf[off + 1], off + 2]; 41 + if (tag === 2) 42 + return [ 43 + ((first & 0x3f) << 24) | (buf[off + 1] << 16) | (buf[off + 2] << 8) | buf[off + 3], 44 + off + 4, 45 + ]; 46 + let val = BigInt(first & 0x3f) << 56n; 47 + for (let i = 1; i < 8; i++) val |= BigInt(buf[off + i]) << BigInt(8 * (7 - i)); 48 + return [Number(val), off + 8]; 49 + } 50 + function decString(buf, off) { 51 + const [len, o] = decVarint(buf, off); 52 + const str = new TextDecoder().decode(buf.slice(o, o + len)); 53 + return [str, o + len]; 54 + } 55 + 56 + const DEBUG = 57 + new URLSearchParams(location.search).has("debug") || localStorage.getItem("moqbox_debug"); 58 + function debug(...args) { 59 + if (DEBUG) console.log(...args); 60 + } 61 + 62 + const roomId = "{{ room_id }}"; 63 + let userName = sessionStorage.getItem("moqbox_user"); 64 + if (!userName) { 65 + userName = prompt("Enter your name:", "anonymous") || "anonymous"; 66 + sessionStorage.setItem("moqbox_user", userName); 67 + } 68 + 69 + const proto = location.protocol === "https:" ? "wss:" : "ws:"; 70 + let ws = null; 71 + let reconnectDelay = 1000; 72 + 73 + let mediaSource = null; 74 + let sourceBuffer = null; 75 + const pendingBoxes = []; // buffered while waiting for sourceopen 76 + let currentTrackId = null; // tracks the currently playing track (from state) 77 + let awaitingInit = true; // true until an init segment is processed 78 + let seekInitDone = false; // true after first seek on join or track change 79 + 80 + function initVideo() { 81 + // Guard: don't create a second player if already initialized. 82 + if (document.getElementById("video-player")) return; 83 + debug("initVideo called"); 84 + const container = 85 + document.getElementById("video-container") || document.querySelector(".main-col"); 86 + const video = document.createElement("video"); 87 + video.id = "video-player"; 88 + video.controls = false; 89 + video.autoplay = true; 90 + video.muted = true; 91 + container.insertBefore(video, container.firstChild); 92 + 93 + // Wire up volume controls (element is in HTML, not created in JS) 94 + const volSlider = document.getElementById("vol-slider"); 95 + const volIcon = document.getElementById("vol-icon"); 96 + // Starts muted; user must gesture (click icon or drag slider) to enable audio. 97 + volSlider.addEventListener("input", () => { 98 + const v = parseFloat(volSlider.value); 99 + video.volume = v; 100 + video.muted = v === 0; 101 + volIcon.textContent = v === 0 ? "\u{1F507}" : v < 0.5 ? "\u{1F509}" : "\u{1F50A}"; 102 + }); 103 + volIcon.addEventListener("click", () => { 104 + if (video.muted) { 105 + video.muted = false; 106 + video.volume = 1; 107 + volSlider.value = "1"; 108 + volIcon.textContent = "\u{1F50A}"; 109 + } else { 110 + video.muted = true; 111 + volSlider.value = "0"; 112 + volIcon.textContent = "\u{1F507}"; 113 + } 114 + }); 115 + 116 + mediaSource = new MediaSource(); 117 + mediaSource.addEventListener("sourceopen", () => { 118 + debug("MediaSource sourceopen"); 119 + try { 120 + sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.4d0028,mp4a.40.2"'); 121 + sourceBuffer.mode = "segments"; 122 + sourceBuffer.addEventListener("updateend", () => { 123 + if (pendingBoxes.length > 0) { 124 + const next = pendingBoxes.shift(); 125 + try { 126 + sourceBuffer.appendBuffer(next); 127 + } catch (e) { 128 + console.warn("SourceBuffer append failed:", e); 129 + } 130 + } 131 + if (!seekInitDone) { 132 + const v = document.getElementById("video-player"); 133 + if (v && v.buffered.length > 0) { 134 + seekInitDone = true; 135 + v.currentTime = v.buffered.start(0); 136 + } 137 + } 138 + }); 139 + // Flush boxes that arrived before sourceopen. 140 + while (pendingBoxes.length > 0) { 141 + const box = pendingBoxes.shift(); 142 + if (!sourceBuffer.updating) { 143 + try { 144 + sourceBuffer.appendBuffer(box); 145 + } catch (e) { 146 + console.warn("SourceBuffer flush failed:", e); 147 + } 148 + } else { 149 + pendingBoxes.unshift(box); 150 + break; 151 + } 152 + } 153 + } catch (e) { 154 + console.warn("MediaSource not supported:", e); 155 + } 156 + }); 157 + video.src = URL.createObjectURL(mediaSource); 158 + } 159 + initVideo(); 160 + 161 + function appendVideo(data) { 162 + if (sourceBuffer && !sourceBuffer.updating) { 163 + try { 164 + sourceBuffer.appendBuffer(data); 165 + } catch (e) { 166 + console.warn("SourceBuffer append failed:", e); 167 + } 168 + } else { 169 + pendingBoxes.push(data); 170 + } 171 + } 172 + 173 + function subscribeTracks() { 174 + // Subscribe to chat track. 175 + ws.send( 176 + concat([encVarint(0x01), encString("moqbox/room/" + roomId + "/chat"), encString("chat")]), 177 + ); 178 + // Subscribe to state track for now-playing updates. 179 + ws.send( 180 + concat([ 181 + encVarint(0x01), 182 + encString("moqbox/room/" + roomId + "/state"), 183 + encString("state"), 184 + ]), 185 + ); 186 + } 187 + 188 + function updateConnStatus(connected) { 189 + const el = document.getElementById("conn-status"); 190 + if (!el) return; 191 + el.className = "conn-status " + (connected ? "connected" : "disconnected"); 192 + el.textContent = connected ? "connected" : "disconnected"; 193 + } 194 + 195 + function connect() { 196 + ws = new WebSocket( 197 + proto + "//" + location.host + "/ws/" + roomId + "?name=" + encodeURIComponent(userName), 198 + ); 199 + ws.binaryType = "arraybuffer"; 200 + 201 + ws.onopen = () => { 202 + debug("WS connected"); 203 + updateConnStatus(true); 204 + reconnectDelay = 1000; 205 + if (elapsedInterval) { clearInterval(elapsedInterval); elapsedInterval = null; } 206 + currentTrackId = null; 207 + subscribeTracks(); 208 + }; 209 + 210 + ws.onmessage = (event) => { 211 + const buf = new Uint8Array(event.data); 212 + let off = 0; 213 + while (off < buf.length) { 214 + const [msgType, o1] = decVarint(buf, off); 215 + if (msgType === 0x00) { 216 + const [trackId, o2] = decVarint(buf, o1); 217 + const [groupId, o3] = decVarint(buf, o2); 218 + const [objectId, o4] = decVarint(buf, o3); 219 + if (trackId === 2) { 220 + const payload = new TextDecoder().decode(buf.slice(o4)); 221 + try { 222 + const chat = JSON.parse(payload); 223 + appendChat(chat); 224 + } catch (_) {} 225 + off = buf.length; 226 + } else if (trackId === 3) { 227 + const payload = new TextDecoder().decode(buf.slice(o4)); 228 + try { 229 + const state = JSON.parse(payload); 230 + updateNowPlaying(state); 231 + } catch (_) {} 232 + off = buf.length; 233 + } else { 234 + off = buf.length; 235 + } 236 + } else if (msgType === 0x02) { 237 + const [ns, o2] = decString(buf, o1); 238 + const [tr, o3] = decString(buf, o2); 239 + off = o3; 240 + } else if (msgType === 0x06) { 241 + const [ns, o2] = decString(buf, o1); 242 + const [tr, o3] = decString(buf, o2); 243 + const [reason, o4] = decString(buf, o3); 244 + off = o4; 245 + } else { 246 + break; 247 + } 248 + } 249 + }; 250 + 251 + ws.onclose = () => { 252 + updateConnStatus(false); 253 + setTimeout(() => { 254 + reconnectDelay = Math.min(reconnectDelay * 2, 30000); 255 + connect(); 256 + }, reconnectDelay); 257 + }; 258 + } 259 + 260 + connect(); 261 + 262 + let videoWs = null; 263 + let videoReconnectDelay = 1000; 264 + 265 + function connectVideoWs() { 266 + videoWs = new WebSocket(proto + "//" + location.host + "/ws/" + roomId + "/video"); 267 + videoWs.binaryType = "arraybuffer"; 268 + 269 + videoWs.onopen = () => { 270 + debug("video WS connected"); 271 + videoReconnectDelay = 1000; 272 + awaitingInit = true; 273 + pendingBoxes.length = 0; 274 + seekInitDone = false; 275 + }; 276 + 277 + videoWs.onmessage = (event) => { 278 + const data = new Uint8Array(event.data); 279 + 280 + // Always detect init segments, even when not awaitingInit. 281 + // The video WS and control WS are independent connections with 282 + // no ordering guarantee — init can arrive before the state update. 283 + const isInit = 284 + data.length >= 8 && String.fromCharCode(data[4], data[5], data[6], data[7]) === "ftyp"; 285 + 286 + if (isInit) { 287 + debug("init segment received (video WS)"); 288 + awaitingInit = false; 289 + pendingBoxes.length = 0; 290 + if (sourceBuffer && !sourceBuffer.updating) { 291 + try { 292 + sourceBuffer.abort(); 293 + if (sourceBuffer.buffered.length > 0) { 294 + const end = sourceBuffer.buffered.end(sourceBuffer.buffered.length - 1); 295 + sourceBuffer.remove(0, end); 296 + } 297 + } catch (_) {} 298 + } 299 + appendVideo(data); 300 + return; 301 + } 302 + 303 + if (awaitingInit) { 304 + pendingBoxes.push(data); 305 + return; 306 + } 307 + 308 + appendVideo(data); 309 + }; 310 + 311 + videoWs.onclose = () => { 312 + setTimeout(() => { 313 + videoReconnectDelay = Math.min(videoReconnectDelay * 2, 30000); 314 + connectVideoWs(); 315 + }, videoReconnectDelay); 316 + }; 317 + } 318 + 319 + // Open video connection after a short delay to let the control WS 320 + // establish the room subscription first. 321 + setTimeout(connectVideoWs, 100); 322 + 323 + function copyRoomLink() { 324 + const url = window.location.href; 325 + navigator.clipboard 326 + .writeText(url) 327 + .then(() => { 328 + const btn = document.getElementById("copy-link"); 329 + if (!btn) return; 330 + const original = btn.innerHTML; 331 + btn.innerHTML = '<span class="icon">&#x2713;</span>'; 332 + setTimeout(() => { 333 + btn.innerHTML = original; 334 + }, 1500); 335 + }) 336 + .catch(() => { 337 + const input = document.createElement("input"); 338 + input.value = url; 339 + document.body.appendChild(input); 340 + input.select(); 341 + document.execCommand("copy"); 342 + document.body.removeChild(input); 343 + }); 344 + } 345 + 346 + function appendChat(chat) { 347 + const container = document.getElementById("chat-messages"); 348 + const div = document.createElement("div"); 349 + div.className = "chat-msg" + (chat.type === "system_event" ? " system" : ""); 350 + 351 + let ts = ""; 352 + if (chat.ts) { 353 + const d = new Date(chat.ts); 354 + ts = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 355 + } 356 + 357 + div.innerHTML = 358 + '<span class="user">' + 359 + esc(chat.user || "anonymous") + 360 + "</span>" + 361 + '<span class="time">' + 362 + ts + 363 + "</span><br>" + 364 + '<span class="text">' + 365 + esc(chat.content || "") + 366 + "</span>"; 367 + container.appendChild(div); 368 + 369 + // Auto-scroll only if user is near the bottom (within 30px threshold). 370 + const threshold = 30; 371 + if (container.scrollHeight - container.scrollTop - container.clientHeight < threshold) { 372 + container.scrollTop = container.scrollHeight; 373 + } 374 + } 375 + 376 + let elapsedInterval = null; 377 + 378 + function parseDuration(s) { 379 + // Converts "M:SS" or "H:MM:SS" to seconds 380 + const parts = s.split(":").map(Number); 381 + if (parts.length === 2) return parts[0] * 60 + parts[1]; 382 + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; 383 + return 0; 384 + } 385 + 386 + function updateNowPlaying(state) { 387 + const container = document.getElementById("video-container"); 388 + if (!container) return; 389 + 390 + // Update room name in top bar from state 391 + if (state.room_name) { 392 + const nameEl = document.getElementById("room-name"); 393 + if (nameEl) nameEl.textContent = state.room_name; 394 + } 395 + 396 + // Update viewer count pill 397 + const pill = document.getElementById("viewer-pill"); 398 + if (pill) { 399 + const c = state.clients || 0; 400 + pill.textContent = c === 1 ? "1 viewer" : c + " viewers"; 401 + } 402 + 403 + if (state.current_track) { 404 + const trackId = state.current_track.id; 405 + const trackChanged = currentTrackId !== trackId; 406 + 407 + // Detect track change — reset video state. 408 + if (currentTrackId !== null && trackChanged) { 409 + debug("track change", currentTrackId, "->", trackId); 410 + awaitingInit = true; 411 + pendingBoxes.length = 0; 412 + seekInitDone = false; 413 + if (elapsedInterval) { 414 + clearInterval(elapsedInterval); 415 + elapsedInterval = null; 416 + } 417 + } 418 + currentTrackId = trackId; 419 + 420 + // Skip overlay DOM work when the track hasn't changed. 421 + // Queue display is still updated below. 422 + if (!trackChanged) { 423 + if (state.current_track) { 424 + const toThumb = document.getElementById("to-thumb"); 425 + const toTitle = document.getElementById("to-title"); 426 + if (toThumb) { 427 + if (state.current_track.thumbnail) { 428 + toThumb.src = state.current_track.thumbnail; 429 + toThumb.style.display = "block"; 430 + } else { 431 + toThumb.style.display = "none"; 432 + } 433 + } 434 + if (toTitle) toTitle.textContent = state.current_track.title; 435 + } 436 + updateQueueDisplay(state); 437 + updatePlaylistDisplay(state); 438 + return; 439 + } 440 + 441 + const startedAt = state.current_track.started_at; 442 + const sourceUrl = state.current_track.url || ""; 443 + 444 + // Show track overlay, hide idle message 445 + container.classList.remove("idle"); 446 + 447 + const toThumb = document.getElementById("to-thumb"); 448 + const toTitle = document.getElementById("to-title"); 449 + const toElapsed = document.getElementById("to-elapsed"); 450 + const toDuration = document.getElementById("to-duration"); 451 + const toViewers = document.getElementById("to-viewers"); 452 + const toSource = document.getElementById("to-source"); 453 + const toFill = document.getElementById("to-progress-fill"); 454 + const toOverlay = document.getElementById("track-overlay"); 455 + 456 + // Thumbnail 457 + if (state.current_track.thumbnail) { 458 + toThumb.src = state.current_track.thumbnail; 459 + toThumb.style.display = "block"; 460 + } else { 461 + toThumb.style.display = "none"; 462 + } 463 + 464 + // Title 465 + toTitle.textContent = state.current_track.title; 466 + 467 + // Duration 468 + toDuration.textContent = " / " + state.current_track.duration; 469 + 470 + // Source link 471 + if (sourceUrl) { 472 + toSource.href = sourceUrl; 473 + toSource.textContent = "source"; 474 + toSource.style.display = "inline"; 475 + } else { 476 + toSource.style.display = "none"; 477 + } 478 + 479 + // Rotation add button 480 + const toRot = document.getElementById("to-rot"); 481 + if (toRot) { 482 + toRot.style.display = sourceUrl ? "inline-flex" : "none"; 483 + toRot.onclick = () => addToRotation(sourceUrl); 484 + } 485 + 486 + // Viewers 487 + toViewers.textContent = state.clients 488 + ? " · " + state.clients + " viewer" + (state.clients !== 1 ? "s" : "") 489 + : ""; 490 + 491 + // Elapsed (initial) 492 + toElapsed.textContent = formatTime((Date.now() - startedAt) / 1000); 493 + 494 + // Ensure overlay is visible on first play 495 + toOverlay.classList.add("show"); 496 + setTimeout(() => toOverlay.classList.remove("show"), 3000); 497 + 498 + // Update elapsed every second from local clock. 499 + elapsedInterval = setInterval(() => { 500 + const elapsed = (Date.now() - startedAt) / 1000; 501 + const elSpan = document.getElementById("to-elapsed"); 502 + if (elSpan) elSpan.textContent = formatTime(elapsed); 503 + 504 + const fill = document.getElementById("to-progress-fill"); 505 + if (fill && state.current_track) { 506 + const total = parseDuration(state.current_track.duration); 507 + if (total > 0) { 508 + fill.style.width = Math.min(100, (elapsed / total) * 100) + "%"; 509 + } 510 + } 511 + }, 1000); 512 + 513 + // Forward-only sync: if video is more than 3 seconds behind 514 + // the live edge, seek forward to catch up. Never seek backward. 515 + const video = document.getElementById("video-player"); 516 + if (video && video.buffered.length > 0 && startedAt) { 517 + const target = (Date.now() - startedAt) / 1000; 518 + if (target > 0) { 519 + const behind = target - video.currentTime; 520 + if (behind > 3) { 521 + video.currentTime = target; 522 + } 523 + } 524 + } 525 + } else { 526 + if (elapsedInterval) { 527 + clearInterval(elapsedInterval); 528 + elapsedInterval = null; 529 + } 530 + currentTrackId = null; 531 + container.classList.add("idle"); 532 + } 533 + 534 + // Update queue and playlist from state. 535 + updateQueueDisplay(state); 536 + updatePlaylistDisplay(state); 537 + } 538 + 539 + function formatTime(secs) { 540 + const m = Math.floor(secs / 60); 541 + const s = Math.floor(secs % 60); 542 + return m + ":" + String(s).padStart(2, "0"); 543 + } 544 + 545 + function esc(s) { 546 + const d = document.createElement("div"); 547 + d.textContent = s; 548 + return d.innerHTML; 549 + } 550 + 551 + function safeUrl(url) { 552 + if (!url) return ""; 553 + if (url.startsWith("http://") || url.startsWith("https://")) { 554 + return esc(url); 555 + } 556 + return ""; 557 + } 558 + 559 + function renderTrackItem(track, actions, extraCls) { 560 + const cls = extraCls || ""; 561 + const thumb = track.thumbnail 562 + ? '<img src="' + esc(track.thumbnail) + '" class="thumb-img" alt="">' 563 + : '<div class="thumb"></div>'; 564 + let btns = ""; 565 + for (const a of actions) { 566 + if (a.cls === "source-link") { 567 + btns += 568 + '<a href="' + 569 + safeUrl(track.url) + 570 + '" target="_blank" class="source-link" rel="noopener noreferrer">source</a>'; 571 + } else { 572 + btns += 573 + '<button class="' + 574 + a.cls + 575 + '" data-track-url="' + 576 + esc(String(track.url || "")) + 577 + '" data-track-id="' + 578 + (track.id ?? "") + 579 + '" title="' + 580 + a.title + 581 + '">' + 582 + a.label + 583 + "</button>"; 584 + } 585 + } 586 + return ( 587 + '<li class="' + 588 + cls + 589 + '">' + 590 + thumb + 591 + '<div class="title">' + 592 + esc(track.title || "Loading...") + 593 + "</div>" + 594 + '<div class="duration">' + 595 + esc(track.duration || "--:--") + 596 + "</div>" + 597 + btns + 598 + "</li>" 599 + ); 600 + } 601 + 602 + function updateQueueDisplay(state) { 603 + const qSection = document.getElementById("queue-section"); 604 + const hSection = document.getElementById("history-section"); 605 + if (!qSection) return; 606 + 607 + const rotAction = { cls: "rot-add", label: "+", title: "Add to Rotation" }; 608 + const linkAction = { cls: "source-link", label: "source", title: "Open source" }; 609 + 610 + if (state.queue && state.queue.length > 0) { 611 + let html = '<ul class="queue">'; 612 + for (const item of state.queue) { 613 + html += renderTrackItem(item, [linkAction, rotAction], item.pending ? "pending" : ""); 614 + } 615 + html += "</ul>"; 616 + qSection.innerHTML = html; 617 + } else if (state.current_track) { 618 + qSection.innerHTML = 619 + '<div class="empty-state"><div class="icon">&#x25B6;</div><div class="label">No upcoming tracks</div><div class="hint">Add another link to keep the queue going.</div></div>'; 620 + } else { 621 + qSection.innerHTML = 622 + '<div class="empty-state"><div class="icon">&#x1F3B5;</div><div class="label">Queue is empty</div><div class="hint">Paste a link from YouTube, SoundCloud, or Bandcamp above to get started.</div></div>'; 623 + } 624 + 625 + if (hSection && state.history && state.history.length > 0) { 626 + let html = '<div class="history-header">Played</div><ul class="queue">'; 627 + for (const item of state.history) { 628 + html += renderTrackItem(item, [linkAction, rotAction], "history-item"); 629 + } 630 + html += "</ul>"; 631 + hSection.innerHTML = html; 632 + } else if (hSection) { 633 + hSection.innerHTML = ""; 634 + } 635 + } 636 + 637 + function updatePlaylistDisplay(state) { 638 + const container = document.getElementById("playlist-items"); 639 + if (!container) return; 640 + if (!state.playlist || state.playlist.length === 0) { 641 + container.innerHTML = '<div class="playlist-empty">Rotation is empty</div>'; 642 + return; 643 + } 644 + const removeAction = { cls: "playlist-remove", label: "\u2715", title: "Remove from Rotation" }; 645 + const linkAction = { cls: "source-link", label: "source", title: "Open source" }; 646 + let html = '<ul class="queue">'; 647 + for (const entry of state.playlist) { 648 + html += renderTrackItem(entry, [linkAction, removeAction]); 649 + } 650 + html += "</ul>"; 651 + container.innerHTML = html; 652 + } 653 + 654 + document.addEventListener("click", (e) => { 655 + const btn = e.target.closest(".rot-add, .playlist-remove"); 656 + if (!btn) return; 657 + const url = btn.dataset.trackUrl; 658 + const id = btn.dataset.trackId; 659 + if (btn.classList.contains("rot-add") && url) { 660 + e.preventDefault(); 661 + addToRotation(url); 662 + } else if (btn.classList.contains("playlist-remove") && id) { 663 + e.preventDefault(); 664 + removeFromPlaylist(id); 665 + } 666 + }); 667 + 668 + // Send chat 669 + document.getElementById("chat-send").addEventListener("click", sendChat); 670 + document.getElementById("chat-input").addEventListener("keydown", (e) => { 671 + if (e.key === "Enter") sendChat(); 672 + }); 673 + 674 + function sendChat() { 675 + const input = document.getElementById("chat-input"); 676 + const content = input.value.trim(); 677 + if (!content) return; 678 + const payload = new TextEncoder().encode(JSON.stringify({ content })); 679 + const obj = concat([ 680 + encVarint(0x00), 681 + encVarint(2), // track_id = Chat 682 + encVarint(0), // group_id 683 + encVarint(0), // object_id 684 + payload, 685 + ]); 686 + ws.send(obj); 687 + input.value = ""; 688 + } 689 + 690 + async function ingest() { 691 + const input = document.getElementById("url-input"); 692 + const btn = document.getElementById("queue-add-btn"); 693 + if (!input.value.trim()) return; 694 + btn.disabled = true; 695 + btn.textContent = "Adding…"; 696 + try { 697 + const resp = await fetch("/api/ingest", { 698 + method: "POST", 699 + headers: { "Content-Type": "application/json" }, 700 + body: JSON.stringify({ url: input.value, room_id: roomId }), 701 + }); 702 + if (resp.ok) { 703 + input.value = ""; 704 + } else { 705 + const err = await resp.json(); 706 + alert(err.error || "ingest failed"); 707 + } 708 + } finally { 709 + btn.disabled = false; 710 + btn.textContent = "Add"; 711 + } 712 + } 713 + 714 + async function skip() { 715 + await fetch("/api/skip", { 716 + method: "POST", 717 + headers: { "Content-Type": "application/json" }, 718 + body: JSON.stringify({ room_id: roomId }), 719 + }); 720 + } 721 + 722 + async function addToPlaylist() { 723 + const input = document.getElementById("playlist-input"); 724 + const btn = document.querySelector("#playlist-section .btn-primary"); 725 + if (!input.value.trim()) return; 726 + btn.disabled = true; 727 + btn.textContent = "Adding…"; 728 + try { 729 + const resp = await fetch("/api/playlist/add", { 730 + method: "POST", 731 + headers: { "Content-Type": "application/json" }, 732 + body: JSON.stringify({ url: input.value, room_id: roomId }), 733 + }); 734 + if (resp.ok) { 735 + input.value = ""; 736 + } else { 737 + const err = await resp.json(); 738 + alert(err.error || "add to playlist failed"); 739 + } 740 + } finally { 741 + btn.disabled = false; 742 + btn.textContent = "Add"; 743 + } 744 + } 745 + 746 + async function removeFromPlaylist(id) { 747 + await fetch("/api/playlist/remove", { 748 + method: "POST", 749 + headers: { "Content-Type": "application/json" }, 750 + body: JSON.stringify({ room_id: roomId, id }), 751 + }); 752 + } 753 + 754 + async function addToRotation(url) { 755 + await fetch("/api/playlist/add", { 756 + method: "POST", 757 + headers: { "Content-Type": "application/json" }, 758 + body: JSON.stringify({ url, room_id: roomId }), 759 + }); 760 + } 761 + 762 + document.getElementById("playlist-input").addEventListener("keydown", (e) => { 763 + if (e.key === "Enter") addToPlaylist(); 764 + }); 765 + document.getElementById("url-input").addEventListener("keydown", (e) => { 766 + if (e.key === "Enter") ingest(); 767 + });
+874
templates/partials/room_styles.html
··· 1 + :root { 2 + --base: #24273a; 3 + --mantle: #1e2030; 4 + --crust: #181926; 5 + --surface0: #363a4f; 6 + --surface1: #494d64; 7 + --surface2: #5b6078; 8 + --overlay0: #6c7086; 9 + --overlay1: #7f849c; 10 + --overlay2: #939ab7; 11 + --subtext0: #a5adcb; 12 + --subtext1: #b8c0e0; 13 + --text: #cad3f5; 14 + --lavender: #b7bdf8; 15 + --blue: #8aadf4; 16 + --sapphire: #7dc4e4; 17 + --sky: #91d7e3; 18 + --teal: #8bd5ca; 19 + --green: #a6da95; 20 + --yellow: #eed49f; 21 + --peach: #f5a97f; 22 + --maroon: #ee99a0; 23 + --red: #ed8796; 24 + --mauve: #c6a0f6; 25 + --pink: #f5bde6; 26 + --flamingo: #f0c6c6; 27 + --rosewater: #f4dbd6; 28 + --radius-sm: 6px; 29 + --radius-md: 10px; 30 + --radius-lg: 14px; 31 + --font-sans: "Space Grotesk", system-ui, -apple-system, sans-serif; 32 + --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(198, 160, 246, 0.04); 33 + --shadow-card-hover: 0 4px 16px rgba(0, 0, 0, 0.35), 0 1px 4px rgba(198, 160, 246, 0.06); 34 + --shadow-elevated: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); 35 + --shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.3); 36 + } 37 + *, 38 + *::before, 39 + *::after { 40 + box-sizing: border-box; 41 + margin: 0; 42 + padding: 0; 43 + } 44 + html { 45 + -webkit-font-smoothing: antialiased; 46 + -moz-osx-font-smoothing: grayscale; 47 + } 48 + html, 49 + body { 50 + height: 100%; 51 + } 52 + body { 53 + font-family: var(--font-sans); 54 + background: var(--base); 55 + color: var(--text); 56 + padding: 1rem 1.5rem; 57 + display: flex; 58 + flex-direction: column; 59 + } 60 + 61 + /* Top bar */ 62 + .top-bar { 63 + display: flex; 64 + align-items: center; 65 + justify-content: space-between; 66 + margin-bottom: 1rem; 67 + gap: 1rem; 68 + } 69 + .top-bar-home { 70 + color: var(--overlay1); 71 + text-decoration: none; 72 + font-size: 0.875rem; 73 + font-weight: 600; 74 + letter-spacing: 0.02em; 75 + text-transform: uppercase; 76 + flex-shrink: 0; 77 + transition: color 150ms ease-out; 78 + } 79 + .top-bar-home:hover { 80 + color: var(--text); 81 + } 82 + .top-bar-center { 83 + flex: 1; 84 + text-align: center; 85 + min-width: 0; 86 + } 87 + .room-name { 88 + display: block; 89 + font-weight: 700; 90 + font-size: 1.125rem; 91 + color: var(--text); 92 + line-height: 1.3; 93 + text-wrap: balance; 94 + } 95 + .room-id { 96 + display: block; 97 + font-size: 0.75rem; 98 + color: var(--overlay0); 99 + font-variant-numeric: tabular-nums; 100 + letter-spacing: 0.03em; 101 + } 102 + .top-bar-actions { 103 + display: flex; 104 + align-items: center; 105 + gap: 0.5rem; 106 + flex-shrink: 0; 107 + } 108 + .btn-icon { 109 + display: inline-flex; 110 + align-items: center; 111 + justify-content: center; 112 + width: 36px; 113 + height: 36px; 114 + border-radius: var(--radius-sm); 115 + border: none; 116 + background: var(--surface0); 117 + color: var(--subtext0); 118 + cursor: pointer; 119 + font-size: 1rem; 120 + line-height: 1; 121 + transition: 122 + background 150ms ease-out, 123 + color 150ms ease-out; 124 + } 125 + .btn-icon:hover { 126 + background: var(--surface1); 127 + color: var(--text); 128 + } 129 + .btn-icon:active { 130 + transform: scale(0.92); 131 + } 132 + .viewer-pill { 133 + display: inline-flex; 134 + align-items: center; 135 + gap: 0.25rem; 136 + padding: 0.25rem 0.625rem; 137 + border-radius: 999px; 138 + background: var(--surface0); 139 + color: var(--overlay1); 140 + font-size: 0.75rem; 141 + font-weight: 500; 142 + white-space: nowrap; 143 + font-variant-numeric: tabular-nums; 144 + } 145 + 146 + /* Layout */ 147 + .layout { 148 + display: flex; 149 + gap: 1rem; 150 + flex: 1; 151 + min-height: 0; 152 + } 153 + .main-col { 154 + flex: 1; 155 + min-width: 0; 156 + display: flex; 157 + flex-direction: column; 158 + overflow-y: auto; 159 + } 160 + .chat-col { 161 + width: 320px; 162 + flex-shrink: 0; 163 + display: flex; 164 + flex-direction: column; 165 + min-height: 0; 166 + border-left: 1px solid var(--surface2); 167 + padding-left: 1rem; 168 + } 169 + 170 + /* Video */ 171 + #video-container { 172 + position: relative; 173 + width: 100%; 174 + min-height: 200px; 175 + margin-bottom: 1rem; 176 + border-radius: var(--radius-lg); 177 + overflow: hidden; 178 + background: var(--crust); 179 + box-shadow: var(--shadow-card); 180 + } 181 + #video-player { 182 + width: 100%; 183 + aspect-ratio: 16 / 9; 184 + max-height: 60vh; 185 + object-fit: contain; 186 + display: block; 187 + background: var(--crust); 188 + } 189 + /* Video idle state (no track playing) */ 190 + #video-container.idle { 191 + display: flex; 192 + align-items: center; 193 + justify-content: center; 194 + min-height: 200px; 195 + } 196 + #video-container.idle #video-player { 197 + display: none; 198 + } 199 + #video-container.idle .idle-msg { 200 + display: block; 201 + } 202 + .idle-msg { 203 + display: none; 204 + color: var(--overlay0); 205 + font-size: 0.9375rem; 206 + text-align: center; 207 + padding: 3rem 1.5rem; 208 + } 209 + .idle-msg .icon { 210 + font-size: 2rem; 211 + display: block; 212 + margin-bottom: 0.5rem; 213 + opacity: 0.5; 214 + } 215 + 216 + /* Track info overlay on video */ 217 + #track-overlay { 218 + position: absolute; 219 + bottom: 0; 220 + left: 0; 221 + right: 0; 222 + background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); 223 + padding: 2rem 1rem 0.75rem; 224 + opacity: 0; 225 + transition: opacity 200ms ease-out; 226 + pointer-events: none; 227 + z-index: 5; 228 + } 229 + #video-container:hover #track-overlay, 230 + #track-overlay.show { 231 + opacity: 1; 232 + pointer-events: auto; 233 + } 234 + .to-row { 235 + display: flex; 236 + align-items: center; 237 + gap: 0.75rem; 238 + } 239 + .to-thumb { 240 + width: 36px; 241 + height: 36px; 242 + border-radius: var(--radius-sm); 243 + object-fit: cover; 244 + flex-shrink: 0; 245 + outline: 1px solid rgba(0, 0, 0, 0.3); 246 + outline-offset: -1px; 247 + } 248 + .to-info { 249 + flex: 1; 250 + min-width: 0; 251 + } 252 + .to-title { 253 + display: block; 254 + color: #fff; 255 + font-weight: 600; 256 + font-size: 0.9375rem; 257 + line-height: 1.3; 258 + overflow: hidden; 259 + text-overflow: ellipsis; 260 + white-space: nowrap; 261 + } 262 + .to-meta { 263 + display: block; 264 + color: rgba(255, 255, 255, 0.6); 265 + font-size: 0.75rem; 266 + line-height: 1.4; 267 + } 268 + .to-meta a { 269 + color: inherit; 270 + text-decoration: underline; 271 + text-underline-offset: 2px; 272 + } 273 + .to-viewers { 274 + font-variant-numeric: tabular-nums; 275 + } 276 + #to-progress-wrap { 277 + width: 100%; 278 + height: 2px; 279 + background: rgba(255, 255, 255, 0.15); 280 + border-radius: 1px; 281 + overflow: hidden; 282 + margin-top: 0.5rem; 283 + } 284 + #to-progress-fill { 285 + height: 100%; 286 + width: 0%; 287 + background: var(--mauve); 288 + border-radius: 1px; 289 + transition: width 1s linear; 290 + } 291 + 292 + .volume-overlay { 293 + position: absolute; 294 + bottom: 0.75rem; 295 + right: 0.75rem; 296 + display: flex; 297 + align-items: center; 298 + gap: 0.5rem; 299 + background: rgba(0, 0, 0, 0.6); 300 + border-radius: var(--radius-md); 301 + padding: 0.375rem 0.625rem; 302 + opacity: 0; 303 + transition: opacity 150ms ease-out; 304 + pointer-events: none; 305 + z-index: 10; 306 + } 307 + #video-container:hover .volume-overlay { 308 + opacity: 1; 309 + pointer-events: auto; 310 + } 311 + .volume-overlay .icon { 312 + font-size: 1.125rem; 313 + line-height: 1; 314 + color: var(--text); 315 + cursor: pointer; 316 + user-select: none; 317 + } 318 + .volume-overlay input[type="range"] { 319 + width: 80px; 320 + height: 4px; 321 + accent-color: var(--blue); 322 + cursor: pointer; 323 + background: transparent; 324 + } 325 + 326 + /* Input Row */ 327 + .queue-group { 328 + margin-bottom: 0.5rem; 329 + } 330 + .input-row { 331 + display: flex; 332 + gap: 0.5rem; 333 + margin-top: 0.75rem; 334 + } 335 + .input-row input { 336 + flex: 1; 337 + font: inherit; 338 + padding: 0.75rem 1rem; 339 + border-radius: var(--radius-md); 340 + border: none; 341 + background: var(--mantle); 342 + color: var(--text); 343 + font-size: 0.9375rem; 344 + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 345 + transition: box-shadow 150ms ease-out; 346 + } 347 + .input-row input:focus { 348 + outline: none; 349 + box-shadow: 350 + inset 0 2px 4px rgba(0, 0, 0, 0.2), 351 + 0 0 0 2px var(--blue); 352 + } 353 + .input-row input::placeholder { 354 + color: var(--overlay0); 355 + } 356 + 357 + /* Buttons */ 358 + button { 359 + font: inherit; 360 + padding: 0.75rem 1.25rem; 361 + border-radius: var(--radius-md); 362 + border: none; 363 + cursor: pointer; 364 + font-weight: 600; 365 + font-size: 0.9375rem; 366 + transition: 367 + transform 150ms ease-out, 368 + box-shadow 150ms ease-out, 369 + filter 150ms ease-out; 370 + min-height: 44px; 371 + display: inline-flex; 372 + align-items: center; 373 + justify-content: center; 374 + } 375 + button:active { 376 + transform: scale(0.96); 377 + } 378 + button:disabled { 379 + opacity: 0.5; 380 + cursor: not-allowed; 381 + transform: none; 382 + filter: none; 383 + } 384 + button.btn-primary { 385 + background: var(--green); 386 + color: var(--crust); 387 + box-shadow: var(--shadow-card); 388 + } 389 + button.btn-primary:hover { 390 + filter: brightness(1.12); 391 + box-shadow: var(--shadow-card-hover); 392 + } 393 + button.btn-secondary { 394 + background: var(--surface1); 395 + color: var(--text); 396 + box-shadow: var(--shadow-card); 397 + } 398 + button.btn-secondary:hover { 399 + background: var(--surface2); 400 + filter: brightness(1.08); 401 + box-shadow: var(--shadow-card-hover); 402 + } 403 + button.btn-accent { 404 + background: var(--mauve); 405 + color: var(--crust); 406 + box-shadow: var(--shadow-card); 407 + } 408 + button.btn-accent:hover { 409 + filter: brightness(1.12); 410 + box-shadow: var(--shadow-card-hover); 411 + } 412 + 413 + /* Queue */ 414 + .queue { 415 + list-style: none; 416 + } 417 + .queue li { 418 + background: var(--surface0); 419 + border-radius: var(--radius-md); 420 + box-shadow: var(--shadow-card); 421 + padding: 0.75rem; 422 + display: flex; 423 + align-items: center; 424 + gap: 0.75rem; 425 + margin-bottom: 0.5rem; 426 + transition: 427 + background 150ms ease-out, 428 + box-shadow 150ms ease-out; 429 + animation: queueFadeIn 0.12s ease-out both; 430 + } 431 + .queue li:hover { 432 + background: var(--surface1); 433 + box-shadow: var(--shadow-card-hover); 434 + } 435 + .queue li .thumb, 436 + .queue li .thumb-img { 437 + width: 40px; 438 + height: 40px; 439 + border-radius: var(--radius-sm); 440 + background: var(--mantle); 441 + flex-shrink: 0; 442 + object-fit: cover; 443 + outline: 1px solid var(--surface2); 444 + outline-offset: -1px; 445 + } 446 + .queue li .title { 447 + flex: 1; 448 + min-width: 0; 449 + overflow: hidden; 450 + text-overflow: ellipsis; 451 + white-space: nowrap; 452 + text-wrap: pretty; 453 + } 454 + .queue li .duration { 455 + color: var(--subtext0); 456 + font-size: 0.875rem; 457 + flex-shrink: 0; 458 + font-variant-numeric: tabular-nums; 459 + } 460 + 461 + /* Source link */ 462 + .source-link { 463 + color: var(--blue); 464 + text-decoration: none; 465 + font-size: 0.8rem; 466 + padding: 6px 10px; 467 + border-radius: var(--radius-sm); 468 + background: color-mix(in srgb, var(--blue) 15%, transparent); 469 + transition: background 150ms ease-out; 470 + flex-shrink: 0; 471 + min-height: 40px; 472 + display: inline-flex; 473 + align-items: center; 474 + } 475 + .source-link:hover { 476 + background: color-mix(in srgb, var(--blue) 25%, transparent); 477 + } 478 + 479 + .history-item { 480 + opacity: 1; 481 + border-radius: var(--radius-sm); 482 + padding: 0.5rem 0.75rem; 483 + margin-bottom: 0.25rem; 484 + background: transparent; 485 + box-shadow: none; 486 + } 487 + .history-item:hover { 488 + background: transparent; 489 + box-shadow: none; 490 + } 491 + .history-item .thumb, 492 + .history-item .thumb-img { 493 + width: 28px; 494 + height: 28px; 495 + } 496 + .history-item .title { 497 + color: var(--overlay1); 498 + font-size: 0.8125rem; 499 + } 500 + .history-item .duration { 501 + color: var(--overlay0); 502 + font-size: 0.75rem; 503 + } 504 + 505 + /* Connection status */ 506 + .conn-status { 507 + font-size: 0.75rem; 508 + color: var(--overlay0); 509 + margin-bottom: 0.5rem; 510 + text-align: right; 511 + transition: color 300ms ease-out; 512 + } 513 + .conn-status.connected { 514 + color: var(--green); 515 + } 516 + .conn-status.disconnected { 517 + color: var(--red); 518 + } 519 + 520 + /* History header */ 521 + .history-header { 522 + margin-top: 1.5rem; 523 + margin-bottom: 0.5rem; 524 + padding-top: 1rem; 525 + border-top: 1px solid var(--surface2); 526 + font-size: 0.8125rem; 527 + color: var(--overlay0); 528 + font-weight: 500; 529 + text-transform: uppercase; 530 + letter-spacing: 0.04em; 531 + } 532 + 533 + /* Empty state */ 534 + .empty-state { 535 + display: flex; 536 + flex-direction: column; 537 + align-items: center; 538 + justify-content: center; 539 + gap: 0.75rem; 540 + padding: 3rem 1.5rem; 541 + text-align: center; 542 + color: var(--overlay1); 543 + font-size: 0.875rem; 544 + line-height: 1.5; 545 + background: var(--surface0); 546 + border-radius: var(--radius-lg); 547 + border: 1px dashed var(--surface2); 548 + margin-bottom: 0.5rem; 549 + } 550 + .empty-state .icon { 551 + font-size: 1.75rem; 552 + line-height: 1; 553 + opacity: 0.5; 554 + } 555 + .empty-state .label { 556 + color: var(--subtext0); 557 + font-weight: 500; 558 + } 559 + .empty-state .hint { 560 + color: var(--overlay0); 561 + font-size: 0.8125rem; 562 + max-width: 24em; 563 + } 564 + 565 + /* Chat */ 566 + .chat-messages { 567 + flex: 1; 568 + overflow-y: auto; 569 + min-height: 0; 570 + background: var(--surface0); 571 + border-radius: var(--radius-lg); 572 + box-shadow: var(--shadow-card); 573 + padding: 0.5rem; 574 + display: flex; 575 + flex-direction: column; 576 + gap: 4px; 577 + margin-bottom: 0.5rem; 578 + } 579 + .chat-messages:empty::after { 580 + content: "No messages yet"; 581 + display: flex; 582 + align-items: center; 583 + justify-content: center; 584 + height: 100%; 585 + min-height: 80px; 586 + color: var(--overlay0); 587 + font-size: 0.875rem; 588 + font-style: italic; 589 + } 590 + .chat-msg { 591 + padding: 6px 10px; 592 + border-left: 3px solid var(--blue); 593 + padding-left: 10px; 594 + font-size: 0.875rem; 595 + line-height: 1.4; 596 + } 597 + .chat-msg .user { 598 + color: var(--blue); 599 + font-weight: 600; 600 + font-size: 0.8rem; 601 + } 602 + .chat-msg .time { 603 + color: var(--overlay0); 604 + font-size: 0.7rem; 605 + float: right; 606 + margin-left: 8px; 607 + } 608 + .chat-msg .text { 609 + color: var(--text); 610 + } 611 + .chat-msg.system { 612 + border-left-color: var(--overlay0); 613 + } 614 + .chat-msg.system .user { 615 + color: var(--overlay0); 616 + } 617 + .chat-msg.system .text { 618 + color: var(--subtext0); 619 + font-style: italic; 620 + } 621 + 622 + /* Chat input */ 623 + .chat-input-row { 624 + display: flex; 625 + gap: 0.5rem; 626 + } 627 + .chat-input-row input { 628 + flex: 1; 629 + font: inherit; 630 + padding: 0.625rem 0.875rem; 631 + border-radius: var(--radius-md); 632 + border: none; 633 + background: var(--mantle); 634 + color: var(--text); 635 + font-size: 0.875rem; 636 + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 637 + transition: box-shadow 150ms ease-out; 638 + } 639 + .chat-input-row input:focus { 640 + outline: none; 641 + box-shadow: 642 + inset 0 2px 4px rgba(0, 0, 0, 0.2), 643 + 0 0 0 2px var(--blue); 644 + } 645 + .chat-input-row input::placeholder { 646 + color: var(--overlay0); 647 + } 648 + .chat-input-row button { 649 + padding: 0.625rem 1rem; 650 + min-height: 40px; 651 + background: var(--mauve); 652 + color: var(--crust); 653 + box-shadow: var(--shadow-card); 654 + } 655 + .chat-input-row button:hover { 656 + filter: brightness(1.12); 657 + box-shadow: var(--shadow-card-hover); 658 + } 659 + 660 + /* Skeleton loading state for pending queue items */ 661 + @keyframes shimmer { 662 + 0% { 663 + background-position: -200% 0; 664 + } 665 + 100% { 666 + background-position: 200% 0; 667 + } 668 + } 669 + .queue li.pending { 670 + position: relative; 671 + } 672 + .queue li.pending .title, 673 + .queue li.pending .duration { 674 + display: inline-block; 675 + border-radius: var(--radius-sm); 676 + background: linear-gradient( 677 + 90deg, 678 + var(--surface1) 25%, 679 + var(--surface2) 37%, 680 + var(--surface1) 63% 681 + ); 682 + background-size: 200% 100%; 683 + animation: shimmer 1.5s ease-in-out infinite; 684 + color: transparent !important; 685 + user-select: none; 686 + } 687 + .queue li.pending .title { 688 + min-width: 16ch; 689 + max-width: 24ch; 690 + } 691 + .queue li.pending .duration { 692 + min-width: 5ch; 693 + } 694 + .queue li.pending .source-link { 695 + visibility: hidden; 696 + } 697 + 698 + /* Stagger animation */ 699 + @keyframes queueFadeIn { 700 + from { 701 + opacity: 0.3; 702 + transform: translateY(4px); 703 + } 704 + to { 705 + opacity: 1; 706 + transform: translateY(0); 707 + } 708 + } 709 + .queue li:nth-child(1) { 710 + animation-delay: 0s; 711 + } 712 + .queue li:nth-child(2) { 713 + animation-delay: 0.01s; 714 + } 715 + .queue li:nth-child(3) { 716 + animation-delay: 0.02s; 717 + } 718 + .queue li:nth-child(4) { 719 + animation-delay: 0.03s; 720 + } 721 + .queue li:nth-child(5) { 722 + animation-delay: 0.04s; 723 + } 724 + .queue li:nth-child(6) { 725 + animation-delay: 0.05s; 726 + } 727 + .queue li:nth-child(n + 7) { 728 + animation-delay: 0.05s; 729 + } 730 + 731 + /* Playlist */ 732 + .playlist-section { 733 + margin-bottom: 0.5rem; 734 + } 735 + .playlist-section .playlist-header { 736 + margin-top: 1.5rem; 737 + margin-bottom: 0.5rem; 738 + padding-top: 1rem; 739 + border-top: 1px solid var(--surface2); 740 + font-size: 0.8125rem; 741 + color: var(--overlay0); 742 + font-weight: 500; 743 + text-transform: uppercase; 744 + letter-spacing: 0.04em; 745 + } 746 + .playlist-section .playlist-empty { 747 + color: var(--overlay0); 748 + font-size: 0.8125rem; 749 + text-align: center; 750 + padding: 1rem; 751 + font-style: italic; 752 + } 753 + .playlist-item { 754 + display: flex; 755 + align-items: center; 756 + gap: 0.75rem; 757 + padding: 0.5rem 0.75rem; 758 + border-radius: var(--radius-sm); 759 + margin-bottom: 0.175rem; 760 + transition: background 150ms ease-out; 761 + font-size: 0.875rem; 762 + } 763 + .playlist-item:hover { 764 + background: var(--surface0); 765 + } 766 + .playlist-item .title { 767 + flex: 1; 768 + min-width: 0; 769 + overflow: hidden; 770 + text-overflow: ellipsis; 771 + white-space: nowrap; 772 + color: var(--subtext1); 773 + } 774 + .playlist-item .duration { 775 + color: var(--overlay0); 776 + font-size: 0.75rem; 777 + font-variant-numeric: tabular-nums; 778 + } 779 + .playlist-remove { 780 + display: inline-flex; 781 + align-items: center; 782 + justify-content: center; 783 + width: 28px; 784 + height: 28px; 785 + border: none; 786 + border-radius: var(--radius-sm); 787 + background: transparent; 788 + color: var(--overlay0); 789 + cursor: pointer; 790 + font-size: 0.875rem; 791 + line-height: 1; 792 + flex-shrink: 0; 793 + transition: color 150ms ease-out, background 150ms ease-out; 794 + } 795 + .playlist-remove:hover { 796 + color: var(--red); 797 + background: color-mix(in srgb, var(--red) 15%, transparent); 798 + } 799 + .rot-add { 800 + display: inline-flex; 801 + align-items: center; 802 + justify-content: center; 803 + width: 28px; 804 + height: 28px; 805 + border: none; 806 + border-radius: var(--radius-sm); 807 + background: transparent; 808 + color: var(--overlay0); 809 + cursor: pointer; 810 + font-size: 0.875rem; 811 + line-height: 1; 812 + flex-shrink: 0; 813 + transition: color 150ms ease-out, background 150ms ease-out; 814 + } 815 + .rot-add:hover { 816 + color: var(--teal); 817 + background: color-mix(in srgb, var(--teal) 15%, transparent); 818 + } 819 + 820 + /* Mobile responsive */ 821 + @media (max-width: 768px) { 822 + html, 823 + body { 824 + height: auto; 825 + } 826 + body { 827 + min-height: 100vh; 828 + } 829 + .layout { 830 + flex-direction: column; 831 + overflow-y: visible; 832 + flex: none; 833 + } 834 + .main-col { 835 + overflow-y: visible; 836 + flex: none; 837 + } 838 + .chat-col { 839 + width: 100%; 840 + padding-left: 0; 841 + border-left: none; 842 + border-top: 1px solid var(--surface2); 843 + padding-top: 1rem; 844 + flex: none; 845 + } 846 + #video-player { 847 + max-height: 40vh; 848 + } 849 + #video-container.idle { 850 + min-height: 120px; 851 + } 852 + .top-bar { 853 + flex-wrap: wrap; 854 + gap: 0.5rem; 855 + } 856 + .top-bar-center { 857 + order: 3; 858 + width: 100%; 859 + text-align: left; 860 + } 861 + /* Touch devices: always show overlays (no hover dependency) */ 862 + @media (hover: none) { 863 + #track-overlay { 864 + opacity: 1; 865 + pointer-events: auto; 866 + background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); 867 + padding-bottom: 1rem; 868 + } 869 + .volume-overlay { 870 + opacity: 1; 871 + pointer-events: auto; 872 + } 873 + } 874 + }
+17 -1460
templates/room.html
··· 11 11 rel="stylesheet" 12 12 > 13 13 <style> 14 - :root { 15 - --base: #24273a; 16 - --mantle: #1e2030; 17 - --crust: #181926; 18 - --surface0: #363a4f; 19 - --surface1: #494d64; 20 - --surface2: #5b6078; 21 - --overlay0: #6c7086; 22 - --overlay1: #7f849c; 23 - --overlay2: #939ab7; 24 - --subtext0: #a5adcb; 25 - --subtext1: #b8c0e0; 26 - --text: #cad3f5; 27 - --lavender: #b7bdf8; 28 - --blue: #8aadf4; 29 - --sapphire: #7dc4e4; 30 - --sky: #91d7e3; 31 - --teal: #8bd5ca; 32 - --green: #a6da95; 33 - --yellow: #eed49f; 34 - --peach: #f5a97f; 35 - --maroon: #ee99a0; 36 - --red: #ed8796; 37 - --mauve: #c6a0f6; 38 - --pink: #f5bde6; 39 - --flamingo: #f0c6c6; 40 - --rosewater: #f4dbd6; 41 - --radius-sm: 6px; 42 - --radius-md: 10px; 43 - --radius-lg: 14px; 44 - --font-sans: "Space Grotesk", system-ui, -apple-system, sans-serif; 45 - --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(198, 160, 246, 0.04); 46 - --shadow-card-hover: 0 4px 16px rgba(0, 0, 0, 0.35), 0 1px 4px rgba(198, 160, 246, 0.06); 47 - --shadow-elevated: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); 48 - --shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.3); 49 - } 50 - *, 51 - *::before, 52 - *::after { 53 - box-sizing: border-box; 54 - margin: 0; 55 - padding: 0; 56 - } 57 - html { 58 - -webkit-font-smoothing: antialiased; 59 - -moz-osx-font-smoothing: grayscale; 60 - } 61 - html, 62 - body { 63 - height: 100%; 64 - } 65 - body { 66 - font-family: var(--font-sans); 67 - background: var(--base); 68 - color: var(--text); 69 - padding: 1rem 1.5rem; 70 - display: flex; 71 - flex-direction: column; 72 - } 73 - 74 - /* Top bar */ 75 - .top-bar { 76 - display: flex; 77 - align-items: center; 78 - justify-content: space-between; 79 - margin-bottom: 1rem; 80 - gap: 1rem; 81 - } 82 - .top-bar-home { 83 - color: var(--overlay1); 84 - text-decoration: none; 85 - font-size: 0.875rem; 86 - font-weight: 600; 87 - letter-spacing: 0.02em; 88 - text-transform: uppercase; 89 - flex-shrink: 0; 90 - transition: color 150ms ease-out; 91 - } 92 - .top-bar-home:hover { 93 - color: var(--text); 94 - } 95 - .top-bar-center { 96 - flex: 1; 97 - text-align: center; 98 - min-width: 0; 99 - } 100 - .room-name { 101 - display: block; 102 - font-weight: 700; 103 - font-size: 1.125rem; 104 - color: var(--text); 105 - line-height: 1.3; 106 - text-wrap: balance; 107 - } 108 - .room-id { 109 - display: block; 110 - font-size: 0.75rem; 111 - color: var(--overlay0); 112 - font-variant-numeric: tabular-nums; 113 - letter-spacing: 0.03em; 114 - } 115 - .top-bar-actions { 116 - display: flex; 117 - align-items: center; 118 - gap: 0.5rem; 119 - flex-shrink: 0; 120 - } 121 - .btn-icon { 122 - display: inline-flex; 123 - align-items: center; 124 - justify-content: center; 125 - width: 36px; 126 - height: 36px; 127 - border-radius: var(--radius-sm); 128 - border: none; 129 - background: var(--surface0); 130 - color: var(--subtext0); 131 - cursor: pointer; 132 - font-size: 1rem; 133 - line-height: 1; 134 - transition: 135 - background 150ms ease-out, 136 - color 150ms ease-out; 137 - } 138 - .btn-icon:hover { 139 - background: var(--surface1); 140 - color: var(--text); 141 - } 142 - .btn-icon:active { 143 - transform: scale(0.92); 144 - } 145 - .viewer-pill { 146 - display: inline-flex; 147 - align-items: center; 148 - gap: 0.25rem; 149 - padding: 0.25rem 0.625rem; 150 - border-radius: 999px; 151 - background: var(--surface0); 152 - color: var(--overlay1); 153 - font-size: 0.75rem; 154 - font-weight: 500; 155 - white-space: nowrap; 156 - font-variant-numeric: tabular-nums; 157 - } 158 - 159 - /* Layout */ 160 - .layout { 161 - display: flex; 162 - gap: 1rem; 163 - flex: 1; 164 - min-height: 0; 165 - } 166 - .main-col { 167 - flex: 1; 168 - min-width: 0; 169 - display: flex; 170 - flex-direction: column; 171 - overflow-y: auto; 172 - } 173 - .chat-col { 174 - width: 320px; 175 - flex-shrink: 0; 176 - display: flex; 177 - flex-direction: column; 178 - min-height: 0; 179 - border-left: 1px solid var(--surface2); 180 - padding-left: 1rem; 181 - } 182 - 183 - /* Video */ 184 - #video-container { 185 - position: relative; 186 - width: 100%; 187 - margin-bottom: 1rem; 188 - border-radius: var(--radius-lg); 189 - overflow: hidden; 190 - background: var(--crust); 191 - box-shadow: var(--shadow-card); 192 - } 193 - #video-player { 194 - width: 100%; 195 - aspect-ratio: 16 / 9; 196 - max-height: 60vh; 197 - object-fit: contain; 198 - display: block; 199 - background: var(--crust); 200 - } 201 - /* Video idle state (no track playing) */ 202 - #video-container.idle { 203 - display: flex; 204 - align-items: center; 205 - justify-content: center; 206 - min-height: 200px; 207 - } 208 - #video-container.idle #video-player { 209 - display: none; 210 - } 211 - #video-container.idle .idle-msg { 212 - display: block; 213 - } 214 - .idle-msg { 215 - display: none; 216 - color: var(--overlay0); 217 - font-size: 0.9375rem; 218 - text-align: center; 219 - padding: 3rem 1.5rem; 220 - } 221 - .idle-msg .icon { 222 - font-size: 2rem; 223 - display: block; 224 - margin-bottom: 0.5rem; 225 - opacity: 0.5; 226 - } 227 - 228 - /* Track info overlay on video */ 229 - #track-overlay { 230 - position: absolute; 231 - bottom: 0; 232 - left: 0; 233 - right: 0; 234 - background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); 235 - padding: 2rem 1rem 0.75rem; 236 - opacity: 0; 237 - transition: opacity 200ms ease-out; 238 - pointer-events: none; 239 - z-index: 5; 240 - } 241 - #video-container:hover #track-overlay, 242 - #track-overlay.show { 243 - opacity: 1; 244 - pointer-events: auto; 245 - } 246 - .to-row { 247 - display: flex; 248 - align-items: center; 249 - gap: 0.75rem; 250 - } 251 - .to-thumb { 252 - width: 36px; 253 - height: 36px; 254 - border-radius: var(--radius-sm); 255 - object-fit: cover; 256 - flex-shrink: 0; 257 - outline: 1px solid rgba(0, 0, 0, 0.3); 258 - outline-offset: -1px; 259 - } 260 - .to-info { 261 - flex: 1; 262 - min-width: 0; 263 - } 264 - .to-title { 265 - display: block; 266 - color: #fff; 267 - font-weight: 600; 268 - font-size: 0.9375rem; 269 - line-height: 1.3; 270 - overflow: hidden; 271 - text-overflow: ellipsis; 272 - white-space: nowrap; 273 - } 274 - .to-meta { 275 - display: block; 276 - color: rgba(255, 255, 255, 0.6); 277 - font-size: 0.75rem; 278 - line-height: 1.4; 279 - } 280 - .to-meta a { 281 - color: inherit; 282 - text-decoration: underline; 283 - text-underline-offset: 2px; 284 - } 285 - .to-viewers { 286 - font-variant-numeric: tabular-nums; 287 - } 288 - #to-progress-wrap { 289 - width: 100%; 290 - height: 2px; 291 - background: rgba(255, 255, 255, 0.15); 292 - border-radius: 1px; 293 - overflow: hidden; 294 - margin-top: 0.5rem; 295 - } 296 - #to-progress-fill { 297 - height: 100%; 298 - width: 0%; 299 - background: var(--mauve); 300 - border-radius: 1px; 301 - transition: width 1s linear; 302 - } 303 - 304 - .volume-overlay { 305 - position: absolute; 306 - bottom: 0.75rem; 307 - right: 0.75rem; 308 - display: flex; 309 - align-items: center; 310 - gap: 0.5rem; 311 - background: rgba(0, 0, 0, 0.6); 312 - border-radius: var(--radius-md); 313 - padding: 0.375rem 0.625rem; 314 - opacity: 0; 315 - transition: opacity 150ms ease-out; 316 - pointer-events: none; 317 - z-index: 10; 318 - } 319 - #video-container:hover .volume-overlay { 320 - opacity: 1; 321 - pointer-events: auto; 322 - } 323 - .volume-overlay .icon { 324 - font-size: 1.125rem; 325 - line-height: 1; 326 - color: var(--text); 327 - cursor: pointer; 328 - user-select: none; 329 - } 330 - .volume-overlay input[type="range"] { 331 - width: 80px; 332 - height: 4px; 333 - accent-color: var(--blue); 334 - cursor: pointer; 335 - background: transparent; 336 - } 337 - 338 - /* Input Row */ 339 - .queue-group { 340 - margin-bottom: 0.5rem; 341 - } 342 - .input-row { 343 - display: flex; 344 - gap: 0.5rem; 345 - margin-top: 0.75rem; 346 - } 347 - .input-row input { 348 - flex: 1; 349 - font: inherit; 350 - padding: 0.75rem 1rem; 351 - border-radius: var(--radius-md); 352 - border: none; 353 - background: var(--mantle); 354 - color: var(--text); 355 - font-size: 0.9375rem; 356 - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 357 - transition: box-shadow 150ms ease-out; 358 - } 359 - .input-row input:focus { 360 - outline: none; 361 - box-shadow: 362 - inset 0 2px 4px rgba(0, 0, 0, 0.2), 363 - 0 0 0 2px var(--blue); 364 - } 365 - .input-row input::placeholder { 366 - color: var(--overlay0); 367 - } 368 - 369 - /* Buttons */ 370 - button { 371 - font: inherit; 372 - padding: 0.75rem 1.25rem; 373 - border-radius: var(--radius-md); 374 - border: none; 375 - cursor: pointer; 376 - font-weight: 600; 377 - font-size: 0.9375rem; 378 - transition: 379 - transform 150ms ease-out, 380 - box-shadow 150ms ease-out, 381 - filter 150ms ease-out; 382 - min-height: 44px; 383 - display: inline-flex; 384 - align-items: center; 385 - justify-content: center; 386 - } 387 - button:active { 388 - transform: scale(0.96); 389 - } 390 - button:disabled { 391 - opacity: 0.5; 392 - cursor: not-allowed; 393 - transform: none; 394 - filter: none; 395 - } 396 - button.btn-primary { 397 - background: var(--green); 398 - color: var(--crust); 399 - box-shadow: var(--shadow-card); 400 - } 401 - button.btn-primary:hover { 402 - filter: brightness(1.12); 403 - box-shadow: var(--shadow-card-hover); 404 - } 405 - button.btn-secondary { 406 - background: var(--surface1); 407 - color: var(--text); 408 - box-shadow: var(--shadow-card); 409 - } 410 - button.btn-secondary:hover { 411 - background: var(--surface2); 412 - filter: brightness(1.08); 413 - box-shadow: var(--shadow-card-hover); 414 - } 415 - button.btn-accent { 416 - background: var(--mauve); 417 - color: var(--crust); 418 - box-shadow: var(--shadow-card); 419 - } 420 - button.btn-accent:hover { 421 - filter: brightness(1.12); 422 - box-shadow: var(--shadow-card-hover); 423 - } 424 - 425 - /* Queue */ 426 - .queue { 427 - list-style: none; 428 - } 429 - .queue li { 430 - background: var(--surface0); 431 - border-radius: var(--radius-md); 432 - box-shadow: var(--shadow-card); 433 - padding: 0.75rem; 434 - display: flex; 435 - align-items: center; 436 - gap: 0.75rem; 437 - margin-bottom: 0.5rem; 438 - transition: 439 - background 150ms ease-out, 440 - box-shadow 150ms ease-out; 441 - animation: queueFadeIn 0.12s ease-out both; 442 - } 443 - .queue li:hover { 444 - background: var(--surface1); 445 - box-shadow: var(--shadow-card-hover); 446 - } 447 - .queue li .thumb, 448 - .queue li .thumb-img { 449 - width: 40px; 450 - height: 40px; 451 - border-radius: var(--radius-sm); 452 - background: var(--mantle); 453 - flex-shrink: 0; 454 - object-fit: cover; 455 - outline: 1px solid var(--surface2); 456 - outline-offset: -1px; 457 - } 458 - .queue li .title { 459 - flex: 1; 460 - min-width: 0; 461 - overflow: hidden; 462 - text-overflow: ellipsis; 463 - white-space: nowrap; 464 - text-wrap: pretty; 465 - } 466 - .queue li .duration { 467 - color: var(--subtext0); 468 - font-size: 0.875rem; 469 - flex-shrink: 0; 470 - font-variant-numeric: tabular-nums; 471 - } 472 - 473 - /* Source link */ 474 - .source-link { 475 - color: var(--blue); 476 - text-decoration: none; 477 - font-size: 0.8rem; 478 - padding: 6px 10px; 479 - border-radius: var(--radius-sm); 480 - background: color-mix(in srgb, var(--blue) 15%, transparent); 481 - transition: background 150ms ease-out; 482 - flex-shrink: 0; 483 - min-height: 40px; 484 - display: inline-flex; 485 - align-items: center; 486 - } 487 - .source-link:hover { 488 - background: color-mix(in srgb, var(--blue) 25%, transparent); 489 - } 490 - 491 - .history-item { 492 - opacity: 1; 493 - border-radius: var(--radius-sm); 494 - padding: 0.5rem 0.75rem; 495 - margin-bottom: 0.25rem; 496 - background: transparent; 497 - box-shadow: none; 498 - } 499 - .history-item:hover { 500 - background: transparent; 501 - box-shadow: none; 502 - } 503 - .history-item .thumb, 504 - .history-item .thumb-img { 505 - width: 28px; 506 - height: 28px; 507 - } 508 - .history-item .title { 509 - color: var(--overlay1); 510 - font-size: 0.8125rem; 511 - } 512 - .history-item .duration { 513 - color: var(--overlay0); 514 - font-size: 0.75rem; 515 - } 516 - 517 - /* Connection status */ 518 - .conn-status { 519 - font-size: 0.75rem; 520 - color: var(--overlay0); 521 - margin-bottom: 0.5rem; 522 - text-align: right; 523 - transition: color 300ms ease-out; 524 - } 525 - .conn-status.connected { 526 - color: var(--green); 527 - } 528 - .conn-status.disconnected { 529 - color: var(--red); 530 - } 531 - 532 - /* History header */ 533 - .history-header { 534 - margin-top: 1.5rem; 535 - margin-bottom: 0.5rem; 536 - padding-top: 1rem; 537 - border-top: 1px solid var(--surface2); 538 - font-size: 0.8125rem; 539 - color: var(--overlay0); 540 - font-weight: 500; 541 - text-transform: uppercase; 542 - letter-spacing: 0.04em; 543 - } 544 - 545 - /* Empty state */ 546 - .empty-state { 547 - display: flex; 548 - flex-direction: column; 549 - align-items: center; 550 - justify-content: center; 551 - gap: 0.75rem; 552 - padding: 3rem 1.5rem; 553 - text-align: center; 554 - color: var(--overlay1); 555 - font-size: 0.875rem; 556 - line-height: 1.5; 557 - background: var(--surface0); 558 - border-radius: var(--radius-lg); 559 - border: 1px dashed var(--surface2); 560 - margin-bottom: 0.5rem; 561 - } 562 - .empty-state .icon { 563 - font-size: 1.75rem; 564 - line-height: 1; 565 - opacity: 0.5; 566 - } 567 - .empty-state .label { 568 - color: var(--subtext0); 569 - font-weight: 500; 570 - } 571 - .empty-state .hint { 572 - color: var(--overlay0); 573 - font-size: 0.8125rem; 574 - max-width: 24em; 575 - } 576 - 577 - /* Chat */ 578 - .chat-messages { 579 - flex: 1; 580 - overflow-y: auto; 581 - min-height: 0; 582 - background: var(--surface0); 583 - border-radius: var(--radius-lg); 584 - box-shadow: var(--shadow-card); 585 - padding: 0.5rem; 586 - display: flex; 587 - flex-direction: column; 588 - gap: 4px; 589 - margin-bottom: 0.5rem; 590 - } 591 - .chat-messages:empty::after { 592 - content: "No messages yet"; 593 - display: flex; 594 - align-items: center; 595 - justify-content: center; 596 - height: 100%; 597 - min-height: 80px; 598 - color: var(--overlay0); 599 - font-size: 0.875rem; 600 - font-style: italic; 601 - } 602 - .chat-msg { 603 - padding: 6px 10px; 604 - border-left: 3px solid var(--blue); 605 - padding-left: 10px; 606 - font-size: 0.875rem; 607 - line-height: 1.4; 608 - } 609 - .chat-msg .user { 610 - color: var(--blue); 611 - font-weight: 600; 612 - font-size: 0.8rem; 613 - } 614 - .chat-msg .time { 615 - color: var(--overlay0); 616 - font-size: 0.7rem; 617 - float: right; 618 - margin-left: 8px; 619 - } 620 - .chat-msg .text { 621 - color: var(--text); 622 - } 623 - .chat-msg.system { 624 - border-left-color: var(--overlay0); 625 - } 626 - .chat-msg.system .user { 627 - color: var(--overlay0); 628 - } 629 - .chat-msg.system .text { 630 - color: var(--subtext0); 631 - font-style: italic; 632 - } 633 - 634 - /* Chat input */ 635 - .chat-input-row { 636 - display: flex; 637 - gap: 0.5rem; 638 - } 639 - .chat-input-row input { 640 - flex: 1; 641 - font: inherit; 642 - padding: 0.625rem 0.875rem; 643 - border-radius: var(--radius-md); 644 - border: none; 645 - background: var(--mantle); 646 - color: var(--text); 647 - font-size: 0.875rem; 648 - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 649 - transition: box-shadow 150ms ease-out; 650 - } 651 - .chat-input-row input:focus { 652 - outline: none; 653 - box-shadow: 654 - inset 0 2px 4px rgba(0, 0, 0, 0.2), 655 - 0 0 0 2px var(--blue); 656 - } 657 - .chat-input-row input::placeholder { 658 - color: var(--overlay0); 659 - } 660 - .chat-input-row button { 661 - padding: 0.625rem 1rem; 662 - min-height: 40px; 663 - background: var(--mauve); 664 - color: var(--crust); 665 - box-shadow: var(--shadow-card); 666 - } 667 - .chat-input-row button:hover { 668 - filter: brightness(1.12); 669 - box-shadow: var(--shadow-card-hover); 670 - } 671 - 672 - /* Skeleton loading state for pending queue items */ 673 - @keyframes shimmer { 674 - 0% { 675 - background-position: -200% 0; 676 - } 677 - 100% { 678 - background-position: 200% 0; 679 - } 680 - } 681 - .queue li.pending { 682 - position: relative; 683 - } 684 - .queue li.pending .title, 685 - .queue li.pending .duration { 686 - display: inline-block; 687 - border-radius: var(--radius-sm); 688 - background: linear-gradient( 689 - 90deg, 690 - var(--surface1) 25%, 691 - var(--surface2) 37%, 692 - var(--surface1) 63% 693 - ); 694 - background-size: 200% 100%; 695 - animation: shimmer 1.5s ease-in-out infinite; 696 - color: transparent !important; 697 - user-select: none; 698 - } 699 - .queue li.pending .title { 700 - min-width: 16ch; 701 - max-width: 24ch; 702 - } 703 - .queue li.pending .duration { 704 - min-width: 5ch; 705 - } 706 - .queue li.pending .source-link { 707 - visibility: hidden; 708 - } 709 - 710 - /* Stagger animation */ 711 - @keyframes queueFadeIn { 712 - from { 713 - opacity: 0.3; 714 - transform: translateY(4px); 715 - } 716 - to { 717 - opacity: 1; 718 - transform: translateY(0); 719 - } 720 - } 721 - .queue li:nth-child(1) { 722 - animation-delay: 0s; 723 - } 724 - .queue li:nth-child(2) { 725 - animation-delay: 0.01s; 726 - } 727 - .queue li:nth-child(3) { 728 - animation-delay: 0.02s; 729 - } 730 - .queue li:nth-child(4) { 731 - animation-delay: 0.03s; 732 - } 733 - .queue li:nth-child(5) { 734 - animation-delay: 0.04s; 735 - } 736 - .queue li:nth-child(6) { 737 - animation-delay: 0.05s; 738 - } 739 - .queue li:nth-child(n + 7) { 740 - animation-delay: 0.05s; 741 - } 742 - 743 - /* Mobile responsive */ 744 - @media (max-width: 768px) { 745 - .layout { 746 - flex-direction: column; 747 - overflow-y: visible; 748 - } 749 - .chat-col { 750 - width: 100%; 751 - padding-left: 0; 752 - border-left: none; 753 - border-top: 1px solid var(--surface2); 754 - padding-top: 1rem; 755 - } 756 - /* Touch devices: always show overlays (no hover dependency) */ 757 - @media (hover: none) { 758 - #track-overlay { 759 - opacity: 1; 760 - pointer-events: auto; 761 - background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); 762 - padding-bottom: 1rem; 763 - } 764 - .volume-overlay { 765 - opacity: 1; 766 - pointer-events: auto; 767 - } 768 - } 769 - } 770 - 771 - @media (max-width: 768px) { 772 - html, 773 - body { 774 - height: auto; 775 - } 776 - body { 777 - min-height: 100vh; 778 - } 779 - .layout { 780 - flex-direction: column; 781 - overflow-y: visible; 782 - flex: none; 783 - } 784 - .main-col { 785 - overflow-y: visible; 786 - flex: none; 787 - } 788 - .chat-col { 789 - width: 100%; 790 - padding-left: 0; 791 - border-left: none; 792 - border-top: 1px solid var(--surface2); 793 - padding-top: 1rem; 794 - flex: none; 795 - } 796 - #video-player { 797 - max-height: 40vh; 798 - } 799 - #video-container.idle { 800 - min-height: 120px; 801 - } 802 - .top-bar { 803 - flex-wrap: wrap; 804 - gap: 0.5rem; 805 - } 806 - .top-bar-center { 807 - order: 3; 808 - width: 100%; 809 - text-align: left; 810 - } 811 - } 14 + {%- include "partials/room_styles.html" -%} 812 15 </style> 813 16 </head> 814 17 <body> ··· 843 46 <span id="to-duration" class="to-duration"> / 0:00</span> 844 47 <span id="to-viewers" class="to-viewers"></span> 845 48 <a id="to-source" class="to-source"></a> 49 + <button id="to-rot" class="rot-add" title="Add to Rotation">+</button> 846 50 </span> 847 51 </div> 848 52 </div> ··· 894 98 id="url-input" 895 99 placeholder="Paste YouTube / SoundCloud / Bandcamp URL..." 896 100 > 897 - <button class="btn-primary" onclick="ingest()">Add</button> 101 + <button id="queue-add-btn" class="btn-primary" onclick="ingest()">Add</button> 898 102 <button class="btn-secondary" onclick="skip()" title="Skip current track">Skip</button> 899 103 </div> 900 104 </div> ··· 920 124 </ul> 921 125 {% endif %} 922 126 </div> 127 + 128 + <div class="playlist-section" id="playlist-section"> 129 + <div class="playlist-header">Rotation</div> 130 + <div id="playlist-items"></div> 131 + <div class="input-row" style="margin-top:0.25rem"> 132 + <input 133 + type="text" 134 + id="playlist-input" 135 + placeholder="Add URL to Rotation..." 136 + > 137 + <button class="btn-primary" onclick="addToPlaylist()">Add</button> 138 + </div> 139 + </div> 923 140 </div> 924 141 925 142 <div class="chat-col"> ··· 933 150 </div> 934 151 935 152 <script> 936 - // ── MoQ binary helpers ────────────────────────────────────────── 937 - function encVarint(n) { 938 - if (n < 64) return new Uint8Array([n]); 939 - if (n < 16384) return new Uint8Array([(n >> 8) | 0x40, n & 0xff]); 940 - if (n < 1073741824) 941 - return new Uint8Array([(n >> 24) | 0x80, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]); 942 - const b = new Uint8Array(8); 943 - b[0] = (n >> 56) | 0xc0; 944 - b[1] = (n >> 48) & 0xff; 945 - b[2] = (n >> 40) & 0xff; 946 - b[3] = (n >> 32) & 0xff; 947 - b[4] = (n >> 24) & 0xff; 948 - b[5] = (n >> 16) & 0xff; 949 - b[6] = (n >> 8) & 0xff; 950 - b[7] = n & 0xff; 951 - return b; 952 - } 953 - function encString(s) { 954 - const bytes = new TextEncoder().encode(s); 955 - const len = encVarint(bytes.length); 956 - const out = new Uint8Array(len.length + bytes.length); 957 - out.set(len); 958 - out.set(bytes, len.length); 959 - return out; 960 - } 961 - function concat(arrays) { 962 - const total = arrays.reduce((sum, arr) => sum + arr.length, 0); 963 - const out = new Uint8Array(total); 964 - let off = 0; 965 - for (const a of arrays) { 966 - out.set(a, off); 967 - off += a.length; 968 - } 969 - return out; 970 - } 971 - 972 - function decVarint(buf, off) { 973 - const first = buf[off]; 974 - const tag = first >> 6; 975 - if (tag === 0) return [first & 0x3f, off + 1]; 976 - if (tag === 1) return [((first & 0x3f) << 8) | buf[off + 1], off + 2]; 977 - if (tag === 2) 978 - return [ 979 - ((first & 0x3f) << 24) | (buf[off + 1] << 16) | (buf[off + 2] << 8) | buf[off + 3], 980 - off + 4, 981 - ]; 982 - let val = BigInt(first & 0x3f) << 56n; 983 - for (let i = 1; i < 8; i++) val |= BigInt(buf[off + i]) << BigInt(8 * (7 - i)); 984 - return [Number(val), off + 8]; 985 - } 986 - function decString(buf, off) { 987 - const [len, o] = decVarint(buf, off); 988 - const str = new TextDecoder().decode(buf.slice(o, o + len)); 989 - return [str, o + len]; 990 - } 991 - 992 - // ── Debug logging (opt-in via ?debug or localStorage) ──────────── 993 - const DEBUG = 994 - new URLSearchParams(location.search).has("debug") || localStorage.getItem("moqbox_debug"); 995 - function debug(...args) { 996 - if (DEBUG) console.log(...args); 997 - } 998 - 999 - // ── Chat WebSocket client ─────────────────────────────────────── 1000 - const roomId = "{{ room_id }}"; 1001 - let userName = sessionStorage.getItem("moqbox_user"); 1002 - if (!userName) { 1003 - userName = prompt("Enter your name:", "anonymous") || "anonymous"; 1004 - sessionStorage.setItem("moqbox_user", userName); 1005 - } 1006 - 1007 - const proto = location.protocol === "https:" ? "wss:" : "ws:"; 1008 - let ws = null; 1009 - let reconnectDelay = 1000; 1010 - 1011 - // ── MediaSource video playback ──────────────────────────────────── 1012 - let mediaSource = null; 1013 - let sourceBuffer = null; 1014 - const pendingBoxes = []; // buffered while waiting for sourceopen 1015 - let currentTrackId = null; // tracks the currently playing track (from state) 1016 - let awaitingInit = true; // true until an init segment is processed 1017 - let seekInitDone = false; // true after first seek on join or track change 1018 - 1019 - function initVideo() { 1020 - // Guard: don't create a second player if already initialized. 1021 - if (document.getElementById("video-player")) return; 1022 - debug("initVideo called"); 1023 - const container = 1024 - document.getElementById("video-container") || document.querySelector(".main-col"); 1025 - const video = document.createElement("video"); 1026 - video.id = "video-player"; 1027 - video.controls = false; 1028 - video.autoplay = true; 1029 - video.muted = true; 1030 - container.insertBefore(video, container.firstChild); 1031 - 1032 - // Wire up volume controls (element is in HTML, not created in JS) 1033 - const volSlider = document.getElementById("vol-slider"); 1034 - const volIcon = document.getElementById("vol-icon"); 1035 - // Starts muted; user must gesture (click icon or drag slider) to enable audio. 1036 - volSlider.addEventListener("input", () => { 1037 - const v = parseFloat(volSlider.value); 1038 - video.volume = v; 1039 - video.muted = v === 0; 1040 - volIcon.textContent = v === 0 ? "\u{1F507}" : v < 0.5 ? "\u{1F509}" : "\u{1F50A}"; 1041 - }); 1042 - volIcon.addEventListener("click", () => { 1043 - if (video.muted) { 1044 - video.muted = false; 1045 - video.volume = 1; 1046 - volSlider.value = "1"; 1047 - volIcon.textContent = "\u{1F50A}"; 1048 - } else { 1049 - video.muted = true; 1050 - volSlider.value = "0"; 1051 - volIcon.textContent = "\u{1F507}"; 1052 - } 1053 - }); 1054 - 1055 - mediaSource = new MediaSource(); 1056 - mediaSource.addEventListener("sourceopen", () => { 1057 - debug("MediaSource sourceopen"); 1058 - try { 1059 - sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.4d0028,mp4a.40.2"'); 1060 - sourceBuffer.mode = "segments"; 1061 - sourceBuffer.addEventListener("updateend", () => { 1062 - if (pendingBoxes.length > 0) { 1063 - const next = pendingBoxes.shift(); 1064 - try { 1065 - sourceBuffer.appendBuffer(next); 1066 - } catch (e) { 1067 - console.warn("SourceBuffer append failed:", e); 1068 - } 1069 - } 1070 - if (!seekInitDone) { 1071 - const v = document.getElementById("video-player"); 1072 - if (v && v.buffered.length > 0) { 1073 - seekInitDone = true; 1074 - v.currentTime = v.buffered.start(0); 1075 - } 1076 - } 1077 - }); 1078 - // Flush boxes that arrived before sourceopen. 1079 - while (pendingBoxes.length > 0) { 1080 - const box = pendingBoxes.shift(); 1081 - if (!sourceBuffer.updating) { 1082 - try { 1083 - sourceBuffer.appendBuffer(box); 1084 - } catch (e) { 1085 - console.warn("SourceBuffer flush failed:", e); 1086 - } 1087 - } else { 1088 - pendingBoxes.unshift(box); 1089 - break; 1090 - } 1091 - } 1092 - } catch (e) { 1093 - console.warn("MediaSource not supported:", e); 1094 - } 1095 - }); 1096 - video.src = URL.createObjectURL(mediaSource); 1097 - } 1098 - initVideo(); 1099 - 1100 - function appendVideo(data) { 1101 - if (sourceBuffer && !sourceBuffer.updating) { 1102 - try { 1103 - sourceBuffer.appendBuffer(data); 1104 - } catch (e) { 1105 - console.warn("SourceBuffer append failed:", e); 1106 - } 1107 - } else { 1108 - pendingBoxes.push(data); 1109 - } 1110 - } 1111 - 1112 - function subscribeTracks() { 1113 - // Subscribe to chat track. 1114 - ws.send( 1115 - concat([encVarint(0x01), encString("moqbox/room/" + roomId + "/chat"), encString("chat")]), 1116 - ); 1117 - // Subscribe to state track for now-playing updates. 1118 - ws.send( 1119 - concat([ 1120 - encVarint(0x01), 1121 - encString("moqbox/room/" + roomId + "/state"), 1122 - encString("state"), 1123 - ]), 1124 - ); 1125 - } 1126 - 1127 - function updateConnStatus(connected) { 1128 - const el = document.getElementById("conn-status"); 1129 - if (!el) return; 1130 - el.className = "conn-status " + (connected ? "connected" : "disconnected"); 1131 - el.textContent = connected ? "connected" : "disconnected"; 1132 - } 1133 - 1134 - function connect() { 1135 - ws = new WebSocket( 1136 - proto + "//" + location.host + "/ws/" + roomId + "?name=" + encodeURIComponent(userName), 1137 - ); 1138 - ws.binaryType = "arraybuffer"; 1139 - 1140 - ws.onopen = () => { 1141 - debug("WS connected"); 1142 - updateConnStatus(true); 1143 - reconnectDelay = 1000; 1144 - currentTrackId = null; 1145 - subscribeTracks(); 1146 - }; 1147 - 1148 - ws.onmessage = (event) => { 1149 - const buf = new Uint8Array(event.data); 1150 - let off = 0; 1151 - while (off < buf.length) { 1152 - const [msgType, o1] = decVarint(buf, off); 1153 - if (msgType === 0x00) { 1154 - const [trackId, o2] = decVarint(buf, o1); 1155 - const [groupId, o3] = decVarint(buf, o2); 1156 - const [objectId, o4] = decVarint(buf, o3); 1157 - if (trackId === 2) { 1158 - const payload = new TextDecoder().decode(buf.slice(o4)); 1159 - try { 1160 - const chat = JSON.parse(payload); 1161 - appendChat(chat); 1162 - } catch (_) {} 1163 - off = buf.length; 1164 - } else if (trackId === 3) { 1165 - const payload = new TextDecoder().decode(buf.slice(o4)); 1166 - try { 1167 - const state = JSON.parse(payload); 1168 - updateNowPlaying(state); 1169 - } catch (_) {} 1170 - off = buf.length; 1171 - } else { 1172 - off = buf.length; 1173 - } 1174 - } else if (msgType === 0x02) { 1175 - const [ns, o2] = decString(buf, o1); 1176 - const [tr, o3] = decString(buf, o2); 1177 - off = o3; 1178 - } else if (msgType === 0x06) { 1179 - const [ns, o2] = decString(buf, o1); 1180 - const [tr, o3] = decString(buf, o2); 1181 - const [reason, o4] = decString(buf, o3); 1182 - off = o4; 1183 - } else { 1184 - break; 1185 - } 1186 - } 1187 - }; 1188 - 1189 - ws.onclose = () => { 1190 - updateConnStatus(false); 1191 - setTimeout(() => { 1192 - reconnectDelay = Math.min(reconnectDelay * 2, 30000); 1193 - connect(); 1194 - }, reconnectDelay); 1195 - }; 1196 - } 1197 - 1198 - connect(); 1199 - 1200 - // ── Video WebSocket (separate connection, no MoQ framing) ────────── 1201 - let videoWs = null; 1202 - let videoReconnectDelay = 1000; 1203 - 1204 - function connectVideoWs() { 1205 - videoWs = new WebSocket(proto + "//" + location.host + "/ws/" + roomId + "/video"); 1206 - videoWs.binaryType = "arraybuffer"; 1207 - 1208 - videoWs.onopen = () => { 1209 - debug("video WS connected"); 1210 - videoReconnectDelay = 1000; 1211 - awaitingInit = true; 1212 - pendingBoxes.length = 0; 1213 - seekInitDone = false; 1214 - }; 1215 - 1216 - videoWs.onmessage = (event) => { 1217 - const data = new Uint8Array(event.data); 1218 - 1219 - // Always detect init segments, even when not awaitingInit. 1220 - // The video WS and control WS are independent connections with 1221 - // no ordering guarantee — init can arrive before the state update. 1222 - const isInit = 1223 - data.length >= 8 && String.fromCharCode(data[4], data[5], data[6], data[7]) === "ftyp"; 1224 - 1225 - if (isInit) { 1226 - debug("init segment received (video WS)"); 1227 - awaitingInit = false; 1228 - pendingBoxes.length = 0; 1229 - if (sourceBuffer && !sourceBuffer.updating) { 1230 - try { 1231 - sourceBuffer.abort(); 1232 - if (sourceBuffer.buffered.length > 0) { 1233 - const end = sourceBuffer.buffered.end(sourceBuffer.buffered.length - 1); 1234 - sourceBuffer.remove(0, end); 1235 - } 1236 - } catch (_) {} 1237 - } 1238 - appendVideo(data); 1239 - return; 1240 - } 1241 - 1242 - if (awaitingInit) { 1243 - pendingBoxes.push(data); 1244 - return; 1245 - } 1246 - 1247 - appendVideo(data); 1248 - }; 1249 - 1250 - videoWs.onclose = () => { 1251 - setTimeout(() => { 1252 - videoReconnectDelay = Math.min(videoReconnectDelay * 2, 30000); 1253 - connectVideoWs(); 1254 - }, videoReconnectDelay); 1255 - }; 1256 - } 1257 - 1258 - // Open video connection after a short delay to let the control WS 1259 - // establish the room subscription first. 1260 - setTimeout(connectVideoWs, 100); 1261 - 1262 - function copyRoomLink() { 1263 - const url = window.location.href; 1264 - navigator.clipboard 1265 - .writeText(url) 1266 - .then(() => { 1267 - const btn = document.getElementById("copy-link"); 1268 - if (!btn) return; 1269 - const original = btn.innerHTML; 1270 - btn.innerHTML = '<span class="icon">&#x2713;</span>'; 1271 - setTimeout(() => { 1272 - btn.innerHTML = original; 1273 - }, 1500); 1274 - }) 1275 - .catch(() => { 1276 - const input = document.createElement("input"); 1277 - input.value = url; 1278 - document.body.appendChild(input); 1279 - input.select(); 1280 - document.execCommand("copy"); 1281 - document.body.removeChild(input); 1282 - }); 1283 - } 1284 - 1285 - function appendChat(chat) { 1286 - const container = document.getElementById("chat-messages"); 1287 - const div = document.createElement("div"); 1288 - div.className = "chat-msg" + (chat.type === "system_event" ? " system" : ""); 1289 - 1290 - let ts = ""; 1291 - if (chat.ts) { 1292 - const d = new Date(chat.ts); 1293 - ts = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 1294 - } 1295 - 1296 - div.innerHTML = 1297 - '<span class="user">' + 1298 - esc(chat.user || "anonymous") + 1299 - "</span>" + 1300 - '<span class="time">' + 1301 - ts + 1302 - "</span><br>" + 1303 - '<span class="text">' + 1304 - esc(chat.content || "") + 1305 - "</span>"; 1306 - container.appendChild(div); 1307 - 1308 - // Auto-scroll only if user is near the bottom (within 30px threshold). 1309 - const threshold = 30; 1310 - if (container.scrollHeight - container.scrollTop - container.clientHeight < threshold) { 1311 - container.scrollTop = container.scrollHeight; 1312 - } 1313 - } 1314 - 1315 - let elapsedInterval = null; 1316 - 1317 - function parseDuration(s) { 1318 - // Converts "M:SS" or "H:MM:SS" to seconds 1319 - const parts = s.split(":").map(Number); 1320 - if (parts.length === 2) return parts[0] * 60 + parts[1]; 1321 - if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; 1322 - return 0; 1323 - } 1324 - 1325 - function updateNowPlaying(state) { 1326 - const container = document.getElementById("video-container"); 1327 - if (!container) return; 1328 - 1329 - // Update room name in top bar from state 1330 - if (state.room_name) { 1331 - const nameEl = document.getElementById("room-name"); 1332 - if (nameEl) nameEl.textContent = state.room_name; 1333 - } 1334 - 1335 - // Update viewer count pill 1336 - const pill = document.getElementById("viewer-pill"); 1337 - if (pill) { 1338 - const c = state.clients || 0; 1339 - pill.textContent = c === 1 ? "1 viewer" : c + " viewers"; 1340 - } 1341 - 1342 - if (state.current_track) { 1343 - const trackId = state.current_track.id; 1344 - const trackChanged = currentTrackId !== trackId; 1345 - 1346 - // Detect track change — reset video state. 1347 - if (currentTrackId !== null && trackChanged) { 1348 - debug("track change", currentTrackId, "->", trackId); 1349 - awaitingInit = true; 1350 - pendingBoxes.length = 0; 1351 - seekInitDone = false; 1352 - if (elapsedInterval) { 1353 - clearInterval(elapsedInterval); 1354 - elapsedInterval = null; 1355 - } 1356 - } 1357 - currentTrackId = trackId; 1358 - 1359 - // Skip overlay DOM work when the track hasn't changed. 1360 - // Queue display is still updated below. 1361 - if (!trackChanged) { 1362 - updateQueueDisplay(state); 1363 - return; 1364 - } 1365 - 1366 - const startedAt = state.current_track.started_at; 1367 - const sourceUrl = state.current_track.url || ""; 1368 - 1369 - // Show track overlay, hide idle message 1370 - container.classList.remove("idle"); 1371 - 1372 - const toThumb = document.getElementById("to-thumb"); 1373 - const toTitle = document.getElementById("to-title"); 1374 - const toElapsed = document.getElementById("to-elapsed"); 1375 - const toDuration = document.getElementById("to-duration"); 1376 - const toViewers = document.getElementById("to-viewers"); 1377 - const toSource = document.getElementById("to-source"); 1378 - const toFill = document.getElementById("to-progress-fill"); 1379 - const toOverlay = document.getElementById("track-overlay"); 1380 - 1381 - // Thumbnail 1382 - if (state.current_track.thumbnail) { 1383 - toThumb.src = state.current_track.thumbnail; 1384 - toThumb.style.display = "block"; 1385 - } else { 1386 - toThumb.style.display = "none"; 1387 - } 1388 - 1389 - // Title 1390 - toTitle.textContent = state.current_track.title; 1391 - 1392 - // Duration 1393 - toDuration.textContent = " / " + state.current_track.duration; 1394 - 1395 - // Source link 1396 - if (sourceUrl) { 1397 - toSource.href = sourceUrl; 1398 - toSource.textContent = "source"; 1399 - toSource.style.display = "inline"; 1400 - } else { 1401 - toSource.style.display = "none"; 1402 - } 1403 - 1404 - // Viewers 1405 - toViewers.textContent = state.clients 1406 - ? " · " + state.clients + " viewer" + (state.clients !== 1 ? "s" : "") 1407 - : ""; 1408 - 1409 - // Elapsed (initial) 1410 - toElapsed.textContent = formatTime((Date.now() - startedAt) / 1000); 1411 - 1412 - // Ensure overlay is visible on first play 1413 - toOverlay.classList.add("show"); 1414 - setTimeout(() => toOverlay.classList.remove("show"), 3000); 1415 - 1416 - // Update elapsed every second from local clock. 1417 - elapsedInterval = setInterval(() => { 1418 - const elapsed = (Date.now() - startedAt) / 1000; 1419 - const elSpan = document.getElementById("to-elapsed"); 1420 - if (elSpan) elSpan.textContent = formatTime(elapsed); 1421 - 1422 - const fill = document.getElementById("to-progress-fill"); 1423 - if (fill && state.current_track) { 1424 - const total = parseDuration(state.current_track.duration); 1425 - if (total > 0) { 1426 - fill.style.width = Math.min(100, (elapsed / total) * 100) + "%"; 1427 - } 1428 - } 1429 - }, 1000); 1430 - 1431 - // Forward-only sync: if video is more than 3 seconds behind 1432 - // the live edge, seek forward to catch up. Never seek backward. 1433 - const video = document.getElementById("video-player"); 1434 - if (video && video.buffered.length > 0 && startedAt) { 1435 - const target = (Date.now() - startedAt) / 1000; 1436 - if (target > 0) { 1437 - const behind = target - video.currentTime; 1438 - if (behind > 3) { 1439 - video.currentTime = target; 1440 - } 1441 - } 1442 - } 1443 - } else { 1444 - if (elapsedInterval) { 1445 - clearInterval(elapsedInterval); 1446 - elapsedInterval = null; 1447 - } 1448 - currentTrackId = null; 1449 - container.classList.add("idle"); 1450 - } 1451 - 1452 - // Update queue section from state — inline, not deferred. 1453 - updateQueueDisplay(state); 1454 - } 1455 - 1456 - function updateQueueDisplay(state) { 1457 - const qSection = document.getElementById("queue-section"); 1458 - const hSection = document.getElementById("history-section"); 1459 - if (!qSection) return; 1460 - 1461 - if (state.queue && state.queue.length > 0) { 1462 - let html = '<ul class="queue">'; 1463 - for (const item of state.queue) { 1464 - const thumb = item.thumbnail 1465 - ? '<img src="' + esc(item.thumbnail) + '" class="thumb-img" alt="">' 1466 - : '<div class="thumb"></div>'; 1467 - const pendingClass = item.pending ? ' class="pending"' : ""; 1468 - html += 1469 - "<li" + 1470 - pendingClass + 1471 - ">" + 1472 - thumb + 1473 - '<div class="title">' + 1474 - esc(item.title) + 1475 - "</div>" + 1476 - '<div class="duration">' + 1477 - esc(item.duration) + 1478 - "</div>" + 1479 - '<a href="' + 1480 - safeUrl(item.url) + 1481 - '" target="_blank" class="source-link" rel="noopener noreferrer">source</a>' + 1482 - "</li>"; 1483 - } 1484 - html += "</ul>"; 1485 - qSection.innerHTML = html; 1486 - } else if (state.current_track) { 1487 - qSection.innerHTML = 1488 - '<div class="empty-state"><div class="icon">&#x25B6;</div><div class="label">No upcoming tracks</div><div class="hint">Add another link to keep the queue going.</div></div>'; 1489 - } else { 1490 - qSection.innerHTML = 1491 - '<div class="empty-state"><div class="icon">&#x1F3B5;</div><div class="label">Queue is empty</div><div class="hint">Paste a link from YouTube, SoundCloud, or Bandcamp above to get started.</div></div>'; 1492 - } 1493 - 1494 - if (hSection && state.history && state.history.length > 0) { 1495 - let html = '<div class="history-header">Played</div><ul class="queue">'; 1496 - for (const item of state.history) { 1497 - const thumb = item.thumbnail 1498 - ? '<img src="' + esc(item.thumbnail) + '" class="thumb-img" alt="">' 1499 - : '<div class="thumb"></div>'; 1500 - html += 1501 - '<li style="opacity:0.7">' + 1502 - thumb + 1503 - '<div class="title">' + 1504 - esc(item.title) + 1505 - "</div>" + 1506 - '<div class="duration">' + 1507 - esc(item.duration) + 1508 - "</div>" + 1509 - '<a href="' + 1510 - safeUrl(item.url) + 1511 - '" target="_blank" class="source-link" rel="noopener noreferrer">source</a>' + 1512 - "</li>"; 1513 - } 1514 - html += "</ul>"; 1515 - hSection.innerHTML = html; 1516 - } else if (hSection) { 1517 - hSection.innerHTML = ""; 1518 - } 1519 - } 1520 - 1521 - function formatTime(secs) { 1522 - const m = Math.floor(secs / 60); 1523 - const s = Math.floor(secs % 60); 1524 - return m + ":" + String(s).padStart(2, "0"); 1525 - } 1526 - 1527 - function esc(s) { 1528 - const d = document.createElement("div"); 1529 - d.textContent = s; 1530 - return d.innerHTML; 1531 - } 1532 - 1533 - function safeUrl(url) { 1534 - if (!url) return ""; 1535 - // Only allow http:// and https:// URLs. 1536 - if (url.startsWith("http://") || url.startsWith("https://")) { 1537 - return esc(url); 1538 - } 1539 - return ""; // Reject all other URL schemes (javascript:, data:, file:, etc.) 1540 - } 1541 - 1542 - // Send chat 1543 - document.getElementById("chat-send").addEventListener("click", sendChat); 1544 - document.getElementById("chat-input").addEventListener("keydown", (e) => { 1545 - if (e.key === "Enter") sendChat(); 1546 - }); 1547 - 1548 - function sendChat() { 1549 - const input = document.getElementById("chat-input"); 1550 - const content = input.value.trim(); 1551 - if (!content) return; 1552 - const payload = new TextEncoder().encode(JSON.stringify({ content })); 1553 - const obj = concat([ 1554 - encVarint(0x00), 1555 - encVarint(2), // track_id = Chat 1556 - encVarint(0), // group_id 1557 - encVarint(0), // object_id 1558 - payload, 1559 - ]); 1560 - ws.send(obj); 1561 - input.value = ""; 1562 - } 1563 - 1564 - // ── Ingest helper ─────────────────────────────────────────────── 1565 - async function ingest() { 1566 - const input = document.getElementById("url-input"); 1567 - const btn = document.querySelector(".input-row .btn-primary"); 1568 - if (!input.value.trim()) return; 1569 - btn.disabled = true; 1570 - btn.textContent = "Adding…"; 1571 - try { 1572 - const resp = await fetch("/api/ingest", { 1573 - method: "POST", 1574 - headers: { "Content-Type": "application/json" }, 1575 - body: JSON.stringify({ url: input.value, room_id: roomId }), 1576 - }); 1577 - if (resp.ok) { 1578 - input.value = ""; 1579 - } else { 1580 - const err = await resp.json(); 1581 - alert(err.error || "ingest failed"); 1582 - } 1583 - } finally { 1584 - btn.disabled = false; 1585 - btn.textContent = "Add"; 1586 - } 1587 - } 1588 - 1589 - // ── Skip current track ────────────────────────────────────────── 1590 - async function skip() { 1591 - await fetch("/api/skip", { 1592 - method: "POST", 1593 - headers: { "Content-Type": "application/json" }, 1594 - body: JSON.stringify({ room_id: roomId }), 1595 - }); 1596 - } 153 + {%- include "partials/room_scripts.html" -%} 1597 154 </script> 1598 155 </body> 1599 156 </html>