a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv chromecast mpris navidrome jellyfin upnp tui
15

Configure Feed

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

Initial Commit

Tsiry Sandratraina (Jul 2, 2026, 10:29 PM +0300) 8c3f7265

+8670
+1
.envrc
··· 1 + use flake
+2
.gitignore
··· 1 + target/ 2 +
+2925
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.103" 82 + source = "registry+https://github.com/rust-lang/crates.io-index" 83 + checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" 84 + 85 + [[package]] 86 + name = "async-trait" 87 + version = "0.1.89" 88 + source = "registry+https://github.com/rust-lang/crates.io-index" 89 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 90 + dependencies = [ 91 + "proc-macro2", 92 + "quote", 93 + "syn", 94 + ] 95 + 96 + [[package]] 97 + name = "atomic-waker" 98 + version = "1.1.2" 99 + source = "registry+https://github.com/rust-lang/crates.io-index" 100 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 101 + 102 + [[package]] 103 + name = "autocfg" 104 + version = "1.5.1" 105 + source = "registry+https://github.com/rust-lang/crates.io-index" 106 + checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" 107 + 108 + [[package]] 109 + name = "aws-lc-rs" 110 + version = "1.17.1" 111 + source = "registry+https://github.com/rust-lang/crates.io-index" 112 + checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" 113 + dependencies = [ 114 + "aws-lc-sys", 115 + "zeroize", 116 + ] 117 + 118 + [[package]] 119 + name = "aws-lc-sys" 120 + version = "0.42.0" 121 + source = "registry+https://github.com/rust-lang/crates.io-index" 122 + checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" 123 + dependencies = [ 124 + "cc", 125 + "cmake", 126 + "dunce", 127 + "fs_extra", 128 + "pkg-config", 129 + ] 130 + 131 + [[package]] 132 + name = "base64" 133 + version = "0.22.1" 134 + source = "registry+https://github.com/rust-lang/crates.io-index" 135 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 136 + 137 + [[package]] 138 + name = "bitflags" 139 + version = "1.3.2" 140 + source = "registry+https://github.com/rust-lang/crates.io-index" 141 + checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 142 + 143 + [[package]] 144 + name = "bitflags" 145 + version = "2.13.0" 146 + source = "registry+https://github.com/rust-lang/crates.io-index" 147 + checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" 148 + 149 + [[package]] 150 + name = "bumpalo" 151 + version = "3.20.3" 152 + source = "registry+https://github.com/rust-lang/crates.io-index" 153 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" 154 + 155 + [[package]] 156 + name = "byteorder" 157 + version = "1.5.0" 158 + source = "registry+https://github.com/rust-lang/crates.io-index" 159 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 160 + 161 + [[package]] 162 + name = "bytes" 163 + version = "1.12.0" 164 + source = "registry+https://github.com/rust-lang/crates.io-index" 165 + checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" 166 + 167 + [[package]] 168 + name = "cassowary" 169 + version = "0.3.0" 170 + source = "registry+https://github.com/rust-lang/crates.io-index" 171 + checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 172 + 173 + [[package]] 174 + name = "castaway" 175 + version = "0.2.4" 176 + source = "registry+https://github.com/rust-lang/crates.io-index" 177 + checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 178 + dependencies = [ 179 + "rustversion", 180 + ] 181 + 182 + [[package]] 183 + name = "cc" 184 + version = "1.2.65" 185 + source = "registry+https://github.com/rust-lang/crates.io-index" 186 + checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" 187 + dependencies = [ 188 + "find-msvc-tools", 189 + "jobserver", 190 + "libc", 191 + "shlex", 192 + ] 193 + 194 + [[package]] 195 + name = "cfg-if" 196 + version = "1.0.4" 197 + source = "registry+https://github.com/rust-lang/crates.io-index" 198 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 199 + 200 + [[package]] 201 + name = "cfg_aliases" 202 + version = "0.2.1" 203 + source = "registry+https://github.com/rust-lang/crates.io-index" 204 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 205 + 206 + [[package]] 207 + name = "chrono" 208 + version = "0.4.45" 209 + source = "registry+https://github.com/rust-lang/crates.io-index" 210 + checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" 211 + dependencies = [ 212 + "iana-time-zone", 213 + "js-sys", 214 + "num-traits", 215 + "serde", 216 + "wasm-bindgen", 217 + "windows-link", 218 + ] 219 + 220 + [[package]] 221 + name = "clap" 222 + version = "4.6.1" 223 + source = "registry+https://github.com/rust-lang/crates.io-index" 224 + checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" 225 + dependencies = [ 226 + "clap_builder", 227 + "clap_derive", 228 + ] 229 + 230 + [[package]] 231 + name = "clap_builder" 232 + version = "4.6.0" 233 + source = "registry+https://github.com/rust-lang/crates.io-index" 234 + checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" 235 + dependencies = [ 236 + "anstream", 237 + "anstyle", 238 + "clap_lex", 239 + "strsim", 240 + ] 241 + 242 + [[package]] 243 + name = "clap_derive" 244 + version = "4.6.1" 245 + source = "registry+https://github.com/rust-lang/crates.io-index" 246 + checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" 247 + dependencies = [ 248 + "heck", 249 + "proc-macro2", 250 + "quote", 251 + "syn", 252 + ] 253 + 254 + [[package]] 255 + name = "clap_lex" 256 + version = "1.1.0" 257 + source = "registry+https://github.com/rust-lang/crates.io-index" 258 + checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 259 + 260 + [[package]] 261 + name = "cmake" 262 + version = "0.1.58" 263 + source = "registry+https://github.com/rust-lang/crates.io-index" 264 + checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" 265 + dependencies = [ 266 + "cc", 267 + ] 268 + 269 + [[package]] 270 + name = "colorchoice" 271 + version = "1.0.5" 272 + source = "registry+https://github.com/rust-lang/crates.io-index" 273 + checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" 274 + 275 + [[package]] 276 + name = "compact_str" 277 + version = "0.8.2" 278 + source = "registry+https://github.com/rust-lang/crates.io-index" 279 + checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" 280 + dependencies = [ 281 + "castaway", 282 + "cfg-if", 283 + "itoa", 284 + "rustversion", 285 + "ryu", 286 + "static_assertions", 287 + ] 288 + 289 + [[package]] 290 + name = "concurrent-queue" 291 + version = "2.5.0" 292 + source = "registry+https://github.com/rust-lang/crates.io-index" 293 + checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" 294 + dependencies = [ 295 + "crossbeam-utils", 296 + ] 297 + 298 + [[package]] 299 + name = "core-foundation" 300 + version = "0.10.1" 301 + source = "registry+https://github.com/rust-lang/crates.io-index" 302 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" 303 + dependencies = [ 304 + "core-foundation-sys", 305 + "libc", 306 + ] 307 + 308 + [[package]] 309 + name = "core-foundation-sys" 310 + version = "0.8.7" 311 + source = "registry+https://github.com/rust-lang/crates.io-index" 312 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 313 + 314 + [[package]] 315 + name = "crossbeam-utils" 316 + version = "0.8.21" 317 + source = "registry+https://github.com/rust-lang/crates.io-index" 318 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 319 + 320 + [[package]] 321 + name = "crossterm" 322 + version = "0.28.1" 323 + source = "registry+https://github.com/rust-lang/crates.io-index" 324 + checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 325 + dependencies = [ 326 + "bitflags 2.13.0", 327 + "crossterm_winapi", 328 + "futures-core", 329 + "mio", 330 + "parking_lot", 331 + "rustix 0.38.44", 332 + "signal-hook", 333 + "signal-hook-mio", 334 + "winapi", 335 + ] 336 + 337 + [[package]] 338 + name = "crossterm_winapi" 339 + version = "0.9.1" 340 + source = "registry+https://github.com/rust-lang/crates.io-index" 341 + checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 342 + dependencies = [ 343 + "winapi", 344 + ] 345 + 346 + [[package]] 347 + name = "darling" 348 + version = "0.23.0" 349 + source = "registry+https://github.com/rust-lang/crates.io-index" 350 + checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" 351 + dependencies = [ 352 + "darling_core", 353 + "darling_macro", 354 + ] 355 + 356 + [[package]] 357 + name = "darling_core" 358 + version = "0.23.0" 359 + source = "registry+https://github.com/rust-lang/crates.io-index" 360 + checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" 361 + dependencies = [ 362 + "ident_case", 363 + "proc-macro2", 364 + "quote", 365 + "strsim", 366 + "syn", 367 + ] 368 + 369 + [[package]] 370 + name = "darling_macro" 371 + version = "0.23.0" 372 + source = "registry+https://github.com/rust-lang/crates.io-index" 373 + checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" 374 + dependencies = [ 375 + "darling_core", 376 + "quote", 377 + "syn", 378 + ] 379 + 380 + [[package]] 381 + name = "directories" 382 + version = "5.0.1" 383 + source = "registry+https://github.com/rust-lang/crates.io-index" 384 + checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" 385 + dependencies = [ 386 + "dirs-sys", 387 + ] 388 + 389 + [[package]] 390 + name = "dirs-sys" 391 + version = "0.4.1" 392 + source = "registry+https://github.com/rust-lang/crates.io-index" 393 + checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 394 + dependencies = [ 395 + "libc", 396 + "option-ext", 397 + "redox_users", 398 + "windows-sys 0.48.0", 399 + ] 400 + 401 + [[package]] 402 + name = "displaydoc" 403 + version = "0.2.6" 404 + source = "registry+https://github.com/rust-lang/crates.io-index" 405 + checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" 406 + dependencies = [ 407 + "proc-macro2", 408 + "quote", 409 + "syn", 410 + ] 411 + 412 + [[package]] 413 + name = "dunce" 414 + version = "1.0.5" 415 + source = "registry+https://github.com/rust-lang/crates.io-index" 416 + checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 417 + 418 + [[package]] 419 + name = "either" 420 + version = "1.16.0" 421 + source = "registry+https://github.com/rust-lang/crates.io-index" 422 + checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" 423 + 424 + [[package]] 425 + name = "equivalent" 426 + version = "1.0.2" 427 + source = "registry+https://github.com/rust-lang/crates.io-index" 428 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 429 + 430 + [[package]] 431 + name = "errno" 432 + version = "0.3.14" 433 + source = "registry+https://github.com/rust-lang/crates.io-index" 434 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 435 + dependencies = [ 436 + "libc", 437 + "windows-sys 0.61.2", 438 + ] 439 + 440 + [[package]] 441 + name = "fastrand" 442 + version = "2.4.1" 443 + source = "registry+https://github.com/rust-lang/crates.io-index" 444 + checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" 445 + 446 + [[package]] 447 + name = "fin" 448 + version = "0.1.0" 449 + dependencies = [ 450 + "anyhow", 451 + "clap", 452 + "directories", 453 + "fin-config", 454 + "fin-jellyfin", 455 + "fin-player", 456 + "fin-tui", 457 + "rpassword", 458 + "serde", 459 + "serde_json", 460 + "tokio", 461 + "toml", 462 + "tracing", 463 + "tracing-subscriber", 464 + ] 465 + 466 + [[package]] 467 + name = "fin-config" 468 + version = "0.1.0" 469 + dependencies = [ 470 + "anyhow", 471 + "directories", 472 + "serde", 473 + "toml", 474 + ] 475 + 476 + [[package]] 477 + name = "fin-jellyfin" 478 + version = "0.1.0" 479 + dependencies = [ 480 + "anyhow", 481 + "chrono", 482 + "fin-config", 483 + "percent-encoding", 484 + "reqwest", 485 + "serde", 486 + "serde_json", 487 + "thiserror 1.0.69", 488 + "tokio", 489 + "tracing", 490 + "url", 491 + "uuid", 492 + "whoami", 493 + ] 494 + 495 + [[package]] 496 + name = "fin-player" 497 + version = "0.1.0" 498 + dependencies = [ 499 + "anyhow", 500 + "async-trait", 501 + "fin-jellyfin", 502 + "futures", 503 + "mdns-sd", 504 + "parking_lot", 505 + "rust_cast", 506 + "serde", 507 + "serde_json", 508 + "thiserror 1.0.69", 509 + "tokio", 510 + "tracing", 511 + "uuid", 512 + ] 513 + 514 + [[package]] 515 + name = "fin-tui" 516 + version = "0.1.0" 517 + dependencies = [ 518 + "anyhow", 519 + "async-trait", 520 + "chrono", 521 + "crossterm", 522 + "fin-config", 523 + "fin-jellyfin", 524 + "fin-player", 525 + "futures", 526 + "parking_lot", 527 + "ratatui", 528 + "serde", 529 + "serde_json", 530 + "thiserror 1.0.69", 531 + "tokio", 532 + "tracing", 533 + "unicode-width 0.2.0", 534 + ] 535 + 536 + [[package]] 537 + name = "find-msvc-tools" 538 + version = "0.1.9" 539 + source = "registry+https://github.com/rust-lang/crates.io-index" 540 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 541 + 542 + [[package]] 543 + name = "flume" 544 + version = "0.11.1" 545 + source = "registry+https://github.com/rust-lang/crates.io-index" 546 + checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" 547 + dependencies = [ 548 + "futures-core", 549 + "futures-sink", 550 + "spin", 551 + ] 552 + 553 + [[package]] 554 + name = "foldhash" 555 + version = "0.1.5" 556 + source = "registry+https://github.com/rust-lang/crates.io-index" 557 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 558 + 559 + [[package]] 560 + name = "form_urlencoded" 561 + version = "1.2.2" 562 + source = "registry+https://github.com/rust-lang/crates.io-index" 563 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 564 + dependencies = [ 565 + "percent-encoding", 566 + ] 567 + 568 + [[package]] 569 + name = "fs_extra" 570 + version = "1.3.0" 571 + source = "registry+https://github.com/rust-lang/crates.io-index" 572 + checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" 573 + 574 + [[package]] 575 + name = "futures" 576 + version = "0.3.32" 577 + source = "registry+https://github.com/rust-lang/crates.io-index" 578 + checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" 579 + dependencies = [ 580 + "futures-channel", 581 + "futures-core", 582 + "futures-executor", 583 + "futures-io", 584 + "futures-sink", 585 + "futures-task", 586 + "futures-util", 587 + ] 588 + 589 + [[package]] 590 + name = "futures-channel" 591 + version = "0.3.32" 592 + source = "registry+https://github.com/rust-lang/crates.io-index" 593 + checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 594 + dependencies = [ 595 + "futures-core", 596 + "futures-sink", 597 + ] 598 + 599 + [[package]] 600 + name = "futures-core" 601 + version = "0.3.32" 602 + source = "registry+https://github.com/rust-lang/crates.io-index" 603 + checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 604 + 605 + [[package]] 606 + name = "futures-executor" 607 + version = "0.3.32" 608 + source = "registry+https://github.com/rust-lang/crates.io-index" 609 + checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" 610 + dependencies = [ 611 + "futures-core", 612 + "futures-task", 613 + "futures-util", 614 + ] 615 + 616 + [[package]] 617 + name = "futures-io" 618 + version = "0.3.32" 619 + source = "registry+https://github.com/rust-lang/crates.io-index" 620 + checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" 621 + 622 + [[package]] 623 + name = "futures-macro" 624 + version = "0.3.32" 625 + source = "registry+https://github.com/rust-lang/crates.io-index" 626 + checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" 627 + dependencies = [ 628 + "proc-macro2", 629 + "quote", 630 + "syn", 631 + ] 632 + 633 + [[package]] 634 + name = "futures-sink" 635 + version = "0.3.32" 636 + source = "registry+https://github.com/rust-lang/crates.io-index" 637 + checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" 638 + 639 + [[package]] 640 + name = "futures-task" 641 + version = "0.3.32" 642 + source = "registry+https://github.com/rust-lang/crates.io-index" 643 + checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" 644 + 645 + [[package]] 646 + name = "futures-util" 647 + version = "0.3.32" 648 + source = "registry+https://github.com/rust-lang/crates.io-index" 649 + checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 650 + dependencies = [ 651 + "futures-channel", 652 + "futures-core", 653 + "futures-io", 654 + "futures-macro", 655 + "futures-sink", 656 + "futures-task", 657 + "memchr", 658 + "pin-project-lite", 659 + "slab", 660 + ] 661 + 662 + [[package]] 663 + name = "getrandom" 664 + version = "0.2.17" 665 + source = "registry+https://github.com/rust-lang/crates.io-index" 666 + checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" 667 + dependencies = [ 668 + "cfg-if", 669 + "js-sys", 670 + "libc", 671 + "wasi", 672 + "wasm-bindgen", 673 + ] 674 + 675 + [[package]] 676 + name = "getrandom" 677 + version = "0.3.4" 678 + source = "registry+https://github.com/rust-lang/crates.io-index" 679 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 680 + dependencies = [ 681 + "cfg-if", 682 + "js-sys", 683 + "libc", 684 + "r-efi 5.3.0", 685 + "wasip2", 686 + "wasm-bindgen", 687 + ] 688 + 689 + [[package]] 690 + name = "getrandom" 691 + version = "0.4.3" 692 + source = "registry+https://github.com/rust-lang/crates.io-index" 693 + checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" 694 + dependencies = [ 695 + "cfg-if", 696 + "libc", 697 + "r-efi 6.0.0", 698 + ] 699 + 700 + [[package]] 701 + name = "hashbrown" 702 + version = "0.15.5" 703 + source = "registry+https://github.com/rust-lang/crates.io-index" 704 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 705 + dependencies = [ 706 + "allocator-api2", 707 + "equivalent", 708 + "foldhash", 709 + ] 710 + 711 + [[package]] 712 + name = "hashbrown" 713 + version = "0.17.1" 714 + source = "registry+https://github.com/rust-lang/crates.io-index" 715 + checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 716 + 717 + [[package]] 718 + name = "heck" 719 + version = "0.5.0" 720 + source = "registry+https://github.com/rust-lang/crates.io-index" 721 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 722 + 723 + [[package]] 724 + name = "home" 725 + version = "0.5.12" 726 + source = "registry+https://github.com/rust-lang/crates.io-index" 727 + checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" 728 + dependencies = [ 729 + "windows-sys 0.61.2", 730 + ] 731 + 732 + [[package]] 733 + name = "http" 734 + version = "1.4.2" 735 + source = "registry+https://github.com/rust-lang/crates.io-index" 736 + checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" 737 + dependencies = [ 738 + "bytes", 739 + "itoa", 740 + ] 741 + 742 + [[package]] 743 + name = "http-body" 744 + version = "1.0.1" 745 + source = "registry+https://github.com/rust-lang/crates.io-index" 746 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 747 + dependencies = [ 748 + "bytes", 749 + "http", 750 + ] 751 + 752 + [[package]] 753 + name = "http-body-util" 754 + version = "0.1.3" 755 + source = "registry+https://github.com/rust-lang/crates.io-index" 756 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 757 + dependencies = [ 758 + "bytes", 759 + "futures-core", 760 + "http", 761 + "http-body", 762 + "pin-project-lite", 763 + ] 764 + 765 + [[package]] 766 + name = "httparse" 767 + version = "1.10.1" 768 + source = "registry+https://github.com/rust-lang/crates.io-index" 769 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 770 + 771 + [[package]] 772 + name = "hyper" 773 + version = "1.10.1" 774 + source = "registry+https://github.com/rust-lang/crates.io-index" 775 + checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" 776 + dependencies = [ 777 + "atomic-waker", 778 + "bytes", 779 + "futures-channel", 780 + "futures-core", 781 + "http", 782 + "http-body", 783 + "httparse", 784 + "itoa", 785 + "pin-project-lite", 786 + "smallvec", 787 + "tokio", 788 + "want", 789 + ] 790 + 791 + [[package]] 792 + name = "hyper-rustls" 793 + version = "0.27.9" 794 + source = "registry+https://github.com/rust-lang/crates.io-index" 795 + checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" 796 + dependencies = [ 797 + "http", 798 + "hyper", 799 + "hyper-util", 800 + "rustls", 801 + "tokio", 802 + "tokio-rustls", 803 + "tower-service", 804 + "webpki-roots", 805 + ] 806 + 807 + [[package]] 808 + name = "hyper-util" 809 + version = "0.1.20" 810 + source = "registry+https://github.com/rust-lang/crates.io-index" 811 + checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" 812 + dependencies = [ 813 + "base64", 814 + "bytes", 815 + "futures-channel", 816 + "futures-util", 817 + "http", 818 + "http-body", 819 + "hyper", 820 + "ipnet", 821 + "libc", 822 + "percent-encoding", 823 + "pin-project-lite", 824 + "socket2 0.6.4", 825 + "tokio", 826 + "tower-service", 827 + "tracing", 828 + ] 829 + 830 + [[package]] 831 + name = "iana-time-zone" 832 + version = "0.1.65" 833 + source = "registry+https://github.com/rust-lang/crates.io-index" 834 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" 835 + dependencies = [ 836 + "android_system_properties", 837 + "core-foundation-sys", 838 + "iana-time-zone-haiku", 839 + "js-sys", 840 + "log", 841 + "wasm-bindgen", 842 + "windows-core", 843 + ] 844 + 845 + [[package]] 846 + name = "iana-time-zone-haiku" 847 + version = "0.1.2" 848 + source = "registry+https://github.com/rust-lang/crates.io-index" 849 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 850 + dependencies = [ 851 + "cc", 852 + ] 853 + 854 + [[package]] 855 + name = "icu_collections" 856 + version = "2.2.0" 857 + source = "registry+https://github.com/rust-lang/crates.io-index" 858 + checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" 859 + dependencies = [ 860 + "displaydoc", 861 + "potential_utf", 862 + "utf8_iter", 863 + "yoke", 864 + "zerofrom", 865 + "zerovec", 866 + ] 867 + 868 + [[package]] 869 + name = "icu_locale_core" 870 + version = "2.2.0" 871 + source = "registry+https://github.com/rust-lang/crates.io-index" 872 + checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" 873 + dependencies = [ 874 + "displaydoc", 875 + "litemap", 876 + "tinystr", 877 + "writeable", 878 + "zerovec", 879 + ] 880 + 881 + [[package]] 882 + name = "icu_normalizer" 883 + version = "2.2.0" 884 + source = "registry+https://github.com/rust-lang/crates.io-index" 885 + checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" 886 + dependencies = [ 887 + "icu_collections", 888 + "icu_normalizer_data", 889 + "icu_properties", 890 + "icu_provider", 891 + "smallvec", 892 + "zerovec", 893 + ] 894 + 895 + [[package]] 896 + name = "icu_normalizer_data" 897 + version = "2.2.0" 898 + source = "registry+https://github.com/rust-lang/crates.io-index" 899 + checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" 900 + 901 + [[package]] 902 + name = "icu_properties" 903 + version = "2.2.0" 904 + source = "registry+https://github.com/rust-lang/crates.io-index" 905 + checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" 906 + dependencies = [ 907 + "icu_collections", 908 + "icu_locale_core", 909 + "icu_properties_data", 910 + "icu_provider", 911 + "zerotrie", 912 + "zerovec", 913 + ] 914 + 915 + [[package]] 916 + name = "icu_properties_data" 917 + version = "2.2.0" 918 + source = "registry+https://github.com/rust-lang/crates.io-index" 919 + checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" 920 + 921 + [[package]] 922 + name = "icu_provider" 923 + version = "2.2.0" 924 + source = "registry+https://github.com/rust-lang/crates.io-index" 925 + checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" 926 + dependencies = [ 927 + "displaydoc", 928 + "icu_locale_core", 929 + "writeable", 930 + "yoke", 931 + "zerofrom", 932 + "zerotrie", 933 + "zerovec", 934 + ] 935 + 936 + [[package]] 937 + name = "ident_case" 938 + version = "1.0.1" 939 + source = "registry+https://github.com/rust-lang/crates.io-index" 940 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 941 + 942 + [[package]] 943 + name = "idna" 944 + version = "1.1.0" 945 + source = "registry+https://github.com/rust-lang/crates.io-index" 946 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 947 + dependencies = [ 948 + "idna_adapter", 949 + "smallvec", 950 + "utf8_iter", 951 + ] 952 + 953 + [[package]] 954 + name = "idna_adapter" 955 + version = "1.2.2" 956 + source = "registry+https://github.com/rust-lang/crates.io-index" 957 + checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" 958 + dependencies = [ 959 + "icu_normalizer", 960 + "icu_properties", 961 + ] 962 + 963 + [[package]] 964 + name = "if-addrs" 965 + version = "0.13.4" 966 + source = "registry+https://github.com/rust-lang/crates.io-index" 967 + checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" 968 + dependencies = [ 969 + "libc", 970 + "windows-sys 0.59.0", 971 + ] 972 + 973 + [[package]] 974 + name = "indexmap" 975 + version = "2.14.0" 976 + source = "registry+https://github.com/rust-lang/crates.io-index" 977 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" 978 + dependencies = [ 979 + "equivalent", 980 + "hashbrown 0.17.1", 981 + ] 982 + 983 + [[package]] 984 + name = "indoc" 985 + version = "2.0.7" 986 + source = "registry+https://github.com/rust-lang/crates.io-index" 987 + checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" 988 + dependencies = [ 989 + "rustversion", 990 + ] 991 + 992 + [[package]] 993 + name = "instability" 994 + version = "0.3.12" 995 + source = "registry+https://github.com/rust-lang/crates.io-index" 996 + checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" 997 + dependencies = [ 998 + "darling", 999 + "indoc", 1000 + "proc-macro2", 1001 + "quote", 1002 + "syn", 1003 + ] 1004 + 1005 + [[package]] 1006 + name = "ipnet" 1007 + version = "2.12.0" 1008 + source = "registry+https://github.com/rust-lang/crates.io-index" 1009 + checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" 1010 + 1011 + [[package]] 1012 + name = "is_terminal_polyfill" 1013 + version = "1.70.2" 1014 + source = "registry+https://github.com/rust-lang/crates.io-index" 1015 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 1016 + 1017 + [[package]] 1018 + name = "itertools" 1019 + version = "0.13.0" 1020 + source = "registry+https://github.com/rust-lang/crates.io-index" 1021 + checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 1022 + dependencies = [ 1023 + "either", 1024 + ] 1025 + 1026 + [[package]] 1027 + name = "itoa" 1028 + version = "1.0.18" 1029 + source = "registry+https://github.com/rust-lang/crates.io-index" 1030 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 1031 + 1032 + [[package]] 1033 + name = "jobserver" 1034 + version = "0.1.34" 1035 + source = "registry+https://github.com/rust-lang/crates.io-index" 1036 + checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 1037 + dependencies = [ 1038 + "getrandom 0.3.4", 1039 + "libc", 1040 + ] 1041 + 1042 + [[package]] 1043 + name = "js-sys" 1044 + version = "0.3.103" 1045 + source = "registry+https://github.com/rust-lang/crates.io-index" 1046 + checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" 1047 + dependencies = [ 1048 + "cfg-if", 1049 + "futures-util", 1050 + "wasm-bindgen", 1051 + ] 1052 + 1053 + [[package]] 1054 + name = "lazy_static" 1055 + version = "1.5.0" 1056 + source = "registry+https://github.com/rust-lang/crates.io-index" 1057 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1058 + 1059 + [[package]] 1060 + name = "libc" 1061 + version = "0.2.186" 1062 + source = "registry+https://github.com/rust-lang/crates.io-index" 1063 + checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" 1064 + 1065 + [[package]] 1066 + name = "libredox" 1067 + version = "0.1.18" 1068 + source = "registry+https://github.com/rust-lang/crates.io-index" 1069 + checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" 1070 + dependencies = [ 1071 + "bitflags 2.13.0", 1072 + "libc", 1073 + "plain", 1074 + "redox_syscall 0.9.0", 1075 + ] 1076 + 1077 + [[package]] 1078 + name = "linux-raw-sys" 1079 + version = "0.4.15" 1080 + source = "registry+https://github.com/rust-lang/crates.io-index" 1081 + checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 1082 + 1083 + [[package]] 1084 + name = "linux-raw-sys" 1085 + version = "0.12.1" 1086 + source = "registry+https://github.com/rust-lang/crates.io-index" 1087 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" 1088 + 1089 + [[package]] 1090 + name = "litemap" 1091 + version = "0.8.2" 1092 + source = "registry+https://github.com/rust-lang/crates.io-index" 1093 + checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" 1094 + 1095 + [[package]] 1096 + name = "lock_api" 1097 + version = "0.4.14" 1098 + source = "registry+https://github.com/rust-lang/crates.io-index" 1099 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 1100 + dependencies = [ 1101 + "scopeguard", 1102 + ] 1103 + 1104 + [[package]] 1105 + name = "log" 1106 + version = "0.4.33" 1107 + source = "registry+https://github.com/rust-lang/crates.io-index" 1108 + checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" 1109 + 1110 + [[package]] 1111 + name = "lru" 1112 + version = "0.12.5" 1113 + source = "registry+https://github.com/rust-lang/crates.io-index" 1114 + checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 1115 + dependencies = [ 1116 + "hashbrown 0.15.5", 1117 + ] 1118 + 1119 + [[package]] 1120 + name = "lru-slab" 1121 + version = "0.1.2" 1122 + source = "registry+https://github.com/rust-lang/crates.io-index" 1123 + checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 1124 + 1125 + [[package]] 1126 + name = "matchers" 1127 + version = "0.2.0" 1128 + source = "registry+https://github.com/rust-lang/crates.io-index" 1129 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 1130 + dependencies = [ 1131 + "regex-automata", 1132 + ] 1133 + 1134 + [[package]] 1135 + name = "mdns-sd" 1136 + version = "0.11.5" 1137 + source = "registry+https://github.com/rust-lang/crates.io-index" 1138 + checksum = "8fe7c11a1eb3cfbfcf702d1601c1f5f4c102cdc8665b8a557783ef634741676e" 1139 + dependencies = [ 1140 + "flume", 1141 + "if-addrs", 1142 + "log", 1143 + "polling", 1144 + "socket2 0.5.10", 1145 + ] 1146 + 1147 + [[package]] 1148 + name = "memchr" 1149 + version = "2.8.2" 1150 + source = "registry+https://github.com/rust-lang/crates.io-index" 1151 + checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" 1152 + 1153 + [[package]] 1154 + name = "mio" 1155 + version = "1.2.1" 1156 + source = "registry+https://github.com/rust-lang/crates.io-index" 1157 + checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" 1158 + dependencies = [ 1159 + "libc", 1160 + "log", 1161 + "wasi", 1162 + "windows-sys 0.61.2", 1163 + ] 1164 + 1165 + [[package]] 1166 + name = "nu-ansi-term" 1167 + version = "0.50.3" 1168 + source = "registry+https://github.com/rust-lang/crates.io-index" 1169 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 1170 + dependencies = [ 1171 + "windows-sys 0.61.2", 1172 + ] 1173 + 1174 + [[package]] 1175 + name = "num-traits" 1176 + version = "0.2.19" 1177 + source = "registry+https://github.com/rust-lang/crates.io-index" 1178 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1179 + dependencies = [ 1180 + "autocfg", 1181 + ] 1182 + 1183 + [[package]] 1184 + name = "once_cell" 1185 + version = "1.21.4" 1186 + source = "registry+https://github.com/rust-lang/crates.io-index" 1187 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 1188 + 1189 + [[package]] 1190 + name = "once_cell_polyfill" 1191 + version = "1.70.2" 1192 + source = "registry+https://github.com/rust-lang/crates.io-index" 1193 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 1194 + 1195 + [[package]] 1196 + name = "openssl-probe" 1197 + version = "0.2.1" 1198 + source = "registry+https://github.com/rust-lang/crates.io-index" 1199 + checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" 1200 + 1201 + [[package]] 1202 + name = "option-ext" 1203 + version = "0.2.0" 1204 + source = "registry+https://github.com/rust-lang/crates.io-index" 1205 + checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 1206 + 1207 + [[package]] 1208 + name = "parking_lot" 1209 + version = "0.12.5" 1210 + source = "registry+https://github.com/rust-lang/crates.io-index" 1211 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 1212 + dependencies = [ 1213 + "lock_api", 1214 + "parking_lot_core", 1215 + ] 1216 + 1217 + [[package]] 1218 + name = "parking_lot_core" 1219 + version = "0.9.12" 1220 + source = "registry+https://github.com/rust-lang/crates.io-index" 1221 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 1222 + dependencies = [ 1223 + "cfg-if", 1224 + "libc", 1225 + "redox_syscall 0.5.18", 1226 + "smallvec", 1227 + "windows-link", 1228 + ] 1229 + 1230 + [[package]] 1231 + name = "paste" 1232 + version = "1.0.15" 1233 + source = "registry+https://github.com/rust-lang/crates.io-index" 1234 + checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 1235 + 1236 + [[package]] 1237 + name = "percent-encoding" 1238 + version = "2.3.2" 1239 + source = "registry+https://github.com/rust-lang/crates.io-index" 1240 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 1241 + 1242 + [[package]] 1243 + name = "pin-project-lite" 1244 + version = "0.2.17" 1245 + source = "registry+https://github.com/rust-lang/crates.io-index" 1246 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 1247 + 1248 + [[package]] 1249 + name = "pkg-config" 1250 + version = "0.3.33" 1251 + source = "registry+https://github.com/rust-lang/crates.io-index" 1252 + checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" 1253 + 1254 + [[package]] 1255 + name = "plain" 1256 + version = "0.2.3" 1257 + source = "registry+https://github.com/rust-lang/crates.io-index" 1258 + checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" 1259 + 1260 + [[package]] 1261 + name = "polling" 1262 + version = "2.8.0" 1263 + source = "registry+https://github.com/rust-lang/crates.io-index" 1264 + checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" 1265 + dependencies = [ 1266 + "autocfg", 1267 + "bitflags 1.3.2", 1268 + "cfg-if", 1269 + "concurrent-queue", 1270 + "libc", 1271 + "log", 1272 + "pin-project-lite", 1273 + "windows-sys 0.48.0", 1274 + ] 1275 + 1276 + [[package]] 1277 + name = "potential_utf" 1278 + version = "0.1.5" 1279 + source = "registry+https://github.com/rust-lang/crates.io-index" 1280 + checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" 1281 + dependencies = [ 1282 + "zerovec", 1283 + ] 1284 + 1285 + [[package]] 1286 + name = "ppv-lite86" 1287 + version = "0.2.21" 1288 + source = "registry+https://github.com/rust-lang/crates.io-index" 1289 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 1290 + dependencies = [ 1291 + "zerocopy", 1292 + ] 1293 + 1294 + [[package]] 1295 + name = "proc-macro2" 1296 + version = "1.0.106" 1297 + source = "registry+https://github.com/rust-lang/crates.io-index" 1298 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 1299 + dependencies = [ 1300 + "unicode-ident", 1301 + ] 1302 + 1303 + [[package]] 1304 + name = "protobuf" 1305 + version = "3.7.2" 1306 + source = "registry+https://github.com/rust-lang/crates.io-index" 1307 + checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" 1308 + dependencies = [ 1309 + "once_cell", 1310 + "protobuf-support", 1311 + "thiserror 1.0.69", 1312 + ] 1313 + 1314 + [[package]] 1315 + name = "protobuf-codegen" 1316 + version = "3.7.2" 1317 + source = "registry+https://github.com/rust-lang/crates.io-index" 1318 + checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" 1319 + dependencies = [ 1320 + "anyhow", 1321 + "once_cell", 1322 + "protobuf", 1323 + "protobuf-parse", 1324 + "regex", 1325 + "tempfile", 1326 + "thiserror 1.0.69", 1327 + ] 1328 + 1329 + [[package]] 1330 + name = "protobuf-parse" 1331 + version = "3.7.2" 1332 + source = "registry+https://github.com/rust-lang/crates.io-index" 1333 + checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" 1334 + dependencies = [ 1335 + "anyhow", 1336 + "indexmap", 1337 + "log", 1338 + "protobuf", 1339 + "protobuf-support", 1340 + "tempfile", 1341 + "thiserror 1.0.69", 1342 + "which", 1343 + ] 1344 + 1345 + [[package]] 1346 + name = "protobuf-support" 1347 + version = "3.7.2" 1348 + source = "registry+https://github.com/rust-lang/crates.io-index" 1349 + checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" 1350 + dependencies = [ 1351 + "thiserror 1.0.69", 1352 + ] 1353 + 1354 + [[package]] 1355 + name = "quinn" 1356 + version = "0.11.11" 1357 + source = "registry+https://github.com/rust-lang/crates.io-index" 1358 + checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" 1359 + dependencies = [ 1360 + "bytes", 1361 + "cfg_aliases", 1362 + "pin-project-lite", 1363 + "quinn-proto", 1364 + "quinn-udp", 1365 + "rustc-hash", 1366 + "rustls", 1367 + "socket2 0.6.4", 1368 + "thiserror 2.0.18", 1369 + "tokio", 1370 + "tracing", 1371 + "web-time", 1372 + ] 1373 + 1374 + [[package]] 1375 + name = "quinn-proto" 1376 + version = "0.11.15" 1377 + source = "registry+https://github.com/rust-lang/crates.io-index" 1378 + checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" 1379 + dependencies = [ 1380 + "bytes", 1381 + "getrandom 0.3.4", 1382 + "lru-slab", 1383 + "rand", 1384 + "ring", 1385 + "rustc-hash", 1386 + "rustls", 1387 + "rustls-pki-types", 1388 + "slab", 1389 + "thiserror 2.0.18", 1390 + "tinyvec", 1391 + "tracing", 1392 + "web-time", 1393 + ] 1394 + 1395 + [[package]] 1396 + name = "quinn-udp" 1397 + version = "0.5.14" 1398 + source = "registry+https://github.com/rust-lang/crates.io-index" 1399 + checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" 1400 + dependencies = [ 1401 + "cfg_aliases", 1402 + "libc", 1403 + "once_cell", 1404 + "socket2 0.6.4", 1405 + "tracing", 1406 + "windows-sys 0.60.2", 1407 + ] 1408 + 1409 + [[package]] 1410 + name = "quote" 1411 + version = "1.0.46" 1412 + source = "registry+https://github.com/rust-lang/crates.io-index" 1413 + checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" 1414 + dependencies = [ 1415 + "proc-macro2", 1416 + ] 1417 + 1418 + [[package]] 1419 + name = "r-efi" 1420 + version = "5.3.0" 1421 + source = "registry+https://github.com/rust-lang/crates.io-index" 1422 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 1423 + 1424 + [[package]] 1425 + name = "r-efi" 1426 + version = "6.0.0" 1427 + source = "registry+https://github.com/rust-lang/crates.io-index" 1428 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 1429 + 1430 + [[package]] 1431 + name = "rand" 1432 + version = "0.9.4" 1433 + source = "registry+https://github.com/rust-lang/crates.io-index" 1434 + checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" 1435 + dependencies = [ 1436 + "rand_chacha", 1437 + "rand_core", 1438 + ] 1439 + 1440 + [[package]] 1441 + name = "rand_chacha" 1442 + version = "0.9.0" 1443 + source = "registry+https://github.com/rust-lang/crates.io-index" 1444 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1445 + dependencies = [ 1446 + "ppv-lite86", 1447 + "rand_core", 1448 + ] 1449 + 1450 + [[package]] 1451 + name = "rand_core" 1452 + version = "0.9.5" 1453 + source = "registry+https://github.com/rust-lang/crates.io-index" 1454 + checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" 1455 + dependencies = [ 1456 + "getrandom 0.3.4", 1457 + ] 1458 + 1459 + [[package]] 1460 + name = "ratatui" 1461 + version = "0.29.0" 1462 + source = "registry+https://github.com/rust-lang/crates.io-index" 1463 + checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 1464 + dependencies = [ 1465 + "bitflags 2.13.0", 1466 + "cassowary", 1467 + "compact_str", 1468 + "crossterm", 1469 + "indoc", 1470 + "instability", 1471 + "itertools", 1472 + "lru", 1473 + "paste", 1474 + "strum", 1475 + "unicode-segmentation", 1476 + "unicode-truncate", 1477 + "unicode-width 0.2.0", 1478 + ] 1479 + 1480 + [[package]] 1481 + name = "redox_syscall" 1482 + version = "0.5.18" 1483 + source = "registry+https://github.com/rust-lang/crates.io-index" 1484 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 1485 + dependencies = [ 1486 + "bitflags 2.13.0", 1487 + ] 1488 + 1489 + [[package]] 1490 + name = "redox_syscall" 1491 + version = "0.9.0" 1492 + source = "registry+https://github.com/rust-lang/crates.io-index" 1493 + checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" 1494 + dependencies = [ 1495 + "bitflags 2.13.0", 1496 + ] 1497 + 1498 + [[package]] 1499 + name = "redox_users" 1500 + version = "0.4.6" 1501 + source = "registry+https://github.com/rust-lang/crates.io-index" 1502 + checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 1503 + dependencies = [ 1504 + "getrandom 0.2.17", 1505 + "libredox", 1506 + "thiserror 1.0.69", 1507 + ] 1508 + 1509 + [[package]] 1510 + name = "regex" 1511 + version = "1.12.4" 1512 + source = "registry+https://github.com/rust-lang/crates.io-index" 1513 + checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" 1514 + dependencies = [ 1515 + "aho-corasick", 1516 + "memchr", 1517 + "regex-automata", 1518 + "regex-syntax", 1519 + ] 1520 + 1521 + [[package]] 1522 + name = "regex-automata" 1523 + version = "0.4.14" 1524 + source = "registry+https://github.com/rust-lang/crates.io-index" 1525 + checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" 1526 + dependencies = [ 1527 + "aho-corasick", 1528 + "memchr", 1529 + "regex-syntax", 1530 + ] 1531 + 1532 + [[package]] 1533 + name = "regex-syntax" 1534 + version = "0.8.11" 1535 + source = "registry+https://github.com/rust-lang/crates.io-index" 1536 + checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" 1537 + 1538 + [[package]] 1539 + name = "reqwest" 1540 + version = "0.12.28" 1541 + source = "registry+https://github.com/rust-lang/crates.io-index" 1542 + checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" 1543 + dependencies = [ 1544 + "base64", 1545 + "bytes", 1546 + "futures-core", 1547 + "http", 1548 + "http-body", 1549 + "http-body-util", 1550 + "hyper", 1551 + "hyper-rustls", 1552 + "hyper-util", 1553 + "js-sys", 1554 + "log", 1555 + "percent-encoding", 1556 + "pin-project-lite", 1557 + "quinn", 1558 + "rustls", 1559 + "rustls-pki-types", 1560 + "serde", 1561 + "serde_json", 1562 + "serde_urlencoded", 1563 + "sync_wrapper", 1564 + "tokio", 1565 + "tokio-rustls", 1566 + "tower", 1567 + "tower-http", 1568 + "tower-service", 1569 + "url", 1570 + "wasm-bindgen", 1571 + "wasm-bindgen-futures", 1572 + "web-sys", 1573 + "webpki-roots", 1574 + ] 1575 + 1576 + [[package]] 1577 + name = "ring" 1578 + version = "0.17.14" 1579 + source = "registry+https://github.com/rust-lang/crates.io-index" 1580 + checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 1581 + dependencies = [ 1582 + "cc", 1583 + "cfg-if", 1584 + "getrandom 0.2.17", 1585 + "libc", 1586 + "untrusted", 1587 + "windows-sys 0.52.0", 1588 + ] 1589 + 1590 + [[package]] 1591 + name = "rpassword" 1592 + version = "7.5.4" 1593 + source = "registry+https://github.com/rust-lang/crates.io-index" 1594 + checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" 1595 + dependencies = [ 1596 + "libc", 1597 + "rtoolbox", 1598 + "windows-sys 0.61.2", 1599 + ] 1600 + 1601 + [[package]] 1602 + name = "rtoolbox" 1603 + version = "0.0.5" 1604 + source = "registry+https://github.com/rust-lang/crates.io-index" 1605 + checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" 1606 + dependencies = [ 1607 + "libc", 1608 + "windows-sys 0.59.0", 1609 + ] 1610 + 1611 + [[package]] 1612 + name = "rust_cast" 1613 + version = "0.20.0" 1614 + source = "registry+https://github.com/rust-lang/crates.io-index" 1615 + checksum = "c403f9630e398a4a5f2655df392254fcade5e4df94b11308487f2aea49ddee46" 1616 + dependencies = [ 1617 + "byteorder", 1618 + "log", 1619 + "protobuf", 1620 + "protobuf-codegen", 1621 + "rustls", 1622 + "rustls-native-certs", 1623 + "serde", 1624 + "serde_json", 1625 + "thiserror 1.0.69", 1626 + ] 1627 + 1628 + [[package]] 1629 + name = "rustc-hash" 1630 + version = "2.1.2" 1631 + source = "registry+https://github.com/rust-lang/crates.io-index" 1632 + checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" 1633 + 1634 + [[package]] 1635 + name = "rustix" 1636 + version = "0.38.44" 1637 + source = "registry+https://github.com/rust-lang/crates.io-index" 1638 + checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 1639 + dependencies = [ 1640 + "bitflags 2.13.0", 1641 + "errno", 1642 + "libc", 1643 + "linux-raw-sys 0.4.15", 1644 + "windows-sys 0.59.0", 1645 + ] 1646 + 1647 + [[package]] 1648 + name = "rustix" 1649 + version = "1.1.4" 1650 + source = "registry+https://github.com/rust-lang/crates.io-index" 1651 + checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" 1652 + dependencies = [ 1653 + "bitflags 2.13.0", 1654 + "errno", 1655 + "libc", 1656 + "linux-raw-sys 0.12.1", 1657 + "windows-sys 0.61.2", 1658 + ] 1659 + 1660 + [[package]] 1661 + name = "rustls" 1662 + version = "0.23.41" 1663 + source = "registry+https://github.com/rust-lang/crates.io-index" 1664 + checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" 1665 + dependencies = [ 1666 + "aws-lc-rs", 1667 + "log", 1668 + "once_cell", 1669 + "ring", 1670 + "rustls-pki-types", 1671 + "rustls-webpki", 1672 + "subtle", 1673 + "zeroize", 1674 + ] 1675 + 1676 + [[package]] 1677 + name = "rustls-native-certs" 1678 + version = "0.8.4" 1679 + source = "registry+https://github.com/rust-lang/crates.io-index" 1680 + checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" 1681 + dependencies = [ 1682 + "openssl-probe", 1683 + "rustls-pki-types", 1684 + "schannel", 1685 + "security-framework", 1686 + ] 1687 + 1688 + [[package]] 1689 + name = "rustls-pki-types" 1690 + version = "1.15.0" 1691 + source = "registry+https://github.com/rust-lang/crates.io-index" 1692 + checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" 1693 + dependencies = [ 1694 + "web-time", 1695 + "zeroize", 1696 + ] 1697 + 1698 + [[package]] 1699 + name = "rustls-webpki" 1700 + version = "0.103.13" 1701 + source = "registry+https://github.com/rust-lang/crates.io-index" 1702 + checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" 1703 + dependencies = [ 1704 + "aws-lc-rs", 1705 + "ring", 1706 + "rustls-pki-types", 1707 + "untrusted", 1708 + ] 1709 + 1710 + [[package]] 1711 + name = "rustversion" 1712 + version = "1.0.22" 1713 + source = "registry+https://github.com/rust-lang/crates.io-index" 1714 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 1715 + 1716 + [[package]] 1717 + name = "ryu" 1718 + version = "1.0.23" 1719 + source = "registry+https://github.com/rust-lang/crates.io-index" 1720 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 1721 + 1722 + [[package]] 1723 + name = "schannel" 1724 + version = "0.1.29" 1725 + source = "registry+https://github.com/rust-lang/crates.io-index" 1726 + checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" 1727 + dependencies = [ 1728 + "windows-sys 0.61.2", 1729 + ] 1730 + 1731 + [[package]] 1732 + name = "scopeguard" 1733 + version = "1.2.0" 1734 + source = "registry+https://github.com/rust-lang/crates.io-index" 1735 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1736 + 1737 + [[package]] 1738 + name = "security-framework" 1739 + version = "3.7.0" 1740 + source = "registry+https://github.com/rust-lang/crates.io-index" 1741 + checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" 1742 + dependencies = [ 1743 + "bitflags 2.13.0", 1744 + "core-foundation", 1745 + "core-foundation-sys", 1746 + "libc", 1747 + "security-framework-sys", 1748 + ] 1749 + 1750 + [[package]] 1751 + name = "security-framework-sys" 1752 + version = "2.17.0" 1753 + source = "registry+https://github.com/rust-lang/crates.io-index" 1754 + checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" 1755 + dependencies = [ 1756 + "core-foundation-sys", 1757 + "libc", 1758 + ] 1759 + 1760 + [[package]] 1761 + name = "serde" 1762 + version = "1.0.228" 1763 + source = "registry+https://github.com/rust-lang/crates.io-index" 1764 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 1765 + dependencies = [ 1766 + "serde_core", 1767 + "serde_derive", 1768 + ] 1769 + 1770 + [[package]] 1771 + name = "serde_core" 1772 + version = "1.0.228" 1773 + source = "registry+https://github.com/rust-lang/crates.io-index" 1774 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 1775 + dependencies = [ 1776 + "serde_derive", 1777 + ] 1778 + 1779 + [[package]] 1780 + name = "serde_derive" 1781 + version = "1.0.228" 1782 + source = "registry+https://github.com/rust-lang/crates.io-index" 1783 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 1784 + dependencies = [ 1785 + "proc-macro2", 1786 + "quote", 1787 + "syn", 1788 + ] 1789 + 1790 + [[package]] 1791 + name = "serde_json" 1792 + version = "1.0.150" 1793 + source = "registry+https://github.com/rust-lang/crates.io-index" 1794 + checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" 1795 + dependencies = [ 1796 + "itoa", 1797 + "memchr", 1798 + "serde", 1799 + "serde_core", 1800 + "zmij", 1801 + ] 1802 + 1803 + [[package]] 1804 + name = "serde_spanned" 1805 + version = "0.6.9" 1806 + source = "registry+https://github.com/rust-lang/crates.io-index" 1807 + checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 1808 + dependencies = [ 1809 + "serde", 1810 + ] 1811 + 1812 + [[package]] 1813 + name = "serde_urlencoded" 1814 + version = "0.7.1" 1815 + source = "registry+https://github.com/rust-lang/crates.io-index" 1816 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1817 + dependencies = [ 1818 + "form_urlencoded", 1819 + "itoa", 1820 + "ryu", 1821 + "serde", 1822 + ] 1823 + 1824 + [[package]] 1825 + name = "sharded-slab" 1826 + version = "0.1.7" 1827 + source = "registry+https://github.com/rust-lang/crates.io-index" 1828 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 1829 + dependencies = [ 1830 + "lazy_static", 1831 + ] 1832 + 1833 + [[package]] 1834 + name = "shlex" 1835 + version = "2.0.1" 1836 + source = "registry+https://github.com/rust-lang/crates.io-index" 1837 + checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" 1838 + 1839 + [[package]] 1840 + name = "signal-hook" 1841 + version = "0.3.18" 1842 + source = "registry+https://github.com/rust-lang/crates.io-index" 1843 + checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 1844 + dependencies = [ 1845 + "libc", 1846 + "signal-hook-registry", 1847 + ] 1848 + 1849 + [[package]] 1850 + name = "signal-hook-mio" 1851 + version = "0.2.5" 1852 + source = "registry+https://github.com/rust-lang/crates.io-index" 1853 + checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" 1854 + dependencies = [ 1855 + "libc", 1856 + "mio", 1857 + "signal-hook", 1858 + ] 1859 + 1860 + [[package]] 1861 + name = "signal-hook-registry" 1862 + version = "1.4.8" 1863 + source = "registry+https://github.com/rust-lang/crates.io-index" 1864 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 1865 + dependencies = [ 1866 + "errno", 1867 + "libc", 1868 + ] 1869 + 1870 + [[package]] 1871 + name = "slab" 1872 + version = "0.4.12" 1873 + source = "registry+https://github.com/rust-lang/crates.io-index" 1874 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 1875 + 1876 + [[package]] 1877 + name = "smallvec" 1878 + version = "1.15.2" 1879 + source = "registry+https://github.com/rust-lang/crates.io-index" 1880 + checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" 1881 + 1882 + [[package]] 1883 + name = "socket2" 1884 + version = "0.5.10" 1885 + source = "registry+https://github.com/rust-lang/crates.io-index" 1886 + checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" 1887 + dependencies = [ 1888 + "libc", 1889 + "windows-sys 0.52.0", 1890 + ] 1891 + 1892 + [[package]] 1893 + name = "socket2" 1894 + version = "0.6.4" 1895 + source = "registry+https://github.com/rust-lang/crates.io-index" 1896 + checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" 1897 + dependencies = [ 1898 + "libc", 1899 + "windows-sys 0.61.2", 1900 + ] 1901 + 1902 + [[package]] 1903 + name = "spin" 1904 + version = "0.9.8" 1905 + source = "registry+https://github.com/rust-lang/crates.io-index" 1906 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 1907 + dependencies = [ 1908 + "lock_api", 1909 + ] 1910 + 1911 + [[package]] 1912 + name = "stable_deref_trait" 1913 + version = "1.2.1" 1914 + source = "registry+https://github.com/rust-lang/crates.io-index" 1915 + checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 1916 + 1917 + [[package]] 1918 + name = "static_assertions" 1919 + version = "1.1.0" 1920 + source = "registry+https://github.com/rust-lang/crates.io-index" 1921 + checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 1922 + 1923 + [[package]] 1924 + name = "strsim" 1925 + version = "0.11.1" 1926 + source = "registry+https://github.com/rust-lang/crates.io-index" 1927 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1928 + 1929 + [[package]] 1930 + name = "strum" 1931 + version = "0.26.3" 1932 + source = "registry+https://github.com/rust-lang/crates.io-index" 1933 + checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 1934 + dependencies = [ 1935 + "strum_macros", 1936 + ] 1937 + 1938 + [[package]] 1939 + name = "strum_macros" 1940 + version = "0.26.4" 1941 + source = "registry+https://github.com/rust-lang/crates.io-index" 1942 + checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 1943 + dependencies = [ 1944 + "heck", 1945 + "proc-macro2", 1946 + "quote", 1947 + "rustversion", 1948 + "syn", 1949 + ] 1950 + 1951 + [[package]] 1952 + name = "subtle" 1953 + version = "2.6.1" 1954 + source = "registry+https://github.com/rust-lang/crates.io-index" 1955 + checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 1956 + 1957 + [[package]] 1958 + name = "syn" 1959 + version = "2.0.118" 1960 + source = "registry+https://github.com/rust-lang/crates.io-index" 1961 + checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" 1962 + dependencies = [ 1963 + "proc-macro2", 1964 + "quote", 1965 + "unicode-ident", 1966 + ] 1967 + 1968 + [[package]] 1969 + name = "sync_wrapper" 1970 + version = "1.0.2" 1971 + source = "registry+https://github.com/rust-lang/crates.io-index" 1972 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 1973 + dependencies = [ 1974 + "futures-core", 1975 + ] 1976 + 1977 + [[package]] 1978 + name = "synstructure" 1979 + version = "0.13.2" 1980 + source = "registry+https://github.com/rust-lang/crates.io-index" 1981 + checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 1982 + dependencies = [ 1983 + "proc-macro2", 1984 + "quote", 1985 + "syn", 1986 + ] 1987 + 1988 + [[package]] 1989 + name = "tempfile" 1990 + version = "3.27.0" 1991 + source = "registry+https://github.com/rust-lang/crates.io-index" 1992 + checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" 1993 + dependencies = [ 1994 + "fastrand", 1995 + "getrandom 0.4.3", 1996 + "once_cell", 1997 + "rustix 1.1.4", 1998 + "windows-sys 0.61.2", 1999 + ] 2000 + 2001 + [[package]] 2002 + name = "thiserror" 2003 + version = "1.0.69" 2004 + source = "registry+https://github.com/rust-lang/crates.io-index" 2005 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 2006 + dependencies = [ 2007 + "thiserror-impl 1.0.69", 2008 + ] 2009 + 2010 + [[package]] 2011 + name = "thiserror" 2012 + version = "2.0.18" 2013 + source = "registry+https://github.com/rust-lang/crates.io-index" 2014 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 2015 + dependencies = [ 2016 + "thiserror-impl 2.0.18", 2017 + ] 2018 + 2019 + [[package]] 2020 + name = "thiserror-impl" 2021 + version = "1.0.69" 2022 + source = "registry+https://github.com/rust-lang/crates.io-index" 2023 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 2024 + dependencies = [ 2025 + "proc-macro2", 2026 + "quote", 2027 + "syn", 2028 + ] 2029 + 2030 + [[package]] 2031 + name = "thiserror-impl" 2032 + version = "2.0.18" 2033 + source = "registry+https://github.com/rust-lang/crates.io-index" 2034 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 2035 + dependencies = [ 2036 + "proc-macro2", 2037 + "quote", 2038 + "syn", 2039 + ] 2040 + 2041 + [[package]] 2042 + name = "thread_local" 2043 + version = "1.1.9" 2044 + source = "registry+https://github.com/rust-lang/crates.io-index" 2045 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 2046 + dependencies = [ 2047 + "cfg-if", 2048 + ] 2049 + 2050 + [[package]] 2051 + name = "tinystr" 2052 + version = "0.8.3" 2053 + source = "registry+https://github.com/rust-lang/crates.io-index" 2054 + checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" 2055 + dependencies = [ 2056 + "displaydoc", 2057 + "zerovec", 2058 + ] 2059 + 2060 + [[package]] 2061 + name = "tinyvec" 2062 + version = "1.11.0" 2063 + source = "registry+https://github.com/rust-lang/crates.io-index" 2064 + checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" 2065 + dependencies = [ 2066 + "tinyvec_macros", 2067 + ] 2068 + 2069 + [[package]] 2070 + name = "tinyvec_macros" 2071 + version = "0.1.1" 2072 + source = "registry+https://github.com/rust-lang/crates.io-index" 2073 + checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 2074 + 2075 + [[package]] 2076 + name = "tokio" 2077 + version = "1.52.3" 2078 + source = "registry+https://github.com/rust-lang/crates.io-index" 2079 + checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" 2080 + dependencies = [ 2081 + "bytes", 2082 + "libc", 2083 + "mio", 2084 + "parking_lot", 2085 + "pin-project-lite", 2086 + "signal-hook-registry", 2087 + "socket2 0.6.4", 2088 + "tokio-macros", 2089 + "windows-sys 0.61.2", 2090 + ] 2091 + 2092 + [[package]] 2093 + name = "tokio-macros" 2094 + version = "2.7.0" 2095 + source = "registry+https://github.com/rust-lang/crates.io-index" 2096 + checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" 2097 + dependencies = [ 2098 + "proc-macro2", 2099 + "quote", 2100 + "syn", 2101 + ] 2102 + 2103 + [[package]] 2104 + name = "tokio-rustls" 2105 + version = "0.26.4" 2106 + source = "registry+https://github.com/rust-lang/crates.io-index" 2107 + checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 2108 + dependencies = [ 2109 + "rustls", 2110 + "tokio", 2111 + ] 2112 + 2113 + [[package]] 2114 + name = "toml" 2115 + version = "0.8.23" 2116 + source = "registry+https://github.com/rust-lang/crates.io-index" 2117 + checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 2118 + dependencies = [ 2119 + "serde", 2120 + "serde_spanned", 2121 + "toml_datetime", 2122 + "toml_edit", 2123 + ] 2124 + 2125 + [[package]] 2126 + name = "toml_datetime" 2127 + version = "0.6.11" 2128 + source = "registry+https://github.com/rust-lang/crates.io-index" 2129 + checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 2130 + dependencies = [ 2131 + "serde", 2132 + ] 2133 + 2134 + [[package]] 2135 + name = "toml_edit" 2136 + version = "0.22.27" 2137 + source = "registry+https://github.com/rust-lang/crates.io-index" 2138 + checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 2139 + dependencies = [ 2140 + "indexmap", 2141 + "serde", 2142 + "serde_spanned", 2143 + "toml_datetime", 2144 + "toml_write", 2145 + "winnow", 2146 + ] 2147 + 2148 + [[package]] 2149 + name = "toml_write" 2150 + version = "0.1.2" 2151 + source = "registry+https://github.com/rust-lang/crates.io-index" 2152 + checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 2153 + 2154 + [[package]] 2155 + name = "tower" 2156 + version = "0.5.3" 2157 + source = "registry+https://github.com/rust-lang/crates.io-index" 2158 + checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" 2159 + dependencies = [ 2160 + "futures-core", 2161 + "futures-util", 2162 + "pin-project-lite", 2163 + "sync_wrapper", 2164 + "tokio", 2165 + "tower-layer", 2166 + "tower-service", 2167 + ] 2168 + 2169 + [[package]] 2170 + name = "tower-http" 2171 + version = "0.6.11" 2172 + source = "registry+https://github.com/rust-lang/crates.io-index" 2173 + checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" 2174 + dependencies = [ 2175 + "bitflags 2.13.0", 2176 + "bytes", 2177 + "futures-util", 2178 + "http", 2179 + "http-body", 2180 + "pin-project-lite", 2181 + "tower", 2182 + "tower-layer", 2183 + "tower-service", 2184 + "url", 2185 + ] 2186 + 2187 + [[package]] 2188 + name = "tower-layer" 2189 + version = "0.3.3" 2190 + source = "registry+https://github.com/rust-lang/crates.io-index" 2191 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 2192 + 2193 + [[package]] 2194 + name = "tower-service" 2195 + version = "0.3.3" 2196 + source = "registry+https://github.com/rust-lang/crates.io-index" 2197 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 2198 + 2199 + [[package]] 2200 + name = "tracing" 2201 + version = "0.1.44" 2202 + source = "registry+https://github.com/rust-lang/crates.io-index" 2203 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 2204 + dependencies = [ 2205 + "pin-project-lite", 2206 + "tracing-attributes", 2207 + "tracing-core", 2208 + ] 2209 + 2210 + [[package]] 2211 + name = "tracing-attributes" 2212 + version = "0.1.31" 2213 + source = "registry+https://github.com/rust-lang/crates.io-index" 2214 + checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" 2215 + dependencies = [ 2216 + "proc-macro2", 2217 + "quote", 2218 + "syn", 2219 + ] 2220 + 2221 + [[package]] 2222 + name = "tracing-core" 2223 + version = "0.1.36" 2224 + source = "registry+https://github.com/rust-lang/crates.io-index" 2225 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 2226 + dependencies = [ 2227 + "once_cell", 2228 + "valuable", 2229 + ] 2230 + 2231 + [[package]] 2232 + name = "tracing-log" 2233 + version = "0.2.0" 2234 + source = "registry+https://github.com/rust-lang/crates.io-index" 2235 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 2236 + dependencies = [ 2237 + "log", 2238 + "once_cell", 2239 + "tracing-core", 2240 + ] 2241 + 2242 + [[package]] 2243 + name = "tracing-subscriber" 2244 + version = "0.3.23" 2245 + source = "registry+https://github.com/rust-lang/crates.io-index" 2246 + checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" 2247 + dependencies = [ 2248 + "matchers", 2249 + "nu-ansi-term", 2250 + "once_cell", 2251 + "regex-automata", 2252 + "sharded-slab", 2253 + "smallvec", 2254 + "thread_local", 2255 + "tracing", 2256 + "tracing-core", 2257 + "tracing-log", 2258 + ] 2259 + 2260 + [[package]] 2261 + name = "try-lock" 2262 + version = "0.2.5" 2263 + source = "registry+https://github.com/rust-lang/crates.io-index" 2264 + checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2265 + 2266 + [[package]] 2267 + name = "unicode-ident" 2268 + version = "1.0.24" 2269 + source = "registry+https://github.com/rust-lang/crates.io-index" 2270 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 2271 + 2272 + [[package]] 2273 + name = "unicode-segmentation" 2274 + version = "1.13.3" 2275 + source = "registry+https://github.com/rust-lang/crates.io-index" 2276 + checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" 2277 + 2278 + [[package]] 2279 + name = "unicode-truncate" 2280 + version = "1.1.0" 2281 + source = "registry+https://github.com/rust-lang/crates.io-index" 2282 + checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 2283 + dependencies = [ 2284 + "itertools", 2285 + "unicode-segmentation", 2286 + "unicode-width 0.1.14", 2287 + ] 2288 + 2289 + [[package]] 2290 + name = "unicode-width" 2291 + version = "0.1.14" 2292 + source = "registry+https://github.com/rust-lang/crates.io-index" 2293 + checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 2294 + 2295 + [[package]] 2296 + name = "unicode-width" 2297 + version = "0.2.0" 2298 + source = "registry+https://github.com/rust-lang/crates.io-index" 2299 + checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 2300 + 2301 + [[package]] 2302 + name = "untrusted" 2303 + version = "0.9.0" 2304 + source = "registry+https://github.com/rust-lang/crates.io-index" 2305 + checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 2306 + 2307 + [[package]] 2308 + name = "url" 2309 + version = "2.5.8" 2310 + source = "registry+https://github.com/rust-lang/crates.io-index" 2311 + checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" 2312 + dependencies = [ 2313 + "form_urlencoded", 2314 + "idna", 2315 + "percent-encoding", 2316 + "serde", 2317 + ] 2318 + 2319 + [[package]] 2320 + name = "utf8_iter" 2321 + version = "1.0.4" 2322 + source = "registry+https://github.com/rust-lang/crates.io-index" 2323 + checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 2324 + 2325 + [[package]] 2326 + name = "utf8parse" 2327 + version = "0.2.2" 2328 + source = "registry+https://github.com/rust-lang/crates.io-index" 2329 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 2330 + 2331 + [[package]] 2332 + name = "uuid" 2333 + version = "1.23.4" 2334 + source = "registry+https://github.com/rust-lang/crates.io-index" 2335 + checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" 2336 + dependencies = [ 2337 + "getrandom 0.4.3", 2338 + "js-sys", 2339 + "wasm-bindgen", 2340 + ] 2341 + 2342 + [[package]] 2343 + name = "valuable" 2344 + version = "0.1.1" 2345 + source = "registry+https://github.com/rust-lang/crates.io-index" 2346 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 2347 + 2348 + [[package]] 2349 + name = "want" 2350 + version = "0.3.1" 2351 + source = "registry+https://github.com/rust-lang/crates.io-index" 2352 + checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 2353 + dependencies = [ 2354 + "try-lock", 2355 + ] 2356 + 2357 + [[package]] 2358 + name = "wasi" 2359 + version = "0.11.1+wasi-snapshot-preview1" 2360 + source = "registry+https://github.com/rust-lang/crates.io-index" 2361 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 2362 + 2363 + [[package]] 2364 + name = "wasip2" 2365 + version = "1.0.4+wasi-0.2.12" 2366 + source = "registry+https://github.com/rust-lang/crates.io-index" 2367 + checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" 2368 + dependencies = [ 2369 + "wit-bindgen", 2370 + ] 2371 + 2372 + [[package]] 2373 + name = "wasite" 2374 + version = "0.1.0" 2375 + source = "registry+https://github.com/rust-lang/crates.io-index" 2376 + checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" 2377 + 2378 + [[package]] 2379 + name = "wasm-bindgen" 2380 + version = "0.2.126" 2381 + source = "registry+https://github.com/rust-lang/crates.io-index" 2382 + checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" 2383 + dependencies = [ 2384 + "cfg-if", 2385 + "once_cell", 2386 + "rustversion", 2387 + "wasm-bindgen-macro", 2388 + "wasm-bindgen-shared", 2389 + ] 2390 + 2391 + [[package]] 2392 + name = "wasm-bindgen-futures" 2393 + version = "0.4.76" 2394 + source = "registry+https://github.com/rust-lang/crates.io-index" 2395 + checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" 2396 + dependencies = [ 2397 + "js-sys", 2398 + "wasm-bindgen", 2399 + ] 2400 + 2401 + [[package]] 2402 + name = "wasm-bindgen-macro" 2403 + version = "0.2.126" 2404 + source = "registry+https://github.com/rust-lang/crates.io-index" 2405 + checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" 2406 + dependencies = [ 2407 + "quote", 2408 + "wasm-bindgen-macro-support", 2409 + ] 2410 + 2411 + [[package]] 2412 + name = "wasm-bindgen-macro-support" 2413 + version = "0.2.126" 2414 + source = "registry+https://github.com/rust-lang/crates.io-index" 2415 + checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" 2416 + dependencies = [ 2417 + "bumpalo", 2418 + "proc-macro2", 2419 + "quote", 2420 + "syn", 2421 + "wasm-bindgen-shared", 2422 + ] 2423 + 2424 + [[package]] 2425 + name = "wasm-bindgen-shared" 2426 + version = "0.2.126" 2427 + source = "registry+https://github.com/rust-lang/crates.io-index" 2428 + checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" 2429 + dependencies = [ 2430 + "unicode-ident", 2431 + ] 2432 + 2433 + [[package]] 2434 + name = "web-sys" 2435 + version = "0.3.103" 2436 + source = "registry+https://github.com/rust-lang/crates.io-index" 2437 + checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" 2438 + dependencies = [ 2439 + "js-sys", 2440 + "wasm-bindgen", 2441 + ] 2442 + 2443 + [[package]] 2444 + name = "web-time" 2445 + version = "1.1.0" 2446 + source = "registry+https://github.com/rust-lang/crates.io-index" 2447 + checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 2448 + dependencies = [ 2449 + "js-sys", 2450 + "wasm-bindgen", 2451 + ] 2452 + 2453 + [[package]] 2454 + name = "webpki-roots" 2455 + version = "1.0.8" 2456 + source = "registry+https://github.com/rust-lang/crates.io-index" 2457 + checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" 2458 + dependencies = [ 2459 + "rustls-pki-types", 2460 + ] 2461 + 2462 + [[package]] 2463 + name = "which" 2464 + version = "4.4.2" 2465 + source = "registry+https://github.com/rust-lang/crates.io-index" 2466 + checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" 2467 + dependencies = [ 2468 + "either", 2469 + "home", 2470 + "once_cell", 2471 + "rustix 0.38.44", 2472 + ] 2473 + 2474 + [[package]] 2475 + name = "whoami" 2476 + version = "1.6.1" 2477 + source = "registry+https://github.com/rust-lang/crates.io-index" 2478 + checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" 2479 + dependencies = [ 2480 + "libredox", 2481 + "wasite", 2482 + "web-sys", 2483 + ] 2484 + 2485 + [[package]] 2486 + name = "winapi" 2487 + version = "0.3.9" 2488 + source = "registry+https://github.com/rust-lang/crates.io-index" 2489 + checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 2490 + dependencies = [ 2491 + "winapi-i686-pc-windows-gnu", 2492 + "winapi-x86_64-pc-windows-gnu", 2493 + ] 2494 + 2495 + [[package]] 2496 + name = "winapi-i686-pc-windows-gnu" 2497 + version = "0.4.0" 2498 + source = "registry+https://github.com/rust-lang/crates.io-index" 2499 + checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 2500 + 2501 + [[package]] 2502 + name = "winapi-x86_64-pc-windows-gnu" 2503 + version = "0.4.0" 2504 + source = "registry+https://github.com/rust-lang/crates.io-index" 2505 + checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 2506 + 2507 + [[package]] 2508 + name = "windows-core" 2509 + version = "0.62.2" 2510 + source = "registry+https://github.com/rust-lang/crates.io-index" 2511 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 2512 + dependencies = [ 2513 + "windows-implement", 2514 + "windows-interface", 2515 + "windows-link", 2516 + "windows-result", 2517 + "windows-strings", 2518 + ] 2519 + 2520 + [[package]] 2521 + name = "windows-implement" 2522 + version = "0.60.2" 2523 + source = "registry+https://github.com/rust-lang/crates.io-index" 2524 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 2525 + dependencies = [ 2526 + "proc-macro2", 2527 + "quote", 2528 + "syn", 2529 + ] 2530 + 2531 + [[package]] 2532 + name = "windows-interface" 2533 + version = "0.59.3" 2534 + source = "registry+https://github.com/rust-lang/crates.io-index" 2535 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 2536 + dependencies = [ 2537 + "proc-macro2", 2538 + "quote", 2539 + "syn", 2540 + ] 2541 + 2542 + [[package]] 2543 + name = "windows-link" 2544 + version = "0.2.1" 2545 + source = "registry+https://github.com/rust-lang/crates.io-index" 2546 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 2547 + 2548 + [[package]] 2549 + name = "windows-result" 2550 + version = "0.4.1" 2551 + source = "registry+https://github.com/rust-lang/crates.io-index" 2552 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 2553 + dependencies = [ 2554 + "windows-link", 2555 + ] 2556 + 2557 + [[package]] 2558 + name = "windows-strings" 2559 + version = "0.5.1" 2560 + source = "registry+https://github.com/rust-lang/crates.io-index" 2561 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 2562 + dependencies = [ 2563 + "windows-link", 2564 + ] 2565 + 2566 + [[package]] 2567 + name = "windows-sys" 2568 + version = "0.48.0" 2569 + source = "registry+https://github.com/rust-lang/crates.io-index" 2570 + checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 2571 + dependencies = [ 2572 + "windows-targets 0.48.5", 2573 + ] 2574 + 2575 + [[package]] 2576 + name = "windows-sys" 2577 + version = "0.52.0" 2578 + source = "registry+https://github.com/rust-lang/crates.io-index" 2579 + checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 2580 + dependencies = [ 2581 + "windows-targets 0.52.6", 2582 + ] 2583 + 2584 + [[package]] 2585 + name = "windows-sys" 2586 + version = "0.59.0" 2587 + source = "registry+https://github.com/rust-lang/crates.io-index" 2588 + checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 2589 + dependencies = [ 2590 + "windows-targets 0.52.6", 2591 + ] 2592 + 2593 + [[package]] 2594 + name = "windows-sys" 2595 + version = "0.60.2" 2596 + source = "registry+https://github.com/rust-lang/crates.io-index" 2597 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 2598 + dependencies = [ 2599 + "windows-targets 0.53.5", 2600 + ] 2601 + 2602 + [[package]] 2603 + name = "windows-sys" 2604 + version = "0.61.2" 2605 + source = "registry+https://github.com/rust-lang/crates.io-index" 2606 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 2607 + dependencies = [ 2608 + "windows-link", 2609 + ] 2610 + 2611 + [[package]] 2612 + name = "windows-targets" 2613 + version = "0.48.5" 2614 + source = "registry+https://github.com/rust-lang/crates.io-index" 2615 + checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 2616 + dependencies = [ 2617 + "windows_aarch64_gnullvm 0.48.5", 2618 + "windows_aarch64_msvc 0.48.5", 2619 + "windows_i686_gnu 0.48.5", 2620 + "windows_i686_msvc 0.48.5", 2621 + "windows_x86_64_gnu 0.48.5", 2622 + "windows_x86_64_gnullvm 0.48.5", 2623 + "windows_x86_64_msvc 0.48.5", 2624 + ] 2625 + 2626 + [[package]] 2627 + name = "windows-targets" 2628 + version = "0.52.6" 2629 + source = "registry+https://github.com/rust-lang/crates.io-index" 2630 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 2631 + dependencies = [ 2632 + "windows_aarch64_gnullvm 0.52.6", 2633 + "windows_aarch64_msvc 0.52.6", 2634 + "windows_i686_gnu 0.52.6", 2635 + "windows_i686_gnullvm 0.52.6", 2636 + "windows_i686_msvc 0.52.6", 2637 + "windows_x86_64_gnu 0.52.6", 2638 + "windows_x86_64_gnullvm 0.52.6", 2639 + "windows_x86_64_msvc 0.52.6", 2640 + ] 2641 + 2642 + [[package]] 2643 + name = "windows-targets" 2644 + version = "0.53.5" 2645 + source = "registry+https://github.com/rust-lang/crates.io-index" 2646 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 2647 + dependencies = [ 2648 + "windows-link", 2649 + "windows_aarch64_gnullvm 0.53.1", 2650 + "windows_aarch64_msvc 0.53.1", 2651 + "windows_i686_gnu 0.53.1", 2652 + "windows_i686_gnullvm 0.53.1", 2653 + "windows_i686_msvc 0.53.1", 2654 + "windows_x86_64_gnu 0.53.1", 2655 + "windows_x86_64_gnullvm 0.53.1", 2656 + "windows_x86_64_msvc 0.53.1", 2657 + ] 2658 + 2659 + [[package]] 2660 + name = "windows_aarch64_gnullvm" 2661 + version = "0.48.5" 2662 + source = "registry+https://github.com/rust-lang/crates.io-index" 2663 + checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 2664 + 2665 + [[package]] 2666 + name = "windows_aarch64_gnullvm" 2667 + version = "0.52.6" 2668 + source = "registry+https://github.com/rust-lang/crates.io-index" 2669 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 2670 + 2671 + [[package]] 2672 + name = "windows_aarch64_gnullvm" 2673 + version = "0.53.1" 2674 + source = "registry+https://github.com/rust-lang/crates.io-index" 2675 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 2676 + 2677 + [[package]] 2678 + name = "windows_aarch64_msvc" 2679 + version = "0.48.5" 2680 + source = "registry+https://github.com/rust-lang/crates.io-index" 2681 + checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 2682 + 2683 + [[package]] 2684 + name = "windows_aarch64_msvc" 2685 + version = "0.52.6" 2686 + source = "registry+https://github.com/rust-lang/crates.io-index" 2687 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 2688 + 2689 + [[package]] 2690 + name = "windows_aarch64_msvc" 2691 + version = "0.53.1" 2692 + source = "registry+https://github.com/rust-lang/crates.io-index" 2693 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 2694 + 2695 + [[package]] 2696 + name = "windows_i686_gnu" 2697 + version = "0.48.5" 2698 + source = "registry+https://github.com/rust-lang/crates.io-index" 2699 + checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 2700 + 2701 + [[package]] 2702 + name = "windows_i686_gnu" 2703 + version = "0.52.6" 2704 + source = "registry+https://github.com/rust-lang/crates.io-index" 2705 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 2706 + 2707 + [[package]] 2708 + name = "windows_i686_gnu" 2709 + version = "0.53.1" 2710 + source = "registry+https://github.com/rust-lang/crates.io-index" 2711 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 2712 + 2713 + [[package]] 2714 + name = "windows_i686_gnullvm" 2715 + version = "0.52.6" 2716 + source = "registry+https://github.com/rust-lang/crates.io-index" 2717 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 2718 + 2719 + [[package]] 2720 + name = "windows_i686_gnullvm" 2721 + version = "0.53.1" 2722 + source = "registry+https://github.com/rust-lang/crates.io-index" 2723 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 2724 + 2725 + [[package]] 2726 + name = "windows_i686_msvc" 2727 + version = "0.48.5" 2728 + source = "registry+https://github.com/rust-lang/crates.io-index" 2729 + checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 2730 + 2731 + [[package]] 2732 + name = "windows_i686_msvc" 2733 + version = "0.52.6" 2734 + source = "registry+https://github.com/rust-lang/crates.io-index" 2735 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 2736 + 2737 + [[package]] 2738 + name = "windows_i686_msvc" 2739 + version = "0.53.1" 2740 + source = "registry+https://github.com/rust-lang/crates.io-index" 2741 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 2742 + 2743 + [[package]] 2744 + name = "windows_x86_64_gnu" 2745 + version = "0.48.5" 2746 + source = "registry+https://github.com/rust-lang/crates.io-index" 2747 + checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 2748 + 2749 + [[package]] 2750 + name = "windows_x86_64_gnu" 2751 + version = "0.52.6" 2752 + source = "registry+https://github.com/rust-lang/crates.io-index" 2753 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 2754 + 2755 + [[package]] 2756 + name = "windows_x86_64_gnu" 2757 + version = "0.53.1" 2758 + source = "registry+https://github.com/rust-lang/crates.io-index" 2759 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 2760 + 2761 + [[package]] 2762 + name = "windows_x86_64_gnullvm" 2763 + version = "0.48.5" 2764 + source = "registry+https://github.com/rust-lang/crates.io-index" 2765 + checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 2766 + 2767 + [[package]] 2768 + name = "windows_x86_64_gnullvm" 2769 + version = "0.52.6" 2770 + source = "registry+https://github.com/rust-lang/crates.io-index" 2771 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 2772 + 2773 + [[package]] 2774 + name = "windows_x86_64_gnullvm" 2775 + version = "0.53.1" 2776 + source = "registry+https://github.com/rust-lang/crates.io-index" 2777 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 2778 + 2779 + [[package]] 2780 + name = "windows_x86_64_msvc" 2781 + version = "0.48.5" 2782 + source = "registry+https://github.com/rust-lang/crates.io-index" 2783 + checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 2784 + 2785 + [[package]] 2786 + name = "windows_x86_64_msvc" 2787 + version = "0.52.6" 2788 + source = "registry+https://github.com/rust-lang/crates.io-index" 2789 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 2790 + 2791 + [[package]] 2792 + name = "windows_x86_64_msvc" 2793 + version = "0.53.1" 2794 + source = "registry+https://github.com/rust-lang/crates.io-index" 2795 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 2796 + 2797 + [[package]] 2798 + name = "winnow" 2799 + version = "0.7.15" 2800 + source = "registry+https://github.com/rust-lang/crates.io-index" 2801 + checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" 2802 + dependencies = [ 2803 + "memchr", 2804 + ] 2805 + 2806 + [[package]] 2807 + name = "wit-bindgen" 2808 + version = "0.57.1" 2809 + source = "registry+https://github.com/rust-lang/crates.io-index" 2810 + checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" 2811 + 2812 + [[package]] 2813 + name = "writeable" 2814 + version = "0.6.3" 2815 + source = "registry+https://github.com/rust-lang/crates.io-index" 2816 + checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" 2817 + 2818 + [[package]] 2819 + name = "yoke" 2820 + version = "0.8.3" 2821 + source = "registry+https://github.com/rust-lang/crates.io-index" 2822 + checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" 2823 + dependencies = [ 2824 + "stable_deref_trait", 2825 + "yoke-derive", 2826 + "zerofrom", 2827 + ] 2828 + 2829 + [[package]] 2830 + name = "yoke-derive" 2831 + version = "0.8.2" 2832 + source = "registry+https://github.com/rust-lang/crates.io-index" 2833 + checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" 2834 + dependencies = [ 2835 + "proc-macro2", 2836 + "quote", 2837 + "syn", 2838 + "synstructure", 2839 + ] 2840 + 2841 + [[package]] 2842 + name = "zerocopy" 2843 + version = "0.8.52" 2844 + source = "registry+https://github.com/rust-lang/crates.io-index" 2845 + checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" 2846 + dependencies = [ 2847 + "zerocopy-derive", 2848 + ] 2849 + 2850 + [[package]] 2851 + name = "zerocopy-derive" 2852 + version = "0.8.52" 2853 + source = "registry+https://github.com/rust-lang/crates.io-index" 2854 + checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" 2855 + dependencies = [ 2856 + "proc-macro2", 2857 + "quote", 2858 + "syn", 2859 + ] 2860 + 2861 + [[package]] 2862 + name = "zerofrom" 2863 + version = "0.1.8" 2864 + source = "registry+https://github.com/rust-lang/crates.io-index" 2865 + checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" 2866 + dependencies = [ 2867 + "zerofrom-derive", 2868 + ] 2869 + 2870 + [[package]] 2871 + name = "zerofrom-derive" 2872 + version = "0.1.7" 2873 + source = "registry+https://github.com/rust-lang/crates.io-index" 2874 + checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" 2875 + dependencies = [ 2876 + "proc-macro2", 2877 + "quote", 2878 + "syn", 2879 + "synstructure", 2880 + ] 2881 + 2882 + [[package]] 2883 + name = "zeroize" 2884 + version = "1.9.0" 2885 + source = "registry+https://github.com/rust-lang/crates.io-index" 2886 + checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" 2887 + 2888 + [[package]] 2889 + name = "zerotrie" 2890 + version = "0.2.4" 2891 + source = "registry+https://github.com/rust-lang/crates.io-index" 2892 + checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" 2893 + dependencies = [ 2894 + "displaydoc", 2895 + "yoke", 2896 + "zerofrom", 2897 + ] 2898 + 2899 + [[package]] 2900 + name = "zerovec" 2901 + version = "0.11.6" 2902 + source = "registry+https://github.com/rust-lang/crates.io-index" 2903 + checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" 2904 + dependencies = [ 2905 + "yoke", 2906 + "zerofrom", 2907 + "zerovec-derive", 2908 + ] 2909 + 2910 + [[package]] 2911 + name = "zerovec-derive" 2912 + version = "0.11.3" 2913 + source = "registry+https://github.com/rust-lang/crates.io-index" 2914 + checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" 2915 + dependencies = [ 2916 + "proc-macro2", 2917 + "quote", 2918 + "syn", 2919 + ] 2920 + 2921 + [[package]] 2922 + name = "zmij" 2923 + version = "1.0.21" 2924 + source = "registry+https://github.com/rust-lang/crates.io-index" 2925 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+48
Cargo.toml
··· 1 + [workspace] 2 + members = ["crates/*"] 3 + resolver = "2" 4 + 5 + [workspace.package] 6 + version = "0.1.0" 7 + authors = ["Tsiry Sandratraina <tsiry.sndr@rocksky.app>"] 8 + edition = "2021" 9 + license = "MPL-2.0" 10 + repository = "https://github.com/tsirysndr/fin" 11 + 12 + [workspace.dependencies] 13 + anyhow = "1" 14 + thiserror = "1" 15 + serde = { version = "1", features = ["derive"] } 16 + serde_json = "1" 17 + toml = "0.8" 18 + tokio = { version = "1", features = ["full"] } 19 + tokio-util = "0.7" 20 + futures = "0.3" 21 + async-trait = "0.1" 22 + reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 23 + url = "2" 24 + uuid = { version = "1", features = ["v4"] } 25 + chrono = { version = "0.4", features = ["serde"] } 26 + tracing = "0.1" 27 + tracing-subscriber = { version = "0.3", features = ["env-filter"] } 28 + directories = "5" 29 + ratatui = "0.29" 30 + crossterm = { version = "0.28", features = ["event-stream"] } 31 + clap = { version = "4", features = ["derive", "env"] } 32 + mdns-sd = "0.11" 33 + rust_cast = "0.20" 34 + parking_lot = "0.12" 35 + unicode-width = "0.2" 36 + percent-encoding = "2" 37 + whoami = "1" 38 + rpassword = "7" 39 + 40 + fin-config = { path = "crates/fin-config" } 41 + fin-jellyfin = { path = "crates/fin-jellyfin" } 42 + fin-player = { path = "crates/fin-player" } 43 + fin-tui = { path = "crates/fin-tui" } 44 + 45 + [profile.release] 46 + opt-level = 3 47 + lto = "thin" 48 + strip = true
+373
LICENSE
··· 1 + Mozilla Public License Version 2.0 2 + ================================== 3 + 4 + 1. Definitions 5 + -------------- 6 + 7 + 1.1. "Contributor" 8 + means each individual or legal entity that creates, contributes to 9 + the creation of, or owns Covered Software. 10 + 11 + 1.2. "Contributor Version" 12 + means the combination of the Contributions of others (if any) used 13 + by a Contributor and that particular Contributor's Contribution. 14 + 15 + 1.3. "Contribution" 16 + means Covered Software of a particular Contributor. 17 + 18 + 1.4. "Covered Software" 19 + means Source Code Form to which the initial Contributor has attached 20 + the notice in Exhibit A, the Executable Form of such Source Code 21 + Form, and Modifications of such Source Code Form, in each case 22 + including portions thereof. 23 + 24 + 1.5. "Incompatible With Secondary Licenses" 25 + means 26 + 27 + (a) that the initial Contributor has attached the notice described 28 + in Exhibit B to the Covered Software; or 29 + 30 + (b) that the Covered Software was made available under the terms of 31 + version 1.1 or earlier of the License, but not also under the 32 + terms of a Secondary License. 33 + 34 + 1.6. "Executable Form" 35 + means any form of the work other than Source Code Form. 36 + 37 + 1.7. "Larger Work" 38 + means a work that combines Covered Software with other material, in 39 + a separate file or files, that is not Covered Software. 40 + 41 + 1.8. "License" 42 + means this document. 43 + 44 + 1.9. "Licensable" 45 + means having the right to grant, to the maximum extent possible, 46 + whether at the time of the initial grant or subsequently, any and 47 + all of the rights conveyed by this License. 48 + 49 + 1.10. "Modifications" 50 + means any of the following: 51 + 52 + (a) any file in Source Code Form that results from an addition to, 53 + deletion from, or modification of the contents of Covered 54 + Software; or 55 + 56 + (b) any new file in Source Code Form that contains any Covered 57 + Software. 58 + 59 + 1.11. "Patent Claims" of a Contributor 60 + means any patent claim(s), including without limitation, method, 61 + process, and apparatus claims, in any patent Licensable by such 62 + Contributor that would be infringed, but for the grant of the 63 + License, by the making, using, selling, offering for sale, having 64 + made, import, or transfer of either its Contributions or its 65 + Contributor Version. 66 + 67 + 1.12. "Secondary License" 68 + means either the GNU General Public License, Version 2.0, the GNU 69 + Lesser General Public License, Version 2.1, the GNU Affero General 70 + Public License, Version 3.0, or any later versions of those 71 + licenses. 72 + 73 + 1.13. "Source Code Form" 74 + means the form of the work preferred for making modifications. 75 + 76 + 1.14. "You" (or "Your") 77 + means an individual or a legal entity exercising rights under this 78 + License. For legal entities, "You" includes any entity that 79 + controls, is controlled by, or is under common control with You. For 80 + purposes of this definition, "control" means (a) the power, direct 81 + or indirect, to cause the direction or management of such entity, 82 + whether by contract or otherwise, or (b) ownership of more than 83 + fifty percent (50%) of the outstanding shares or beneficial 84 + ownership of such entity. 85 + 86 + 2. License Grants and Conditions 87 + -------------------------------- 88 + 89 + 2.1. Grants 90 + 91 + Each Contributor hereby grants You a world-wide, royalty-free, 92 + non-exclusive license: 93 + 94 + (a) under intellectual property rights (other than patent or trademark) 95 + Licensable by such Contributor to use, reproduce, make available, 96 + modify, display, perform, distribute, and otherwise exploit its 97 + Contributions, either on an unmodified basis, with Modifications, or 98 + as part of a Larger Work; and 99 + 100 + (b) under Patent Claims of such Contributor to make, use, sell, offer 101 + for sale, have made, import, and otherwise transfer either its 102 + Contributions or its Contributor Version. 103 + 104 + 2.2. Effective Date 105 + 106 + The licenses granted in Section 2.1 with respect to any Contribution 107 + become effective for each Contribution on the date the Contributor first 108 + distributes such Contribution. 109 + 110 + 2.3. Limitations on Grant Scope 111 + 112 + The licenses granted in this Section 2 are the only rights granted under 113 + this License. No additional rights or licenses will be implied from the 114 + distribution or licensing of Covered Software under this License. 115 + Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 + Contributor: 117 + 118 + (a) for any code that a Contributor has removed from Covered Software; 119 + or 120 + 121 + (b) for infringements caused by: (i) Your and any other third party's 122 + modifications of Covered Software, or (ii) the combination of its 123 + Contributions with other software (except as part of its Contributor 124 + Version); or 125 + 126 + (c) under Patent Claims infringed by Covered Software in the absence of 127 + its Contributions. 128 + 129 + This License does not grant any rights in the trademarks, service marks, 130 + or logos of any Contributor (except as may be necessary to comply with 131 + the notice requirements in Section 3.4). 132 + 133 + 2.4. Subsequent Licenses 134 + 135 + No Contributor makes additional grants as a result of Your choice to 136 + distribute the Covered Software under a subsequent version of this 137 + License (see Section 10.2) or under the terms of a Secondary License (if 138 + permitted under the terms of Section 3.3). 139 + 140 + 2.5. Representation 141 + 142 + Each Contributor represents that the Contributor believes its 143 + Contributions are its original creation(s) or it has sufficient rights 144 + to grant the rights to its Contributions conveyed by this License. 145 + 146 + 2.6. Fair Use 147 + 148 + This License is not intended to limit any rights You have under 149 + applicable copyright doctrines of fair use, fair dealing, or other 150 + equivalents. 151 + 152 + 2.7. Conditions 153 + 154 + Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 + in Section 2.1. 156 + 157 + 3. Responsibilities 158 + ------------------- 159 + 160 + 3.1. Distribution of Source Form 161 + 162 + All distribution of Covered Software in Source Code Form, including any 163 + Modifications that You create or to which You contribute, must be under 164 + the terms of this License. You must inform recipients that the Source 165 + Code Form of the Covered Software is governed by the terms of this 166 + License, and how they can obtain a copy of this License. You may not 167 + attempt to alter or restrict the recipients' rights in the Source Code 168 + Form. 169 + 170 + 3.2. Distribution of Executable Form 171 + 172 + If You distribute Covered Software in Executable Form then: 173 + 174 + (a) such Covered Software must also be made available in Source Code 175 + Form, as described in Section 3.1, and You must inform recipients of 176 + the Executable Form how they can obtain a copy of such Source Code 177 + Form by reasonable means in a timely manner, at a charge no more 178 + than the cost of distribution to the recipient; and 179 + 180 + (b) You may distribute such Executable Form under the terms of this 181 + License, or sublicense it under different terms, provided that the 182 + license for the Executable Form does not attempt to limit or alter 183 + the recipients' rights in the Source Code Form under this License. 184 + 185 + 3.3. Distribution of a Larger Work 186 + 187 + You may create and distribute a Larger Work under terms of Your choice, 188 + provided that You also comply with the requirements of this License for 189 + the Covered Software. If the Larger Work is a combination of Covered 190 + Software with a work governed by one or more Secondary Licenses, and the 191 + Covered Software is not Incompatible With Secondary Licenses, this 192 + License permits You to additionally distribute such Covered Software 193 + under the terms of such Secondary License(s), so that the recipient of 194 + the Larger Work may, at their option, further distribute the Covered 195 + Software under the terms of either this License or such Secondary 196 + License(s). 197 + 198 + 3.4. Notices 199 + 200 + You may not remove or alter the substance of any license notices 201 + (including copyright notices, patent notices, disclaimers of warranty, 202 + or limitations of liability) contained within the Source Code Form of 203 + the Covered Software, except that You may alter any license notices to 204 + the extent required to remedy known factual inaccuracies. 205 + 206 + 3.5. Application of Additional Terms 207 + 208 + You may choose to offer, and to charge a fee for, warranty, support, 209 + indemnity or liability obligations to one or more recipients of Covered 210 + Software. However, You may do so only on Your own behalf, and not on 211 + behalf of any Contributor. You must make it absolutely clear that any 212 + such warranty, support, indemnity, or liability obligation is offered by 213 + You alone, and You hereby agree to indemnify every Contributor for any 214 + liability incurred by such Contributor as a result of warranty, support, 215 + indemnity or liability terms You offer. You may include additional 216 + disclaimers of warranty and limitations of liability specific to any 217 + jurisdiction. 218 + 219 + 4. Inability to Comply Due to Statute or Regulation 220 + --------------------------------------------------- 221 + 222 + If it is impossible for You to comply with any of the terms of this 223 + License with respect to some or all of the Covered Software due to 224 + statute, judicial order, or regulation then You must: (a) comply with 225 + the terms of this License to the maximum extent possible; and (b) 226 + describe the limitations and the code they affect. Such description must 227 + be placed in a text file included with all distributions of the Covered 228 + Software under this License. Except to the extent prohibited by statute 229 + or regulation, such description must be sufficiently detailed for a 230 + recipient of ordinary skill to be able to understand it. 231 + 232 + 5. Termination 233 + -------------- 234 + 235 + 5.1. The rights granted under this License will terminate automatically 236 + if You fail to comply with any of its terms. However, if You become 237 + compliant, then the rights granted under this License from a particular 238 + Contributor are reinstated (a) provisionally, unless and until such 239 + Contributor explicitly and finally terminates Your grants, and (b) on an 240 + ongoing basis, if such Contributor fails to notify You of the 241 + non-compliance by some reasonable means prior to 60 days after You have 242 + come back into compliance. Moreover, Your grants from a particular 243 + Contributor are reinstated on an ongoing basis if such Contributor 244 + notifies You of the non-compliance by some reasonable means, this is the 245 + first time You have received notice of non-compliance with this License 246 + from such Contributor, and You become compliant prior to 30 days after 247 + Your receipt of the notice. 248 + 249 + 5.2. If You initiate litigation against any entity by asserting a patent 250 + infringement claim (excluding declaratory judgment actions, 251 + counter-claims, and cross-claims) alleging that a Contributor Version 252 + directly or indirectly infringes any patent, then the rights granted to 253 + You by any and all Contributors for the Covered Software under Section 254 + 2.1 of this License shall terminate. 255 + 256 + 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 + end user license agreements (excluding distributors and resellers) which 258 + have been validly granted by You or Your distributors under this License 259 + prior to termination shall survive termination. 260 + 261 + ************************************************************************ 262 + * * 263 + * 6. Disclaimer of Warranty * 264 + * ------------------------- * 265 + * * 266 + * Covered Software is provided under this License on an "as is" * 267 + * basis, without warranty of any kind, either expressed, implied, or * 268 + * statutory, including, without limitation, warranties that the * 269 + * Covered Software is free of defects, merchantable, fit for a * 270 + * particular purpose or non-infringing. The entire risk as to the * 271 + * quality and performance of the Covered Software is with You. * 272 + * Should any Covered Software prove defective in any respect, You * 273 + * (not any Contributor) assume the cost of any necessary servicing, * 274 + * repair, or correction. This disclaimer of warranty constitutes an * 275 + * essential part of this License. No use of any Covered Software is * 276 + * authorized under this License except under this disclaimer. * 277 + * * 278 + ************************************************************************ 279 + 280 + ************************************************************************ 281 + * * 282 + * 7. Limitation of Liability * 283 + * -------------------------- * 284 + * * 285 + * Under no circumstances and under no legal theory, whether tort * 286 + * (including negligence), contract, or otherwise, shall any * 287 + * Contributor, or anyone who distributes Covered Software as * 288 + * permitted above, be liable to You for any direct, indirect, * 289 + * special, incidental, or consequential damages of any character * 290 + * including, without limitation, damages for lost profits, loss of * 291 + * goodwill, work stoppage, computer failure or malfunction, or any * 292 + * and all other commercial damages or losses, even if such party * 293 + * shall have been informed of the possibility of such damages. This * 294 + * limitation of liability shall not apply to liability for death or * 295 + * personal injury resulting from such party's negligence to the * 296 + * extent applicable law prohibits such limitation. Some * 297 + * jurisdictions do not allow the exclusion or limitation of * 298 + * incidental or consequential damages, so this exclusion and * 299 + * limitation may not apply to You. * 300 + * * 301 + ************************************************************************ 302 + 303 + 8. Litigation 304 + ------------- 305 + 306 + Any litigation relating to this License may be brought only in the 307 + courts of a jurisdiction where the defendant maintains its principal 308 + place of business and such litigation shall be governed by laws of that 309 + jurisdiction, without reference to its conflict-of-law provisions. 310 + Nothing in this Section shall prevent a party's ability to bring 311 + cross-claims or counter-claims. 312 + 313 + 9. Miscellaneous 314 + ---------------- 315 + 316 + This License represents the complete agreement concerning the subject 317 + matter hereof. If any provision of this License is held to be 318 + unenforceable, such provision shall be reformed only to the extent 319 + necessary to make it enforceable. Any law or regulation which provides 320 + that the language of a contract shall be construed against the drafter 321 + shall not be used to construe this License against a Contributor. 322 + 323 + 10. Versions of the License 324 + --------------------------- 325 + 326 + 10.1. New Versions 327 + 328 + Mozilla Foundation is the license steward. Except as provided in Section 329 + 10.3, no one other than the license steward has the right to modify or 330 + publish new versions of this License. Each version will be given a 331 + distinguishing version number. 332 + 333 + 10.2. Effect of New Versions 334 + 335 + You may distribute the Covered Software under the terms of the version 336 + of the License under which You originally received the Covered Software, 337 + or under the terms of any subsequent version published by the license 338 + steward. 339 + 340 + 10.3. Modified Versions 341 + 342 + If you create software not governed by this License, and you want to 343 + create a new license for such software, you may create and use a 344 + modified version of this License if you rename the license and remove 345 + any references to the name of the license steward (except to note that 346 + such modified license differs from this License). 347 + 348 + 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 + Licenses 350 + 351 + If You choose to distribute Source Code Form that is Incompatible With 352 + Secondary Licenses under the terms of this version of the License, the 353 + notice described in Exhibit B of this License must be attached. 354 + 355 + Exhibit A - Source Code Form License Notice 356 + ------------------------------------------- 357 + 358 + This Source Code Form is subject to the terms of the Mozilla Public 359 + License, v. 2.0. If a copy of the MPL was not distributed with this 360 + file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 + 362 + If it is not possible or desirable to put the notice in a particular 363 + file, then You may include the notice in a location (such as a LICENSE 364 + file in a relevant directory) where a recipient would be likely to look 365 + for such a notice. 366 + 367 + You may add additional accurate notices of copyright ownership. 368 + 369 + Exhibit B - "Incompatible With Secondary Licenses" Notice 370 + --------------------------------------------------------- 371 + 372 + This Source Code Form is "Incompatible With Secondary Licenses", as 373 + defined by the Mozilla Public License, v. 2.0.
+231
README.md
··· 1 + # fin ⚡ 2 + 3 + > a neon-electric Jellyfin client for the terminal — powered by `mpv` and Chromecast 4 + 5 + `fin` is a Rust TUI + one-shot CLI that talks to your Jellyfin server, searches 6 + your library, manages playlists, and pushes streams to either your local **mpv** 7 + window or any **Chromecast** on your network. Chromecast playback is fully 8 + queued — enqueue, play-next, skip, resume, all from the terminal. 9 + 10 + ## Features 11 + 12 + - **Ratatui-based TUI** with a neon-electric palette (teal / cyan / violet). 13 + - **fzf-style instant search** — results update on every keystroke. 14 + - **Two renderers**, one interface: 15 + - `mpv` (local) — spawned automatically and driven via its JSON IPC socket. 16 + - `chromecast` — device discovery via mDNS, playback through the Default 17 + Media Receiver, with a **local queue** that auto-advances on `FINISHED`. 18 + - **Real queue management** — enqueue, play next, jump between tracks, and 19 + see the whole queue in its own tab. Works identically for both renderers. 20 + - **Playlists** — browse, open, and play the playlists you've saved on the 21 + server. 22 + - **Now Playing bar** with title, subtitle, elapsed / total time, a neon 23 + progress gauge, volume, and the active renderer. 24 + - **CLI shortcuts** for scripting: `fin play "kind of blue"`, 25 + `fin queue --chromecast "Living Room" "wednesday"`, `fin devices`. 26 + - **All settings** are available as **CLI flags _or_ TOML keys** — one 27 + workflow scales from ad-hoc invocation to per-machine config. 28 + - **Pure Rust TLS** (`rustls`) everywhere — no OpenSSL required. 29 + 30 + ## Install 31 + 32 + `fin` requires **`mpv`** on your `$PATH` (it's used as the local renderer and 33 + as a required preflight check even when casting). 34 + 35 + ```bash 36 + # macOS 37 + brew install mpv 38 + 39 + # Debian / Ubuntu 40 + sudo apt install mpv 41 + 42 + # Arch 43 + sudo pacman -S mpv 44 + ``` 45 + 46 + Then build fin from source: 47 + 48 + ```bash 49 + git clone https://github.com/tsirysndr/fin 50 + cd fin 51 + cargo install --path crates/fin 52 + ``` 53 + 54 + ### Nix 55 + 56 + A flake is provided — mpv is baked into the wrapper, so no extra install 57 + step is needed: 58 + 59 + ```bash 60 + # One-off run: 61 + nix run github:tsirysndr/fin 62 + 63 + # Install into your user profile: 64 + nix profile install github:tsirysndr/fin 65 + 66 + # Dev shell (rust toolchain + mpv + clippy + rust-analyzer): 67 + nix develop 68 + ``` 69 + 70 + ## Getting started 71 + 72 + ```bash 73 + # 1. Sign in 74 + fin login https://media.example.com 75 + 76 + # 2. Launch the TUI (default sub-command) 77 + fin 78 + 79 + # 3. Or drive it entirely from the shell 80 + fin search "daft punk" 81 + fin play "kind of blue" 82 + fin queue "wednesday season 1" 83 + fin devices # list Chromecasts on your LAN 84 + fin play --chromecast "Living Room" "solaris" 85 + ``` 86 + 87 + ## Renderer selection 88 + 89 + Three ways to choose a renderer — all equivalent: 90 + 91 + | Shortcut flag | Long flag | Config key | 92 + |----------------------------------------|---------------------------|--------------------------------| 93 + | `--mpv` | `--renderer mpv` | `renderer = "mpv"` | 94 + | `--chromecast "Living Room"` | `--renderer chromecast` | `renderer = "chromecast"` | 95 + | _(none — falls back to mpv)_ | | | 96 + 97 + When you pass `--chromecast NAME`, the renderer is switched to chromecast 98 + automatically and that device is preferred on connect. If the name is not 99 + found on the network, fin picks the first device discovered. 100 + 101 + ## All settings 102 + 103 + Every setting exists as both a CLI flag and a TOML key. Flags win. 104 + 105 + | CLI flag | Env var | TOML key | Default | 106 + |-----------------------|---------------------|---------------------------|------------------| 107 + | `--server URL` | `FIN_SERVER` | `servers[].url` | _(none)_ | 108 + | `--server-name NAME` | `FIN_SERVER_NAME` | `current_server` | _(latest login)_ | 109 + | `--token TOKEN` | `FIN_TOKEN` | `servers[].access_token` | _(from login)_ | 110 + | `--user-id ID` | `FIN_USER_ID` | `servers[].user_id` | _(from login)_ | 111 + | `--user-name NAME` | | `servers[].user_name` | _(from login)_ | 112 + | `--device-id ID` | `FIN_DEVICE_ID` | `servers[].device_id` | random UUID | 113 + | `--renderer <mpv/chromecast>` | `FIN_RENDERER` | `renderer` | `mpv` | 114 + | `--mpv` | | `renderer = "mpv"` | | 115 + | `--chromecast [NAME]` | `FIN_CHROMECAST` | `last_chromecast` | | 116 + | `-v`, `-vv` | | _(log level)_ | `warn` | 117 + 118 + Find the on-disk config with `fin config --path`; print it with 119 + `fin config --show`. 120 + 121 + ## Multiple servers 122 + 123 + fin authenticates against as many Jellyfin servers as you like and keeps 124 + their tokens side-by-side in one config file: 125 + 126 + ```bash 127 + fin login https://home.example.com --name home 128 + fin login https://work.example.com --name work 129 + fin login https://mom.dyndns.example --name mom 130 + 131 + fin server # list all servers (▍ marks the current one) 132 + fin server switch work # make `work` the active server 133 + fin server rm mom # remove one 134 + fin server rename home casa # rename `home` → `casa` 135 + 136 + # One-off — hit `work` without changing the current pointer: 137 + fin --server-name work search "spirited away" 138 + fin --server-name work play "spirited away" 139 + ``` 140 + 141 + Inside the TUI, the Settings screen shows every saved server; **Enter** on 142 + one switches to it. **`t`** anywhere in the TUI cycles to the next server 143 + without leaving the current screen. 144 + 145 + ## Sub-commands 146 + 147 + ``` 148 + fin # launch the TUI (default) 149 + fin login <url> [--name N] # sign in and save credentials for server `N` 150 + fin logout [--name N] # remove server `N` (defaults to the current one) 151 + fin server # list saved servers 152 + fin server switch <name> # change the active server 153 + fin server rm <name> # remove one 154 + fin server rename <a> <b> # rename 155 + fin search <query> # print matches from the active library 156 + fin play <query> # search + play the top hit 157 + fin queue <query> # search + append to the current queue 158 + fin devices # list Chromecasts on the local network 159 + fin playlists # list playlists 160 + fin playlists --list <id> # dump items of a playlist 161 + fin config --show|--path # inspect config 162 + ``` 163 + 164 + ## Keybindings (TUI) 165 + 166 + | Key | Action | 167 + |------------------------------|-------------------------------------| 168 + | `Tab` / `Shift+Tab` | next / prev screen | 169 + | `1`…`8` | jump to Home / Search / Music / Videos / Playlists / Queue / Devices / Settings | 170 + | `/` | jump to Search & focus input | 171 + | `↑` `↓` / `k` `j` | move selection | 172 + | `PgUp` / `PgDown` | jump 10 rows | 173 + | `Enter` | play the highlighted item (or connect to the highlighted Chromecast) | 174 + | `a` | enqueue the highlighted item | 175 + | `n` | play the highlighted item **next** | 176 + | `Space` or `p` | pause / resume | 177 + | `s` | stop | 178 + | `<` / `>` or `h` / `l` | previous / next track | 179 + | `+` / `-` | volume up / down | 180 + | `m` | switch to local mpv renderer | 181 + | `t` | cycle to the next saved Jellyfin server | 182 + | `r` | refresh the current screen | 183 + | `Esc` | leave the search input / close open playlist | 184 + | `q` / `Ctrl-C` | quit | 185 + 186 + ## Chromecast queue 187 + 188 + For Chromecasts, `fin` maintains the queue **on the client**, tracks the 189 + device's media session, and loads the next item automatically the moment 190 + the current one reports `IDLE / FINISHED`. That means: 191 + 192 + - `a` (queue) and `n` (play-next) do the right thing while something is 193 + already casting. 194 + - Skipping (`>` / `<`) triggers a `load` for the next queue item 195 + immediately — no waiting for the current one to finish. 196 + - Stopping clears the local queue and stops the receiver's media session. 197 + 198 + ## Streams & transcoding 199 + 200 + - Local **mpv** playback uses the original stream (`Static=true`), which 201 + is the fastest path and lets mpv handle any container Jellyfin can 202 + direct-stream. 203 + - **Chromecast** playback defaults to Jellyfin's HLS output (`main.m3u8`), 204 + because the Default Media Receiver's codec matrix is much narrower than 205 + mpv's. This means Jellyfin will transcode when it needs to. 206 + - Force one or the other from the CLI with `--hls` on `play`/`queue`. 207 + 208 + ## Development 209 + 210 + ```bash 211 + cargo check --workspace 212 + cargo build --release -p fin 213 + ./target/release/fin --help 214 + ``` 215 + 216 + The workspace layout: 217 + 218 + ``` 219 + fin/ 220 + ├── crates/ 221 + │ ├── fin/ # binary — clap CLI + startup 222 + │ ├── fin-config/ # TOML config file & credentials 223 + │ ├── fin-jellyfin/ # Jellyfin HTTP API client 224 + │ ├── fin-player/ # Renderer trait + mpv + Chromecast + queue 225 + │ └── fin-tui/ # Ratatui neon TUI 226 + └── Cargo.toml # workspace + shared deps (rustls only, no openssl) 227 + ``` 228 + 229 + ## License 230 + 231 + `fin` is released under the [MPL-2.0](LICENSE).
+13
crates/fin-config/Cargo.toml
··· 1 + [package] 2 + name = "fin-config" 3 + version.workspace = true 4 + edition.workspace = true 5 + authors.workspace = true 6 + license.workspace = true 7 + repository.workspace = true 8 + 9 + [dependencies] 10 + anyhow.workspace = true 11 + serde.workspace = true 12 + toml.workspace = true 13 + directories.workspace = true
+239
crates/fin-config/src/lib.rs
··· 1 + use std::fs; 2 + use std::path::PathBuf; 3 + 4 + use anyhow::{anyhow, Context, Result}; 5 + use directories::ProjectDirs; 6 + use serde::{Deserialize, Serialize}; 7 + 8 + #[derive(Debug, Clone, Serialize, Deserialize, Default)] 9 + pub struct Config { 10 + /// Name of the active server (must exist in `servers`). 11 + #[serde(default)] 12 + pub current_server: Option<String>, 13 + /// All servers the user has authenticated against. 14 + #[serde(default)] 15 + pub servers: Vec<ServerConfig>, 16 + /// Legacy single-server slot. Kept only for one-shot migration; new 17 + /// writes drop it. If both this and `servers` are present, `servers` 18 + /// wins and this is ignored. 19 + #[serde(default, skip_serializing_if = "Option::is_none")] 20 + pub server: Option<LegacyServerConfig>, 21 + #[serde(default)] 22 + pub renderer: RendererPref, 23 + #[serde(default)] 24 + pub last_chromecast: Option<String>, 25 + #[serde(default)] 26 + pub client: ClientInfo, 27 + } 28 + 29 + #[derive(Debug, Clone, Serialize, Deserialize)] 30 + pub struct ServerConfig { 31 + /// Short label the user picks (or auto-derived from the URL host). 32 + pub name: String, 33 + pub url: String, 34 + pub user_id: String, 35 + pub user_name: String, 36 + pub access_token: String, 37 + pub device_id: String, 38 + } 39 + 40 + /// The old, single-server shape we used before multi-server support landed. 41 + /// Loaded once, then folded into `servers` and never written again. 42 + #[derive(Debug, Clone, Serialize, Deserialize)] 43 + pub struct LegacyServerConfig { 44 + pub url: String, 45 + pub user_id: String, 46 + pub user_name: String, 47 + pub access_token: String, 48 + pub device_id: String, 49 + } 50 + 51 + impl From<LegacyServerConfig> for ServerConfig { 52 + fn from(l: LegacyServerConfig) -> Self { 53 + let name = derive_server_name(&l.url); 54 + Self { 55 + name, 56 + url: l.url, 57 + user_id: l.user_id, 58 + user_name: l.user_name, 59 + access_token: l.access_token, 60 + device_id: l.device_id, 61 + } 62 + } 63 + } 64 + 65 + /// Derive a short server name from a URL — the host, without the scheme. 66 + /// 67 + /// https://media.example.com:8096/path → "media.example.com" 68 + /// http://192.168.1.42 → "192.168.1.42" 69 + pub fn derive_server_name(url: &str) -> String { 70 + let trimmed = url 71 + .trim_start_matches("https://") 72 + .trim_start_matches("http://"); 73 + let host: &str = trimmed 74 + .split(|c: char| c == '/' || c == ':') 75 + .next() 76 + .unwrap_or(trimmed); 77 + if host.is_empty() { 78 + "server".to_string() 79 + } else { 80 + host.to_string() 81 + } 82 + } 83 + 84 + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] 85 + #[serde(rename_all = "lowercase")] 86 + pub enum RendererPref { 87 + #[default] 88 + Mpv, 89 + Chromecast, 90 + } 91 + 92 + impl RendererPref { 93 + pub fn label(&self) -> &'static str { 94 + match self { 95 + Self::Mpv => "mpv", 96 + Self::Chromecast => "chromecast", 97 + } 98 + } 99 + } 100 + 101 + #[derive(Debug, Clone, Serialize, Deserialize)] 102 + pub struct ClientInfo { 103 + pub name: String, 104 + pub version: String, 105 + } 106 + 107 + impl Default for ClientInfo { 108 + fn default() -> Self { 109 + Self { 110 + name: "fin".into(), 111 + version: env!("CARGO_PKG_VERSION").into(), 112 + } 113 + } 114 + } 115 + 116 + fn project_dirs() -> Result<ProjectDirs> { 117 + ProjectDirs::from("app", "rocksky", "fin").context("could not determine project dirs") 118 + } 119 + 120 + pub fn config_path() -> Result<PathBuf> { 121 + let dirs = project_dirs()?; 122 + Ok(dirs.config_dir().join("config.toml")) 123 + } 124 + 125 + pub fn config_dir() -> Result<PathBuf> { 126 + let dirs = project_dirs()?; 127 + Ok(dirs.config_dir().to_path_buf()) 128 + } 129 + 130 + pub fn cache_dir() -> Result<PathBuf> { 131 + let dirs = project_dirs()?; 132 + Ok(dirs.cache_dir().to_path_buf()) 133 + } 134 + 135 + impl Config { 136 + pub fn load() -> Result<Self> { 137 + let path = config_path()?; 138 + if !path.exists() { 139 + return Ok(Self::default()); 140 + } 141 + let text = fs::read_to_string(&path) 142 + .with_context(|| format!("reading config at {}", path.display()))?; 143 + let mut cfg: Self = toml::from_str(&text).context("parsing config file")?; 144 + cfg.migrate_legacy_server(); 145 + Ok(cfg) 146 + } 147 + 148 + /// Fold a leftover `[server]` block into the `servers` list. Idempotent. 149 + fn migrate_legacy_server(&mut self) { 150 + if let Some(legacy) = self.server.take() { 151 + let migrated: ServerConfig = legacy.into(); 152 + let already = self.servers.iter().any(|s| s.url == migrated.url); 153 + if !already { 154 + let name = migrated.name.clone(); 155 + self.servers.push(migrated); 156 + if self.current_server.is_none() { 157 + self.current_server = Some(name); 158 + } 159 + } 160 + } 161 + if self.current_server.is_none() { 162 + self.current_server = self.servers.first().map(|s| s.name.clone()); 163 + } 164 + } 165 + 166 + pub fn save(&self) -> Result<()> { 167 + let path = config_path()?; 168 + if let Some(parent) = path.parent() { 169 + fs::create_dir_all(parent).context("creating config dir")?; 170 + } 171 + let text = toml::to_string_pretty(self).context("serializing config")?; 172 + fs::write(&path, text).with_context(|| format!("writing config to {}", path.display()))?; 173 + Ok(()) 174 + } 175 + 176 + pub fn current(&self) -> Option<&ServerConfig> { 177 + let name = self.current_server.as_ref()?; 178 + self.servers.iter().find(|s| &s.name == name) 179 + } 180 + 181 + pub fn require_current(&self) -> Result<&ServerConfig> { 182 + self.current() 183 + .context("no active server — run `fin login <url>` or `fin server switch <name>`") 184 + } 185 + 186 + pub fn find_server(&self, name: &str) -> Option<&ServerConfig> { 187 + self.servers.iter().find(|s| s.name == name) 188 + } 189 + 190 + /// Add or update a server (upsert by name) and mark it as the current one. 191 + pub fn add_or_update_server(&mut self, server: ServerConfig) { 192 + if let Some(existing) = self.servers.iter_mut().find(|s| s.name == server.name) { 193 + *existing = server.clone(); 194 + } else { 195 + self.servers.push(server.clone()); 196 + } 197 + self.current_server = Some(server.name); 198 + } 199 + 200 + /// Switch the active server by name. 201 + pub fn switch_to(&mut self, name: &str) -> Result<()> { 202 + if !self.servers.iter().any(|s| s.name == name) { 203 + return Err(anyhow!( 204 + "no server named `{}` — try `fin server` to list them", 205 + name 206 + )); 207 + } 208 + self.current_server = Some(name.to_string()); 209 + Ok(()) 210 + } 211 + 212 + /// Remove a server by name. Falls back to the first remaining server as 213 + /// the new current one, or clears `current_server` if none remain. 214 + pub fn remove_server(&mut self, name: &str) -> Result<()> { 215 + let before = self.servers.len(); 216 + self.servers.retain(|s| s.name != name); 217 + if self.servers.len() == before { 218 + return Err(anyhow!("no server named `{}`", name)); 219 + } 220 + if self.current_server.as_deref() == Some(name) { 221 + self.current_server = self.servers.first().map(|s| s.name.clone()); 222 + } 223 + Ok(()) 224 + } 225 + 226 + pub fn cycle_next(&mut self) -> Option<&ServerConfig> { 227 + if self.servers.is_empty() { 228 + return None; 229 + } 230 + let cur = self 231 + .current_server 232 + .as_deref() 233 + .and_then(|n| self.servers.iter().position(|s| s.name == n)) 234 + .unwrap_or(0); 235 + let next = (cur + 1) % self.servers.len(); 236 + self.current_server = Some(self.servers[next].name.clone()); 237 + self.servers.get(next) 238 + } 239 + }
+22
crates/fin-jellyfin/Cargo.toml
··· 1 + [package] 2 + name = "fin-jellyfin" 3 + version.workspace = true 4 + edition.workspace = true 5 + authors.workspace = true 6 + license.workspace = true 7 + repository.workspace = true 8 + 9 + [dependencies] 10 + anyhow.workspace = true 11 + thiserror.workspace = true 12 + serde.workspace = true 13 + serde_json.workspace = true 14 + reqwest.workspace = true 15 + url.workspace = true 16 + uuid.workspace = true 17 + chrono.workspace = true 18 + tracing.workspace = true 19 + tokio.workspace = true 20 + percent-encoding.workspace = true 21 + whoami.workspace = true 22 + fin-config.workspace = true
+436
crates/fin-jellyfin/src/client.rs
··· 1 + use anyhow::{Context, Result}; 2 + use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; 3 + use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; 4 + use serde::Serialize; 5 + use serde_json::json; 6 + use tracing::debug; 7 + use uuid::Uuid; 8 + 9 + use crate::models::{AuthResult, BaseItem, SearchHintResult, SearchResult, UserViewsResult}; 10 + 11 + const CLIENT_NAME: &str = "fin"; 12 + const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); 13 + 14 + #[derive(Debug, Clone, Copy)] 15 + pub enum StreamFormat { 16 + /// Direct/original stream if possible. 17 + Direct, 18 + /// HLS transcoded (useful for Chromecast). 19 + Hls, 20 + } 21 + 22 + #[derive(Debug, Clone)] 23 + pub struct JellyfinClient { 24 + base_url: String, 25 + device_id: String, 26 + device_name: String, 27 + access_token: Option<String>, 28 + user_id: Option<String>, 29 + http: reqwest::Client, 30 + } 31 + 32 + impl JellyfinClient { 33 + pub fn new(base_url: impl Into<String>) -> Result<Self> { 34 + let base_url = base_url.into().trim_end_matches('/').to_string(); 35 + let http = reqwest::Client::builder() 36 + .user_agent(format!("{}/{}", CLIENT_NAME, CLIENT_VERSION)) 37 + .build()?; 38 + let device_name = whoami::fallible::hostname().unwrap_or_else(|_| "fin-cli".to_string()); 39 + Ok(Self { 40 + base_url, 41 + device_id: Uuid::new_v4().to_string(), 42 + device_name, 43 + access_token: None, 44 + user_id: None, 45 + http, 46 + }) 47 + } 48 + 49 + pub fn with_credentials( 50 + base_url: impl Into<String>, 51 + device_id: impl Into<String>, 52 + user_id: impl Into<String>, 53 + access_token: impl Into<String>, 54 + ) -> Result<Self> { 55 + let mut c = Self::new(base_url)?; 56 + c.device_id = device_id.into(); 57 + c.user_id = Some(user_id.into()); 58 + c.access_token = Some(access_token.into()); 59 + Ok(c) 60 + } 61 + 62 + pub fn base_url(&self) -> &str { 63 + &self.base_url 64 + } 65 + 66 + pub fn device_id(&self) -> &str { 67 + &self.device_id 68 + } 69 + 70 + pub fn access_token(&self) -> Option<&str> { 71 + self.access_token.as_deref() 72 + } 73 + 74 + pub fn user_id(&self) -> Option<&str> { 75 + self.user_id.as_deref() 76 + } 77 + 78 + fn auth_header(&self) -> String { 79 + let token = self 80 + .access_token 81 + .as_deref() 82 + .map(|t| format!(", Token=\"{}\"", t)) 83 + .unwrap_or_default(); 84 + format!( 85 + "MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"{}", 86 + CLIENT_NAME, self.device_name, self.device_id, CLIENT_VERSION, token 87 + ) 88 + } 89 + 90 + fn headers(&self) -> Result<HeaderMap> { 91 + let mut h = HeaderMap::new(); 92 + h.insert(ACCEPT, HeaderValue::from_static("application/json")); 93 + h.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); 94 + h.insert( 95 + AUTHORIZATION, 96 + HeaderValue::from_str(&self.auth_header()).context("invalid auth header")?, 97 + ); 98 + Ok(h) 99 + } 100 + 101 + fn url(&self, path: &str) -> String { 102 + format!("{}{}", self.base_url, path) 103 + } 104 + 105 + /// Authenticate by username/password and store the access token on the client. 106 + pub async fn login(&mut self, username: &str, password: &str) -> Result<AuthResult> { 107 + let url = self.url("/Users/AuthenticateByName"); 108 + let body = json!({ 109 + "Username": username, 110 + "Pw": password, 111 + }); 112 + debug!(?url, "authenticating"); 113 + let resp = self 114 + .http 115 + .post(&url) 116 + .headers(self.headers()?) 117 + .json(&body) 118 + .send() 119 + .await?; 120 + let status = resp.status(); 121 + if !status.is_success() { 122 + let text = resp.text().await.unwrap_or_default(); 123 + anyhow::bail!("login failed ({}): {}", status, text); 124 + } 125 + let auth: AuthResult = resp.json().await.context("parsing auth response")?; 126 + self.access_token = Some(auth.access_token.clone()); 127 + self.user_id = Some(auth.user.id.clone()); 128 + Ok(auth) 129 + } 130 + 131 + /// Fetch the current user's library views (Movies, TV Shows, Music, …). 132 + pub async fn views(&self) -> Result<Vec<crate::models::UserView>> { 133 + let user_id = self.user_id.as_ref().context("not authenticated")?; 134 + let url = self.url(&format!("/Users/{}/Views", user_id)); 135 + let resp = self.http.get(&url).headers(self.headers()?).send().await?; 136 + let res: UserViewsResult = resp.error_for_status()?.json().await?; 137 + Ok(res.items) 138 + } 139 + 140 + /// Fetch children of a parent (library, folder, album, playlist, …). 141 + pub async fn items( 142 + &self, 143 + parent_id: Option<&str>, 144 + include_types: &[&str], 145 + recursive: bool, 146 + sort_by: Option<&str>, 147 + limit: Option<u32>, 148 + ) -> Result<Vec<BaseItem>> { 149 + let user_id = self.user_id.as_ref().context("not authenticated")?; 150 + let mut q: Vec<(String, String)> = vec![ 151 + ( 152 + "Fields".into(), 153 + "PrimaryImageAspectRatio,ProductionYear,Overview".into(), 154 + ), 155 + ("Recursive".into(), recursive.to_string()), 156 + ]; 157 + if let Some(p) = parent_id { 158 + q.push(("ParentId".into(), p.into())); 159 + } 160 + if !include_types.is_empty() { 161 + q.push(("IncludeItemTypes".into(), include_types.join(","))); 162 + } 163 + if let Some(s) = sort_by { 164 + q.push(("SortBy".into(), s.into())); 165 + q.push(("SortOrder".into(), "Ascending".into())); 166 + } 167 + if let Some(l) = limit { 168 + q.push(("Limit".into(), l.to_string())); 169 + } 170 + let url = self.url(&format!("/Users/{}/Items", user_id)); 171 + let resp = self 172 + .http 173 + .get(&url) 174 + .headers(self.headers()?) 175 + .query(&q) 176 + .send() 177 + .await?; 178 + let res: SearchResult = resp.error_for_status()?.json().await?; 179 + Ok(res.items) 180 + } 181 + 182 + /// Recent / resume items on the home screen. 183 + pub async fn resume(&self, limit: u32) -> Result<Vec<BaseItem>> { 184 + let user_id = self.user_id.as_ref().context("not authenticated")?; 185 + let url = self.url(&format!("/Users/{}/Items/Resume", user_id)); 186 + let resp = self 187 + .http 188 + .get(&url) 189 + .headers(self.headers()?) 190 + .query(&[("Limit", limit.to_string())]) 191 + .send() 192 + .await?; 193 + let res: SearchResult = resp.error_for_status()?.json().await?; 194 + Ok(res.items) 195 + } 196 + 197 + pub async fn latest(&self, parent_id: Option<&str>, limit: u32) -> Result<Vec<BaseItem>> { 198 + let user_id = self.user_id.as_ref().context("not authenticated")?; 199 + let mut q: Vec<(String, String)> = vec![ 200 + ("Limit".into(), limit.to_string()), 201 + ("Fields".into(), "ProductionYear".into()), 202 + ]; 203 + if let Some(p) = parent_id { 204 + q.push(("ParentId".into(), p.into())); 205 + } 206 + let url = self.url(&format!("/Users/{}/Items/Latest", user_id)); 207 + let resp = self 208 + .http 209 + .get(&url) 210 + .headers(self.headers()?) 211 + .query(&q) 212 + .send() 213 + .await?; 214 + let items: Vec<BaseItem> = resp.error_for_status()?.json().await?; 215 + Ok(items) 216 + } 217 + 218 + /// Search hints — the fast fuzzy search endpoint Jellyfin's web client uses. 219 + pub async fn search( 220 + &self, 221 + term: &str, 222 + include_types: &[&str], 223 + limit: u32, 224 + ) -> Result<Vec<BaseItem>> { 225 + let user_id = self.user_id.as_ref().context("not authenticated")?; 226 + let mut q: Vec<(String, String)> = vec![ 227 + ("SearchTerm".into(), term.into()), 228 + ("UserId".into(), user_id.clone()), 229 + ("Limit".into(), limit.to_string()), 230 + ]; 231 + if !include_types.is_empty() { 232 + q.push(("IncludeItemTypes".into(), include_types.join(","))); 233 + } 234 + let url = self.url("/Search/Hints"); 235 + let resp = self 236 + .http 237 + .get(&url) 238 + .headers(self.headers()?) 239 + .query(&q) 240 + .send() 241 + .await?; 242 + let res: SearchHintResult = resp.error_for_status()?.json().await?; 243 + Ok(res 244 + .search_hints 245 + .into_iter() 246 + .map(|h| h.into_base_item()) 247 + .collect()) 248 + } 249 + 250 + /// All playlists visible to the current user. 251 + pub async fn playlists(&self) -> Result<Vec<BaseItem>> { 252 + self.items(None, &["Playlist"], true, Some("SortName"), None) 253 + .await 254 + } 255 + 256 + pub async fn playlist_items(&self, playlist_id: &str) -> Result<Vec<BaseItem>> { 257 + let user_id = self.user_id.as_ref().context("not authenticated")?; 258 + let url = self.url(&format!("/Playlists/{}/Items", playlist_id)); 259 + let resp = self 260 + .http 261 + .get(&url) 262 + .headers(self.headers()?) 263 + .query(&[("UserId", user_id.as_str())]) 264 + .send() 265 + .await?; 266 + let res: SearchResult = resp.error_for_status()?.json().await?; 267 + Ok(res.items) 268 + } 269 + 270 + pub async fn create_playlist( 271 + &self, 272 + name: &str, 273 + media_type: &str, 274 + item_ids: &[String], 275 + ) -> Result<String> { 276 + let user_id = self.user_id.as_ref().context("not authenticated")?; 277 + let url = self.url("/Playlists"); 278 + let body = json!({ 279 + "Name": name, 280 + "Ids": item_ids, 281 + "UserId": user_id, 282 + "MediaType": media_type, 283 + }); 284 + let resp = self 285 + .http 286 + .post(&url) 287 + .headers(self.headers()?) 288 + .json(&body) 289 + .send() 290 + .await?; 291 + #[derive(serde::Deserialize)] 292 + #[serde(rename_all = "PascalCase")] 293 + struct R { 294 + id: String, 295 + } 296 + let r: R = resp.error_for_status()?.json().await?; 297 + Ok(r.id) 298 + } 299 + 300 + pub async fn add_to_playlist(&self, playlist_id: &str, item_ids: &[String]) -> Result<()> { 301 + let user_id = self.user_id.as_ref().context("not authenticated")?; 302 + let url = self.url(&format!("/Playlists/{}/Items", playlist_id)); 303 + self.http 304 + .post(&url) 305 + .headers(self.headers()?) 306 + .query(&[("Ids", item_ids.join(",")), ("UserId", user_id.to_string())]) 307 + .send() 308 + .await? 309 + .error_for_status()?; 310 + Ok(()) 311 + } 312 + 313 + pub async fn remove_from_playlist( 314 + &self, 315 + playlist_id: &str, 316 + entry_ids: &[String], 317 + ) -> Result<()> { 318 + let url = self.url(&format!("/Playlists/{}/Items", playlist_id)); 319 + self.http 320 + .delete(&url) 321 + .headers(self.headers()?) 322 + .query(&[("EntryIds", entry_ids.join(","))]) 323 + .send() 324 + .await? 325 + .error_for_status()?; 326 + Ok(()) 327 + } 328 + 329 + /// Build a direct-playback stream URL. `format` chooses between original stream and HLS. 330 + pub fn stream_url(&self, item: &BaseItem, format: StreamFormat) -> Result<String> { 331 + let token = self.access_token.as_deref().context("no access token")?; 332 + let kind = item.kind(); 333 + let is_audio = kind.is_audio() || item.media_type.as_deref() == Some("Audio"); 334 + let container = if is_audio { "mp3" } else { "mp4" }; 335 + let path = match (format, is_audio) { 336 + (StreamFormat::Hls, true) => format!("/Audio/{}/main.m3u8", item.id), 337 + (StreamFormat::Hls, false) => format!("/Videos/{}/main.m3u8", item.id), 338 + (StreamFormat::Direct, true) => { 339 + format!("/Audio/{}/stream.{}", item.id, container) 340 + } 341 + (StreamFormat::Direct, false) => { 342 + format!("/Videos/{}/stream.{}", item.id, container) 343 + } 344 + }; 345 + let mut params: Vec<(&str, String)> = vec![ 346 + ("api_key", token.to_string()), 347 + ("DeviceId", self.device_id.clone()), 348 + ("Static", "true".into()), 349 + ]; 350 + if matches!(format, StreamFormat::Hls) { 351 + params.push(("PlaySessionId", Uuid::new_v4().to_string())); 352 + } 353 + let qs = params 354 + .into_iter() 355 + .map(|(k, v)| { 356 + format!( 357 + "{}={}", 358 + k, 359 + utf8_percent_encode(&v, NON_ALPHANUMERIC).to_string() 360 + ) 361 + }) 362 + .collect::<Vec<_>>() 363 + .join("&"); 364 + Ok(format!("{}{}?{}", self.base_url, path, qs)) 365 + } 366 + 367 + /// Primary image URL for an item, suitable for tile previews. 368 + pub fn image_url(&self, item_id: &str, tag: &str, width: u32) -> String { 369 + format!( 370 + "{}/Items/{}/Images/Primary?tag={}&fillWidth={}&format=jpg", 371 + self.base_url, item_id, tag, width 372 + ) 373 + } 374 + 375 + pub async fn report_started(&self, item: &BaseItem, session_id: &str) -> Result<()> { 376 + self.report( 377 + "/Sessions/Playing", 378 + json!({ 379 + "ItemId": item.id, 380 + "PlaySessionId": session_id, 381 + "CanSeek": true, 382 + }), 383 + ) 384 + .await 385 + } 386 + 387 + pub async fn report_progress( 388 + &self, 389 + item: &BaseItem, 390 + session_id: &str, 391 + position_ticks: i64, 392 + paused: bool, 393 + ) -> Result<()> { 394 + self.report( 395 + "/Sessions/Playing/Progress", 396 + json!({ 397 + "ItemId": item.id, 398 + "PlaySessionId": session_id, 399 + "PositionTicks": position_ticks, 400 + "IsPaused": paused, 401 + "CanSeek": true, 402 + "EventName": "TimeUpdate", 403 + }), 404 + ) 405 + .await 406 + } 407 + 408 + pub async fn report_stopped( 409 + &self, 410 + item: &BaseItem, 411 + session_id: &str, 412 + position_ticks: i64, 413 + ) -> Result<()> { 414 + self.report( 415 + "/Sessions/Playing/Stopped", 416 + json!({ 417 + "ItemId": item.id, 418 + "PlaySessionId": session_id, 419 + "PositionTicks": position_ticks, 420 + }), 421 + ) 422 + .await 423 + } 424 + 425 + async fn report<T: Serialize>(&self, path: &str, body: T) -> Result<()> { 426 + let url = self.url(path); 427 + self.http 428 + .post(&url) 429 + .headers(self.headers()?) 430 + .json(&body) 431 + .send() 432 + .await? 433 + .error_for_status()?; 434 + Ok(()) 435 + } 436 + }
+7
crates/fin-jellyfin/src/lib.rs
··· 1 + pub mod client; 2 + pub mod models; 3 + 4 + pub use client::{JellyfinClient, StreamFormat}; 5 + pub use models::{ 6 + AuthResult, BaseItem, ItemKind, Playlist, PlaylistItem, SearchHint, SearchResult, UserView, 7 + };
+262
crates/fin-jellyfin/src/models.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Debug, Clone, Serialize, Deserialize)] 4 + #[serde(rename_all = "PascalCase")] 5 + pub struct AuthResult { 6 + pub access_token: String, 7 + pub server_id: String, 8 + pub user: AuthUser, 9 + } 10 + 11 + #[derive(Debug, Clone, Serialize, Deserialize)] 12 + #[serde(rename_all = "PascalCase")] 13 + pub struct AuthUser { 14 + pub id: String, 15 + pub name: String, 16 + } 17 + 18 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 19 + pub enum ItemKind { 20 + Audio, 21 + MusicAlbum, 22 + MusicArtist, 23 + Playlist, 24 + Movie, 25 + Series, 26 + Season, 27 + Episode, 28 + Video, 29 + Folder, 30 + Other, 31 + } 32 + 33 + impl ItemKind { 34 + pub fn parse(s: &str) -> Self { 35 + match s { 36 + "Audio" => Self::Audio, 37 + "MusicAlbum" => Self::MusicAlbum, 38 + "MusicArtist" => Self::MusicArtist, 39 + "Playlist" => Self::Playlist, 40 + "Movie" => Self::Movie, 41 + "Series" => Self::Series, 42 + "Season" => Self::Season, 43 + "Episode" => Self::Episode, 44 + "Video" => Self::Video, 45 + "Folder" | "CollectionFolder" => Self::Folder, 46 + _ => Self::Other, 47 + } 48 + } 49 + 50 + pub fn is_audio(&self) -> bool { 51 + matches!(self, Self::Audio | Self::MusicAlbum | Self::MusicArtist) 52 + } 53 + 54 + pub fn is_video(&self) -> bool { 55 + matches!( 56 + self, 57 + Self::Movie | Self::Series | Self::Season | Self::Episode | Self::Video 58 + ) 59 + } 60 + 61 + pub fn is_playable(&self) -> bool { 62 + matches!( 63 + self, 64 + Self::Audio | Self::Movie | Self::Episode | Self::Video 65 + ) 66 + } 67 + 68 + pub fn icon(&self) -> &'static str { 69 + match self { 70 + Self::Audio => "♪", 71 + Self::MusicAlbum => "◈", 72 + Self::MusicArtist => "◊", 73 + Self::Playlist => "▤", 74 + Self::Movie => "▶", 75 + Self::Series => "▤", 76 + Self::Season => "◱", 77 + Self::Episode => "▶", 78 + Self::Video => "▶", 79 + Self::Folder => "▸", 80 + Self::Other => "•", 81 + } 82 + } 83 + } 84 + 85 + #[derive(Debug, Clone, Serialize, Deserialize)] 86 + #[serde(rename_all = "PascalCase")] 87 + pub struct BaseItem { 88 + pub id: String, 89 + pub name: String, 90 + #[serde(rename = "Type")] 91 + pub type_: String, 92 + #[serde(default)] 93 + pub album: Option<String>, 94 + #[serde(default)] 95 + pub album_id: Option<String>, 96 + #[serde(default)] 97 + pub album_artist: Option<String>, 98 + #[serde(default)] 99 + pub artists: Option<Vec<String>>, 100 + #[serde(default)] 101 + pub series_name: Option<String>, 102 + #[serde(default)] 103 + pub production_year: Option<i32>, 104 + #[serde(default)] 105 + pub run_time_ticks: Option<i64>, 106 + #[serde(default)] 107 + pub media_type: Option<String>, 108 + #[serde(default)] 109 + pub index_number: Option<i32>, 110 + #[serde(default)] 111 + pub parent_index_number: Option<i32>, 112 + #[serde(default)] 113 + pub image_tags: Option<serde_json::Value>, 114 + #[serde(default)] 115 + pub is_folder: Option<bool>, 116 + #[serde(default)] 117 + pub overview: Option<String>, 118 + } 119 + 120 + impl BaseItem { 121 + pub fn kind(&self) -> ItemKind { 122 + ItemKind::parse(&self.type_) 123 + } 124 + 125 + pub fn duration_secs(&self) -> Option<u64> { 126 + self.run_time_ticks.map(|t| (t / 10_000_000) as u64) 127 + } 128 + 129 + pub fn subtitle(&self) -> String { 130 + match self.kind() { 131 + ItemKind::Audio => { 132 + let artists = self 133 + .artists 134 + .as_ref() 135 + .and_then(|a| { 136 + if a.is_empty() { 137 + None 138 + } else { 139 + Some(a.join(", ")) 140 + } 141 + }) 142 + .or_else(|| self.album_artist.clone()) 143 + .unwrap_or_default(); 144 + let album = self.album.clone().unwrap_or_default(); 145 + match (artists.is_empty(), album.is_empty()) { 146 + (true, true) => String::new(), 147 + (false, true) => artists, 148 + (true, false) => album, 149 + (false, false) => format!("{} — {}", artists, album), 150 + } 151 + } 152 + ItemKind::MusicAlbum => self 153 + .album_artist 154 + .clone() 155 + .or_else(|| self.artists.as_ref().map(|a| a.join(", "))) 156 + .unwrap_or_default(), 157 + ItemKind::MusicArtist => String::new(), 158 + ItemKind::Movie => self 159 + .production_year 160 + .map(|y| y.to_string()) 161 + .unwrap_or_default(), 162 + ItemKind::Episode => match (self.parent_index_number, self.index_number) { 163 + (Some(s), Some(e)) => format!( 164 + "S{:02}E{:02} — {}", 165 + s, 166 + e, 167 + self.series_name.clone().unwrap_or_default() 168 + ), 169 + _ => self.series_name.clone().unwrap_or_default(), 170 + }, 171 + ItemKind::Series => self 172 + .production_year 173 + .map(|y| y.to_string()) 174 + .unwrap_or_default(), 175 + _ => String::new(), 176 + } 177 + } 178 + } 179 + 180 + #[derive(Debug, Clone, Serialize, Deserialize)] 181 + #[serde(rename_all = "PascalCase")] 182 + pub struct SearchResult { 183 + #[serde(default)] 184 + pub items: Vec<BaseItem>, 185 + #[serde(default)] 186 + pub total_record_count: i64, 187 + } 188 + 189 + #[derive(Debug, Clone, Serialize, Deserialize)] 190 + #[serde(rename_all = "PascalCase")] 191 + pub struct SearchHintResult { 192 + #[serde(default)] 193 + pub search_hints: Vec<SearchHint>, 194 + #[serde(default)] 195 + pub total_record_count: i64, 196 + } 197 + 198 + #[derive(Debug, Clone, Serialize, Deserialize)] 199 + #[serde(rename_all = "PascalCase")] 200 + pub struct SearchHint { 201 + #[serde(default, alias = "ItemId")] 202 + pub id: String, 203 + pub name: String, 204 + #[serde(rename = "Type", default)] 205 + pub type_: String, 206 + #[serde(default)] 207 + pub album: Option<String>, 208 + #[serde(default)] 209 + pub album_artist: Option<String>, 210 + #[serde(default)] 211 + pub artists: Option<Vec<String>>, 212 + #[serde(default)] 213 + pub production_year: Option<i32>, 214 + #[serde(default)] 215 + pub run_time_ticks: Option<i64>, 216 + #[serde(default)] 217 + pub media_type: Option<String>, 218 + #[serde(default)] 219 + pub series_name: Option<String>, 220 + } 221 + 222 + impl SearchHint { 223 + pub fn into_base_item(self) -> BaseItem { 224 + BaseItem { 225 + id: self.id, 226 + name: self.name, 227 + type_: self.type_, 228 + album: self.album, 229 + album_id: None, 230 + album_artist: self.album_artist, 231 + artists: self.artists, 232 + series_name: self.series_name, 233 + production_year: self.production_year, 234 + run_time_ticks: self.run_time_ticks, 235 + media_type: self.media_type, 236 + index_number: None, 237 + parent_index_number: None, 238 + image_tags: None, 239 + is_folder: None, 240 + overview: None, 241 + } 242 + } 243 + } 244 + 245 + #[derive(Debug, Clone, Serialize, Deserialize)] 246 + #[serde(rename_all = "PascalCase")] 247 + pub struct UserView { 248 + pub id: String, 249 + pub name: String, 250 + #[serde(default)] 251 + pub collection_type: Option<String>, 252 + } 253 + 254 + #[derive(Debug, Clone, Serialize, Deserialize)] 255 + #[serde(rename_all = "PascalCase")] 256 + pub struct UserViewsResult { 257 + #[serde(default)] 258 + pub items: Vec<UserView>, 259 + } 260 + 261 + pub type Playlist = BaseItem; 262 + pub type PlaylistItem = BaseItem;
+22
crates/fin-player/Cargo.toml
··· 1 + [package] 2 + name = "fin-player" 3 + version.workspace = true 4 + edition.workspace = true 5 + authors.workspace = true 6 + license.workspace = true 7 + repository.workspace = true 8 + 9 + [dependencies] 10 + anyhow.workspace = true 11 + thiserror.workspace = true 12 + serde.workspace = true 13 + serde_json.workspace = true 14 + tokio.workspace = true 15 + async-trait.workspace = true 16 + tracing.workspace = true 17 + uuid.workspace = true 18 + parking_lot.workspace = true 19 + futures.workspace = true 20 + mdns-sd.workspace = true 21 + rust_cast.workspace = true 22 + fin-jellyfin.workspace = true
+544
crates/fin-player/src/cast.rs
··· 1 + use std::sync::mpsc as std_mpsc; 2 + use std::sync::Arc; 3 + use std::thread; 4 + use std::time::{Duration, Instant}; 5 + 6 + use anyhow::{anyhow, Context, Result}; 7 + use async_trait::async_trait; 8 + use parking_lot::Mutex; 9 + use tokio::sync::oneshot; 10 + use tracing::error; 11 + 12 + use rust_cast::channels::media::{ 13 + IdleReason, Image, Media, Metadata, MovieMediaMetadata, MusicTrackMediaMetadata, PlayerState, 14 + StreamType, 15 + }; 16 + use rust_cast::channels::receiver::CastDeviceApp; 17 + use rust_cast::CastDevice as RcDevice; 18 + 19 + use crate::discovery::CastDevice; 20 + use crate::queue::{PlaybackQueue, QueueItem}; 21 + use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 22 + 23 + const DEFAULT_DESTINATION_ID: &str = "receiver-0"; 24 + 25 + enum CastCommand { 26 + Play { 27 + items: Vec<QueueItem>, 28 + start_index: usize, 29 + reply: oneshot::Sender<Result<()>>, 30 + }, 31 + Enqueue { 32 + items: Vec<QueueItem>, 33 + reply: oneshot::Sender<Result<()>>, 34 + }, 35 + PlayNext { 36 + items: Vec<QueueItem>, 37 + reply: oneshot::Sender<Result<()>>, 38 + }, 39 + Pause(oneshot::Sender<Result<()>>), 40 + Resume(oneshot::Sender<Result<()>>), 41 + Stop(oneshot::Sender<Result<()>>), 42 + Next(oneshot::Sender<Result<()>>), 43 + Previous(oneshot::Sender<Result<()>>), 44 + Seek(f64, oneshot::Sender<Result<()>>), 45 + Volume(f32, oneshot::Sender<Result<()>>), 46 + Shutdown, 47 + } 48 + 49 + pub struct ChromecastRenderer { 50 + device: CastDevice, 51 + tx: std_mpsc::Sender<CastCommand>, 52 + state: Arc<Mutex<PlaybackState>>, 53 + queue: PlaybackQueue, 54 + } 55 + 56 + impl ChromecastRenderer { 57 + /// Connect to a Chromecast and launch the Default Media Receiver. 58 + pub async fn connect(device: CastDevice) -> Result<Self> { 59 + let state = Arc::new(Mutex::new(PlaybackState::default())); 60 + let queue = PlaybackQueue::new(); 61 + let (tx, rx) = std_mpsc::channel::<CastCommand>(); 62 + 63 + let dev_for_thread = device.clone(); 64 + let state_for_thread = state.clone(); 65 + let queue_for_thread = queue.clone(); 66 + 67 + let (ready_tx, ready_rx) = oneshot::channel(); 68 + 69 + thread::spawn(move || { 70 + if let Err(e) = cast_worker( 71 + dev_for_thread, 72 + rx, 73 + state_for_thread, 74 + queue_for_thread, 75 + ready_tx, 76 + ) { 77 + error!(error=?e, "chromecast worker exited with error"); 78 + } 79 + }); 80 + 81 + ready_rx 82 + .await 83 + .context("chromecast worker dropped ready signal")??; 84 + 85 + Ok(Self { 86 + device, 87 + tx, 88 + state, 89 + queue, 90 + }) 91 + } 92 + 93 + pub fn device(&self) -> &CastDevice { 94 + &self.device 95 + } 96 + 97 + pub fn queue_handle(&self) -> PlaybackQueue { 98 + self.queue.clone() 99 + } 100 + 101 + async fn send( 102 + &self, 103 + make: impl FnOnce(oneshot::Sender<Result<()>>) -> CastCommand, 104 + ) -> Result<()> { 105 + let (tx, rx) = oneshot::channel(); 106 + self.tx 107 + .send(make(tx)) 108 + .map_err(|_| anyhow!("chromecast worker dead"))?; 109 + rx.await.context("chromecast reply dropped")? 110 + } 111 + } 112 + 113 + impl Drop for ChromecastRenderer { 114 + fn drop(&mut self) { 115 + let _ = self.tx.send(CastCommand::Shutdown); 116 + } 117 + } 118 + 119 + fn build_media(item: &QueueItem) -> Media { 120 + let images: Vec<Image> = item 121 + .image_url 122 + .clone() 123 + .map(|u| vec![Image::new(u)]) 124 + .unwrap_or_default(); 125 + let metadata = if item.is_video { 126 + Metadata::Movie(MovieMediaMetadata { 127 + title: Some(item.title.clone()), 128 + subtitle: if item.subtitle.is_empty() { 129 + None 130 + } else { 131 + Some(item.subtitle.clone()) 132 + }, 133 + studio: None, 134 + release_date: None, 135 + images, 136 + }) 137 + } else { 138 + Metadata::MusicTrack(MusicTrackMediaMetadata { 139 + album_name: None, 140 + title: Some(item.title.clone()), 141 + album_artist: None, 142 + artist: if item.subtitle.is_empty() { 143 + None 144 + } else { 145 + Some(item.subtitle.clone()) 146 + }, 147 + composer: None, 148 + track_number: None, 149 + disc_number: None, 150 + images, 151 + release_date: None, 152 + }) 153 + }; 154 + Media { 155 + content_id: item.stream_url.clone(), 156 + content_type: item.content_type.clone(), 157 + stream_type: StreamType::Buffered, 158 + duration: item.duration_secs.map(|d| d as f32), 159 + metadata: Some(metadata), 160 + } 161 + } 162 + 163 + struct Session { 164 + app_transport: String, 165 + session_id: String, 166 + media_session_id: Option<i32>, 167 + } 168 + 169 + fn cast_worker( 170 + device: CastDevice, 171 + rx: std_mpsc::Receiver<CastCommand>, 172 + state: Arc<Mutex<PlaybackState>>, 173 + queue: PlaybackQueue, 174 + ready: oneshot::Sender<Result<()>>, 175 + ) -> Result<()> { 176 + let host = device.address.to_string(); 177 + let rc = match RcDevice::connect_without_host_verification(host.clone(), device.port) { 178 + Ok(d) => d, 179 + Err(e) => { 180 + let _ = ready.send(Err(anyhow!("connect to {}: {}", device.display_name(), e))); 181 + return Ok(()); 182 + } 183 + }; 184 + 185 + if let Err(e) = rc.connection.connect(DEFAULT_DESTINATION_ID) { 186 + let _ = ready.send(Err(anyhow!("initial connect: {}", e))); 187 + return Ok(()); 188 + } 189 + if let Err(e) = rc.heartbeat.ping() { 190 + let _ = ready.send(Err(anyhow!("heartbeat ping: {}", e))); 191 + return Ok(()); 192 + } 193 + 194 + let app = match rc.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver) { 195 + Ok(a) => a, 196 + Err(e) => { 197 + let _ = ready.send(Err(anyhow!("launching default media receiver: {}", e))); 198 + return Ok(()); 199 + } 200 + }; 201 + if let Err(e) = rc.connection.connect(app.transport_id.as_str()) { 202 + let _ = ready.send(Err(anyhow!("connect to app: {}", e))); 203 + return Ok(()); 204 + } 205 + 206 + let session = Arc::new(Mutex::new(Session { 207 + app_transport: app.transport_id.clone(), 208 + session_id: app.session_id.clone(), 209 + media_session_id: None, 210 + })); 211 + 212 + let _ = ready.send(Ok(())); 213 + 214 + let mut last_heartbeat = Instant::now(); 215 + let mut last_status = Instant::now(); 216 + 217 + // We run the worker single-threaded — rust_cast's channels aren't 218 + // thread-safe. Between commands we poll media status for progress 219 + // updates and auto-advance on FINISHED. 220 + loop { 221 + match rx.recv_timeout(Duration::from_millis(300)) { 222 + Ok(CastCommand::Shutdown) => { 223 + let sess = session.lock(); 224 + let _ = rc.receiver.stop_app(sess.session_id.as_str()); 225 + return Ok(()); 226 + } 227 + Ok(cmd) => handle_command(cmd, &rc, &session, &queue, &state), 228 + Err(std_mpsc::RecvTimeoutError::Timeout) => {} 229 + Err(std_mpsc::RecvTimeoutError::Disconnected) => return Ok(()), 230 + } 231 + 232 + if last_heartbeat.elapsed() > Duration::from_secs(4) { 233 + let _ = rc.heartbeat.ping(); 234 + last_heartbeat = Instant::now(); 235 + } 236 + 237 + if last_status.elapsed() > Duration::from_millis(750) { 238 + last_status = Instant::now(); 239 + let (dest, media_id) = { 240 + let sess = session.lock(); 241 + (sess.app_transport.clone(), sess.media_session_id) 242 + }; 243 + if let Some(id) = media_id { 244 + if let Ok(status) = rc.media.get_status(dest.as_str(), Some(id)) { 245 + let mut advanced = false; 246 + if let Some(entry) = status.entries.first() { 247 + let mut s = state.lock(); 248 + s.position_secs = entry.current_time.unwrap_or(0.0) as f64; 249 + if let Some(m) = &entry.media { 250 + if let Some(d) = m.duration { 251 + s.duration_secs = d as f64; 252 + } 253 + } 254 + match entry.player_state { 255 + PlayerState::Playing => s.status = PlaybackStatus::Playing, 256 + PlayerState::Paused => s.status = PlaybackStatus::Paused, 257 + PlayerState::Buffering => s.status = PlaybackStatus::Buffering, 258 + PlayerState::Idle => { 259 + if matches!(entry.idle_reason, Some(IdleReason::Finished)) { 260 + advanced = true; 261 + } else { 262 + s.status = PlaybackStatus::Idle; 263 + } 264 + } 265 + } 266 + } 267 + if advanced { 268 + if queue.advance().is_some() { 269 + if let Some(next) = queue.current() { 270 + let _ = load_media(&rc, &session, &next, &state); 271 + } 272 + } else { 273 + state.lock().status = PlaybackStatus::Idle; 274 + } 275 + } 276 + } 277 + } 278 + } 279 + } 280 + } 281 + 282 + fn handle_command( 283 + cmd: CastCommand, 284 + rc: &RcDevice, 285 + session: &Arc<Mutex<Session>>, 286 + queue: &PlaybackQueue, 287 + state: &Arc<Mutex<PlaybackState>>, 288 + ) { 289 + match cmd { 290 + CastCommand::Play { 291 + items, 292 + start_index, 293 + reply, 294 + } => { 295 + queue.replace(items.clone(), start_index); 296 + let index = queue.current_index().unwrap_or(0); 297 + let current = items.get(index).cloned(); 298 + { 299 + let mut s = state.lock(); 300 + s.queue = queue.items(); 301 + s.current_index = queue.current_index(); 302 + s.now_playing = current.clone(); 303 + s.status = PlaybackStatus::Buffering; 304 + } 305 + let res = if let Some(item) = current { 306 + load_media(rc, session, &item, state) 307 + } else { 308 + Ok(()) 309 + }; 310 + let _ = reply.send(res); 311 + } 312 + CastCommand::Enqueue { items, reply } => { 313 + let was_empty = queue.is_empty(); 314 + queue.append(items); 315 + let res = if was_empty { 316 + if let Some(item) = queue.current() { 317 + load_media(rc, session, &item, state) 318 + } else { 319 + Ok(()) 320 + } 321 + } else { 322 + Ok(()) 323 + }; 324 + { 325 + let mut s = state.lock(); 326 + s.queue = queue.items(); 327 + s.current_index = queue.current_index(); 328 + s.now_playing = queue.current(); 329 + } 330 + let _ = reply.send(res); 331 + } 332 + CastCommand::PlayNext { items, reply } => { 333 + let was_empty = queue.is_empty(); 334 + queue.insert_next(items); 335 + let res = if was_empty { 336 + if let Some(item) = queue.current() { 337 + load_media(rc, session, &item, state) 338 + } else { 339 + Ok(()) 340 + } 341 + } else { 342 + Ok(()) 343 + }; 344 + { 345 + let mut s = state.lock(); 346 + s.queue = queue.items(); 347 + s.current_index = queue.current_index(); 348 + s.now_playing = queue.current(); 349 + } 350 + let _ = reply.send(res); 351 + } 352 + CastCommand::Pause(reply) => { 353 + let sess = session.lock(); 354 + let media_id = sess.media_session_id; 355 + let dest = sess.app_transport.clone(); 356 + drop(sess); 357 + let res = if let Some(id) = media_id { 358 + rc.media 359 + .pause(dest.as_str(), id) 360 + .map(|_| ()) 361 + .map_err(|e| anyhow!("pause: {}", e)) 362 + } else { 363 + Ok(()) 364 + }; 365 + let _ = reply.send(res); 366 + } 367 + CastCommand::Resume(reply) => { 368 + let sess = session.lock(); 369 + let media_id = sess.media_session_id; 370 + let dest = sess.app_transport.clone(); 371 + drop(sess); 372 + let res = if let Some(id) = media_id { 373 + rc.media 374 + .play(dest.as_str(), id) 375 + .map(|_| ()) 376 + .map_err(|e| anyhow!("resume: {}", e)) 377 + } else { 378 + Ok(()) 379 + }; 380 + let _ = reply.send(res); 381 + } 382 + CastCommand::Stop(reply) => { 383 + let sess = session.lock(); 384 + let media_id = sess.media_session_id; 385 + let dest = sess.app_transport.clone(); 386 + drop(sess); 387 + queue.clear(); 388 + { 389 + let mut s = state.lock(); 390 + s.queue.clear(); 391 + s.now_playing = None; 392 + s.current_index = None; 393 + s.status = PlaybackStatus::Stopped; 394 + } 395 + let res = if let Some(id) = media_id { 396 + rc.media 397 + .stop(dest.as_str(), id) 398 + .map(|_| ()) 399 + .map_err(|e| anyhow!("stop: {}", e)) 400 + } else { 401 + Ok(()) 402 + }; 403 + let _ = reply.send(res); 404 + } 405 + CastCommand::Next(reply) => { 406 + let res = if queue.advance().is_some() { 407 + if let Some(item) = queue.current() { 408 + load_media(rc, session, &item, state) 409 + } else { 410 + Ok(()) 411 + } 412 + } else { 413 + Ok(()) 414 + }; 415 + let _ = reply.send(res); 416 + } 417 + CastCommand::Previous(reply) => { 418 + let res = if queue.back().is_some() { 419 + if let Some(item) = queue.current() { 420 + load_media(rc, session, &item, state) 421 + } else { 422 + Ok(()) 423 + } 424 + } else { 425 + Ok(()) 426 + }; 427 + let _ = reply.send(res); 428 + } 429 + CastCommand::Seek(pos, reply) => { 430 + let sess = session.lock(); 431 + let media_id = sess.media_session_id; 432 + let dest = sess.app_transport.clone(); 433 + drop(sess); 434 + let res = if let Some(id) = media_id { 435 + rc.media 436 + .seek(dest.as_str(), id, Some(pos as f32), None) 437 + .map(|_| ()) 438 + .map_err(|e| anyhow!("seek: {}", e)) 439 + } else { 440 + Ok(()) 441 + }; 442 + let _ = reply.send(res); 443 + } 444 + CastCommand::Volume(v, reply) => { 445 + let res = rc 446 + .receiver 447 + .set_volume(v.clamp(0.0, 1.0)) 448 + .map(|_| ()) 449 + .map_err(|e| anyhow!("volume: {}", e)); 450 + if res.is_ok() { 451 + state.lock().volume = v.clamp(0.0, 1.0); 452 + } 453 + let _ = reply.send(res); 454 + } 455 + CastCommand::Shutdown => { 456 + let sess = session.lock(); 457 + let _ = rc.receiver.stop_app(sess.session_id.as_str()); 458 + } 459 + } 460 + } 461 + 462 + fn load_media( 463 + rc: &RcDevice, 464 + session: &Arc<Mutex<Session>>, 465 + item: &QueueItem, 466 + state: &Arc<Mutex<PlaybackState>>, 467 + ) -> Result<()> { 468 + let media = build_media(item); 469 + let sess = session.lock(); 470 + let dest = sess.app_transport.clone(); 471 + let sid = sess.session_id.clone(); 472 + drop(sess); 473 + let status = rc 474 + .media 475 + .load(dest.as_str(), sid.as_str(), &media) 476 + .map_err(|e| anyhow!("cast load: {}", e))?; 477 + if let Some(entry) = status.entries.first() { 478 + session.lock().media_session_id = Some(entry.media_session_id); 479 + let mut s = state.lock(); 480 + s.duration_secs = entry.media.as_ref().and_then(|m| m.duration).unwrap_or(0.0) as f64; 481 + s.now_playing = Some(item.clone()); 482 + s.status = PlaybackStatus::Playing; 483 + } 484 + Ok(()) 485 + } 486 + 487 + #[async_trait] 488 + impl Renderer for ChromecastRenderer { 489 + fn kind(&self) -> RendererKind { 490 + RendererKind::Chromecast 491 + } 492 + 493 + async fn play(&self, items: Vec<QueueItem>, start_index: usize) -> Result<()> { 494 + self.send(|reply| CastCommand::Play { 495 + items, 496 + start_index, 497 + reply, 498 + }) 499 + .await 500 + } 501 + 502 + async fn enqueue(&self, items: Vec<QueueItem>) -> Result<()> { 503 + self.send(|reply| CastCommand::Enqueue { items, reply }) 504 + .await 505 + } 506 + 507 + async fn play_next(&self, items: Vec<QueueItem>) -> Result<()> { 508 + self.send(|reply| CastCommand::PlayNext { items, reply }) 509 + .await 510 + } 511 + 512 + async fn pause(&self) -> Result<()> { 513 + self.send(CastCommand::Pause).await 514 + } 515 + 516 + async fn resume(&self) -> Result<()> { 517 + self.send(CastCommand::Resume).await 518 + } 519 + 520 + async fn stop(&self) -> Result<()> { 521 + self.send(CastCommand::Stop).await 522 + } 523 + 524 + async fn next(&self) -> Result<()> { 525 + self.send(CastCommand::Next).await 526 + } 527 + 528 + async fn previous(&self) -> Result<()> { 529 + self.send(CastCommand::Previous).await 530 + } 531 + 532 + async fn seek(&self, position_secs: f64) -> Result<()> { 533 + self.send(|reply| CastCommand::Seek(position_secs, reply)) 534 + .await 535 + } 536 + 537 + async fn set_volume(&self, volume: f32) -> Result<()> { 538 + self.send(|reply| CastCommand::Volume(volume, reply)).await 539 + } 540 + 541 + fn state(&self) -> PlaybackState { 542 + self.state.lock().clone() 543 + } 544 + }
+89
crates/fin-player/src/discovery.rs
··· 1 + use std::collections::HashMap; 2 + use std::net::IpAddr; 3 + use std::time::Duration; 4 + 5 + use anyhow::Result; 6 + use mdns_sd::{ServiceDaemon, ServiceEvent}; 7 + use tokio::time::timeout; 8 + 9 + const CAST_SERVICE: &str = "_googlecast._tcp.local."; 10 + 11 + #[derive(Debug, Clone)] 12 + pub struct CastDevice { 13 + pub name: String, 14 + pub model: String, 15 + pub address: IpAddr, 16 + pub port: u16, 17 + pub uuid: String, 18 + } 19 + 20 + impl CastDevice { 21 + pub fn display_name(&self) -> String { 22 + if self.name.is_empty() { 23 + self.model.clone() 24 + } else { 25 + self.name.clone() 26 + } 27 + } 28 + } 29 + 30 + /// Browse the local network for Chromecast-compatible receivers. 31 + pub async fn discover_chromecasts(scan_for: Duration) -> Result<Vec<CastDevice>> { 32 + let mdns = ServiceDaemon::new()?; 33 + let receiver = mdns.browse(CAST_SERVICE)?; 34 + let mut devices: HashMap<String, CastDevice> = HashMap::new(); 35 + 36 + let deadline = tokio::time::Instant::now() + scan_for; 37 + while tokio::time::Instant::now() < deadline { 38 + let remaining = deadline 39 + .saturating_duration_since(tokio::time::Instant::now()) 40 + .min(Duration::from_millis(500)); 41 + let ev = timeout(remaining, async { 42 + loop { 43 + match receiver.recv_async().await { 44 + Ok(ev) => return Some(ev), 45 + Err(_) => return None, 46 + } 47 + } 48 + }) 49 + .await; 50 + let ev = match ev { 51 + Ok(Some(ev)) => ev, 52 + _ => continue, 53 + }; 54 + if let ServiceEvent::ServiceResolved(info) = ev { 55 + let props: HashMap<String, String> = info 56 + .get_properties() 57 + .iter() 58 + .map(|p| (p.key().to_string(), p.val_str().to_string())) 59 + .collect(); 60 + let friendly = props 61 + .get("fn") 62 + .cloned() 63 + .unwrap_or_else(|| info.get_hostname().to_string()); 64 + let model = props.get("md").cloned().unwrap_or_default(); 65 + let uuid = props 66 + .get("id") 67 + .cloned() 68 + .unwrap_or_else(|| info.get_fullname().to_string()); 69 + let Some(addr) = info.get_addresses().iter().next().copied() else { 70 + continue; 71 + }; 72 + let port = info.get_port(); 73 + devices.insert( 74 + uuid.clone(), 75 + CastDevice { 76 + name: friendly, 77 + model, 78 + address: addr, 79 + port, 80 + uuid, 81 + }, 82 + ); 83 + } 84 + } 85 + let _ = mdns.shutdown(); 86 + let mut list: Vec<_> = devices.into_values().collect(); 87 + list.sort_by(|a, b| a.display_name().cmp(&b.display_name())); 88 + Ok(list) 89 + }
+11
crates/fin-player/src/lib.rs
··· 1 + pub mod cast; 2 + pub mod discovery; 3 + pub mod mpv; 4 + pub mod queue; 5 + pub mod renderer; 6 + 7 + pub use cast::ChromecastRenderer; 8 + pub use discovery::{discover_chromecasts, CastDevice}; 9 + pub use mpv::MpvRenderer; 10 + pub use queue::{PlaybackQueue, QueueItem}; 11 + pub use renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind};
+414
crates/fin-player/src/mpv.rs
··· 1 + use std::path::PathBuf; 2 + use std::process::Stdio; 3 + use std::sync::atomic::{AtomicU32, Ordering}; 4 + use std::sync::Arc; 5 + use std::time::Duration; 6 + 7 + use anyhow::{anyhow, Context, Result}; 8 + use async_trait::async_trait; 9 + use parking_lot::Mutex; 10 + use serde_json::{json, Value}; 11 + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; 12 + use tokio::net::UnixStream; 13 + use tokio::process::{Child, Command}; 14 + use tokio::sync::{mpsc, oneshot}; 15 + use tokio::time::sleep; 16 + use tracing::{debug, error, warn}; 17 + 18 + use crate::queue::{PlaybackQueue, QueueItem}; 19 + use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 20 + 21 + /// mpv renderer — spawns a headless mpv (or a windowed one, for video) 22 + /// and drives it over the JSON IPC socket. 23 + pub struct MpvRenderer { 24 + socket_path: PathBuf, 25 + force_window: bool, 26 + queue: PlaybackQueue, 27 + state: Arc<Mutex<PlaybackState>>, 28 + ipc: Arc<Mutex<Option<IpcHandle>>>, 29 + child: Arc<Mutex<Option<Child>>>, 30 + } 31 + 32 + #[derive(Clone)] 33 + struct IpcHandle { 34 + tx: mpsc::UnboundedSender<IpcCommand>, 35 + req_id: Arc<AtomicU32>, 36 + } 37 + 38 + struct IpcCommand { 39 + payload: Value, 40 + reply: Option<oneshot::Sender<Result<Value>>>, 41 + } 42 + 43 + impl MpvRenderer { 44 + pub fn new(socket_path: Option<PathBuf>) -> Self { 45 + let socket_path = socket_path.unwrap_or_else(|| { 46 + let mut p = std::env::temp_dir(); 47 + p.push(format!("fin-mpv-{}.sock", std::process::id())); 48 + p 49 + }); 50 + Self { 51 + socket_path, 52 + force_window: true, 53 + queue: PlaybackQueue::new(), 54 + state: Arc::new(Mutex::new(PlaybackState::default())), 55 + ipc: Arc::new(Mutex::new(None)), 56 + child: Arc::new(Mutex::new(None)), 57 + } 58 + } 59 + 60 + pub fn queue_handle(&self) -> PlaybackQueue { 61 + self.queue.clone() 62 + } 63 + 64 + /// Ensures mpv is spawned and the IPC channel is ready. Idempotent — 65 + /// calling it a second time is a no-op. 66 + async fn ensure_running(&self) -> Result<()> { 67 + if self.ipc.lock().is_some() { 68 + return Ok(()); 69 + } 70 + 71 + // If a stale socket exists, remove it first. 72 + let _ = std::fs::remove_file(&self.socket_path); 73 + 74 + let mut cmd = Command::new("mpv"); 75 + cmd.arg("--idle=yes") 76 + .arg("--no-terminal") 77 + .arg("--force-window=".to_string() + if self.force_window { "immediate" } else { "no" }) 78 + .arg("--keep-open=yes") 79 + .arg("--audio-display=no") 80 + .arg("--input-ipc-server=".to_string() + self.socket_path.to_string_lossy().as_ref()) 81 + .stdin(Stdio::null()) 82 + .stdout(Stdio::null()) 83 + .stderr(Stdio::null()); 84 + 85 + let child = cmd.spawn().context( 86 + "failed to spawn mpv — is it installed? On macOS: `brew install mpv`, on Debian/Ubuntu: `sudo apt install mpv`", 87 + )?; 88 + *self.child.lock() = Some(child); 89 + 90 + // Wait for mpv to create the socket. We poll with a small backoff. 91 + let mut stream = None; 92 + for _ in 0..40 { 93 + match UnixStream::connect(&self.socket_path).await { 94 + Ok(s) => { 95 + stream = Some(s); 96 + break; 97 + } 98 + Err(_) => sleep(Duration::from_millis(75)).await, 99 + } 100 + } 101 + let stream = stream.context("timed out connecting to mpv IPC socket")?; 102 + 103 + let (tx, rx) = mpsc::unbounded_channel::<IpcCommand>(); 104 + let req_id = Arc::new(AtomicU32::new(1)); 105 + let handle = IpcHandle { 106 + tx: tx.clone(), 107 + req_id: req_id.clone(), 108 + }; 109 + *self.ipc.lock() = Some(handle); 110 + 111 + let state = self.state.clone(); 112 + let queue = self.queue.clone(); 113 + tokio::spawn(async move { 114 + if let Err(e) = ipc_loop(stream, rx, state, queue).await { 115 + error!(error=?e, "mpv ipc loop exited"); 116 + } 117 + }); 118 + 119 + // Observe events we care about — send directly on the mpsc, 120 + // bypassing `cmd()` to avoid recursion through `ensure_running`. 121 + for prop in [ 122 + "time-pos", 123 + "duration", 124 + "pause", 125 + "volume", 126 + "playlist-pos", 127 + "eof-reached", 128 + ] { 129 + let id = req_id.fetch_add(1, Ordering::SeqCst); 130 + let _ = tx.send(IpcCommand { 131 + payload: json!({ 132 + "command": ["observe_property", 1, prop], 133 + "request_id": id, 134 + }), 135 + reply: None, 136 + }); 137 + } 138 + Ok(()) 139 + } 140 + 141 + async fn cmd(&self, mut payload: Value) -> Result<Value> { 142 + self.ensure_running().await?; 143 + // Clone the handle out of the lock so we don't hold a MutexGuard 144 + // across the following `.await` (parking_lot guards aren't Send). 145 + let handle: IpcHandle = { 146 + let guard = self.ipc.lock(); 147 + guard 148 + .as_ref() 149 + .cloned() 150 + .ok_or_else(|| anyhow!("mpv not running"))? 151 + }; 152 + let id = handle.req_id.fetch_add(1, Ordering::SeqCst); 153 + if let Value::Object(ref mut m) = payload { 154 + m.insert("request_id".into(), Value::from(id)); 155 + } 156 + let (reply_tx, reply_rx) = oneshot::channel(); 157 + handle 158 + .tx 159 + .send(IpcCommand { 160 + payload, 161 + reply: Some(reply_tx), 162 + }) 163 + .map_err(|_| anyhow!("mpv ipc channel closed"))?; 164 + reply_rx.await.context("mpv ipc reply dropped")? 165 + } 166 + 167 + fn apply_queue_from_local(&self) { 168 + let items = self.queue.items(); 169 + let idx = self.queue.current_index(); 170 + let mut s = self.state.lock(); 171 + s.queue = items.clone(); 172 + s.current_index = idx; 173 + s.now_playing = idx.and_then(|i| items.get(i).cloned()); 174 + } 175 + } 176 + 177 + async fn ipc_loop( 178 + stream: UnixStream, 179 + mut rx: mpsc::UnboundedReceiver<IpcCommand>, 180 + state: Arc<Mutex<PlaybackState>>, 181 + queue: PlaybackQueue, 182 + ) -> Result<()> { 183 + let (reader, mut writer) = stream.into_split(); 184 + let mut lines = BufReader::new(reader).lines(); 185 + let pending: Arc<Mutex<std::collections::HashMap<u32, oneshot::Sender<Result<Value>>>>> = 186 + Arc::new(Mutex::new(Default::default())); 187 + 188 + let pending_read = pending.clone(); 189 + let state_r = state.clone(); 190 + let queue_r = queue.clone(); 191 + let read_task = tokio::spawn(async move { 192 + while let Ok(Some(line)) = lines.next_line().await { 193 + let value: Value = match serde_json::from_str(&line) { 194 + Ok(v) => v, 195 + Err(_) => continue, 196 + }; 197 + if let Some(id) = value.get("request_id").and_then(|v| v.as_u64()) { 198 + let sender = pending_read.lock().remove(&(id as u32)); 199 + if let Some(tx) = sender { 200 + let err = value 201 + .get("error") 202 + .and_then(|v| v.as_str()) 203 + .unwrap_or("success"); 204 + if err == "success" { 205 + let _ = tx.send(Ok(value.get("data").cloned().unwrap_or(Value::Null))); 206 + } else { 207 + let _ = tx.send(Err(anyhow!("mpv ipc error: {}", err))); 208 + } 209 + } 210 + continue; 211 + } 212 + if let Some(event) = value.get("event").and_then(|v| v.as_str()) { 213 + handle_event(event, &value, &state_r, &queue_r); 214 + } 215 + } 216 + }); 217 + 218 + while let Some(cmd) = rx.recv().await { 219 + let id = cmd 220 + .payload 221 + .get("request_id") 222 + .and_then(|v| v.as_u64()) 223 + .unwrap_or(0) as u32; 224 + if let Some(reply) = cmd.reply { 225 + pending.lock().insert(id, reply); 226 + } 227 + let mut serialized = serde_json::to_vec(&cmd.payload)?; 228 + serialized.push(b'\n'); 229 + if let Err(e) = writer.write_all(&serialized).await { 230 + warn!(?e, "mpv ipc write failed"); 231 + break; 232 + } 233 + } 234 + read_task.abort(); 235 + Ok(()) 236 + } 237 + 238 + fn handle_event( 239 + event: &str, 240 + value: &Value, 241 + state: &Arc<Mutex<PlaybackState>>, 242 + queue: &PlaybackQueue, 243 + ) { 244 + match event { 245 + "property-change" => { 246 + let name = value.get("name").and_then(|v| v.as_str()).unwrap_or(""); 247 + let data = value.get("data").cloned().unwrap_or(Value::Null); 248 + let mut s = state.lock(); 249 + match name { 250 + "time-pos" => { 251 + s.position_secs = data.as_f64().unwrap_or(s.position_secs); 252 + } 253 + "duration" => { 254 + s.duration_secs = data.as_f64().unwrap_or(s.duration_secs); 255 + } 256 + "pause" => { 257 + if let Some(p) = data.as_bool() { 258 + s.status = if p { 259 + PlaybackStatus::Paused 260 + } else if s.now_playing.is_some() { 261 + PlaybackStatus::Playing 262 + } else { 263 + PlaybackStatus::Idle 264 + }; 265 + } 266 + } 267 + "volume" => { 268 + if let Some(v) = data.as_f64() { 269 + s.volume = (v / 100.0) as f32; 270 + } 271 + } 272 + _ => {} 273 + } 274 + } 275 + "start-file" => { 276 + let mut s = state.lock(); 277 + s.status = PlaybackStatus::Buffering; 278 + } 279 + "file-loaded" => { 280 + let mut s = state.lock(); 281 + s.status = PlaybackStatus::Playing; 282 + } 283 + "end-file" => { 284 + queue.advance(); 285 + let items = queue.items(); 286 + let idx = queue.current_index(); 287 + let mut s = state.lock(); 288 + s.queue = items.clone(); 289 + s.current_index = idx; 290 + s.now_playing = idx.and_then(|i| items.get(i).cloned()); 291 + if s.now_playing.is_none() { 292 + s.status = PlaybackStatus::Idle; 293 + s.position_secs = 0.0; 294 + s.duration_secs = 0.0; 295 + } 296 + } 297 + "shutdown" => { 298 + let mut s = state.lock(); 299 + s.status = PlaybackStatus::Stopped; 300 + } 301 + _ => { 302 + debug!(event, "mpv event"); 303 + } 304 + } 305 + } 306 + 307 + #[async_trait] 308 + impl Renderer for MpvRenderer { 309 + fn kind(&self) -> RendererKind { 310 + RendererKind::Mpv 311 + } 312 + 313 + async fn play(&self, items: Vec<QueueItem>, start_index: usize) -> Result<()> { 314 + self.ensure_running().await?; 315 + self.queue.replace(items.clone(), start_index); 316 + self.apply_queue_from_local(); 317 + 318 + // Reset mpv's own playlist and load our items. 319 + self.cmd(json!({"command": ["playlist-clear"]})).await.ok(); 320 + self.cmd(json!({"command": ["stop"]})).await.ok(); 321 + 322 + for (i, item) in items.iter().enumerate() { 323 + let mode = if i == 0 { "replace" } else { "append" }; 324 + self.cmd(json!({"command": ["loadfile", item.stream_url, mode]})) 325 + .await?; 326 + } 327 + if start_index > 0 { 328 + self.cmd(json!({"command": ["set_property", "playlist-pos", start_index]})) 329 + .await?; 330 + } 331 + self.cmd(json!({"command": ["set_property", "pause", false]})) 332 + .await?; 333 + Ok(()) 334 + } 335 + 336 + async fn enqueue(&self, items: Vec<QueueItem>) -> Result<()> { 337 + self.ensure_running().await?; 338 + self.queue.append(items.clone()); 339 + self.apply_queue_from_local(); 340 + for item in items { 341 + self.cmd(json!({"command": ["loadfile", item.stream_url, "append-play"]})) 342 + .await?; 343 + } 344 + Ok(()) 345 + } 346 + 347 + async fn play_next(&self, items: Vec<QueueItem>) -> Result<()> { 348 + self.ensure_running().await?; 349 + self.queue.insert_next(items.clone()); 350 + self.apply_queue_from_local(); 351 + // mpv's `loadfile` with `insert-next` is available since mpv 0.38. 352 + for item in items { 353 + self.cmd(json!({"command": ["loadfile", item.stream_url, "insert-next"]})) 354 + .await?; 355 + } 356 + Ok(()) 357 + } 358 + 359 + async fn pause(&self) -> Result<()> { 360 + self.cmd(json!({"command": ["set_property", "pause", true]})) 361 + .await 362 + .map(|_| ()) 363 + } 364 + 365 + async fn resume(&self) -> Result<()> { 366 + self.cmd(json!({"command": ["set_property", "pause", false]})) 367 + .await 368 + .map(|_| ()) 369 + } 370 + 371 + async fn stop(&self) -> Result<()> { 372 + self.queue.clear(); 373 + self.apply_queue_from_local(); 374 + self.cmd(json!({"command": ["stop"]})).await.map(|_| ()) 375 + } 376 + 377 + async fn next(&self) -> Result<()> { 378 + self.cmd(json!({"command": ["playlist-next", "weak"]})) 379 + .await 380 + .map(|_| ()) 381 + } 382 + 383 + async fn previous(&self) -> Result<()> { 384 + self.cmd(json!({"command": ["playlist-prev", "weak"]})) 385 + .await 386 + .map(|_| ()) 387 + } 388 + 389 + async fn seek(&self, position_secs: f64) -> Result<()> { 390 + self.cmd(json!({"command": ["seek", position_secs, "absolute"]})) 391 + .await 392 + .map(|_| ()) 393 + } 394 + 395 + async fn set_volume(&self, volume: f32) -> Result<()> { 396 + let pct = (volume.clamp(0.0, 1.5) * 100.0) as i32; 397 + self.cmd(json!({"command": ["set_property", "volume", pct]})) 398 + .await 399 + .map(|_| ()) 400 + } 401 + 402 + fn state(&self) -> PlaybackState { 403 + self.state.lock().clone() 404 + } 405 + } 406 + 407 + impl Drop for MpvRenderer { 408 + fn drop(&mut self) { 409 + if let Some(mut child) = self.child.lock().take() { 410 + let _ = child.start_kill(); 411 + } 412 + let _ = std::fs::remove_file(&self.socket_path); 413 + } 414 + }
+138
crates/fin-player/src/queue.rs
··· 1 + use std::sync::Arc; 2 + 3 + use parking_lot::RwLock; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + /// Everything a renderer needs to play a single item. 7 + #[derive(Debug, Clone, Serialize, Deserialize)] 8 + pub struct QueueItem { 9 + pub id: String, 10 + pub title: String, 11 + pub subtitle: String, 12 + pub stream_url: String, 13 + pub image_url: Option<String>, 14 + pub duration_secs: Option<u64>, 15 + pub is_video: bool, 16 + pub content_type: String, 17 + } 18 + 19 + /// A thread-safe playback queue. Both the TUI and renderers share the same 20 + /// queue snapshot; the renderer is the source of truth for the current index. 21 + #[derive(Debug, Clone, Default)] 22 + pub struct PlaybackQueue { 23 + inner: Arc<RwLock<QueueInner>>, 24 + } 25 + 26 + #[derive(Debug, Default)] 27 + struct QueueInner { 28 + items: Vec<QueueItem>, 29 + index: Option<usize>, 30 + } 31 + 32 + impl PlaybackQueue { 33 + pub fn new() -> Self { 34 + Self::default() 35 + } 36 + 37 + pub fn items(&self) -> Vec<QueueItem> { 38 + self.inner.read().items.clone() 39 + } 40 + 41 + pub fn current_index(&self) -> Option<usize> { 42 + self.inner.read().index 43 + } 44 + 45 + pub fn current(&self) -> Option<QueueItem> { 46 + let g = self.inner.read(); 47 + g.index.and_then(|i| g.items.get(i).cloned()) 48 + } 49 + 50 + pub fn len(&self) -> usize { 51 + self.inner.read().items.len() 52 + } 53 + 54 + pub fn is_empty(&self) -> bool { 55 + self.inner.read().items.is_empty() 56 + } 57 + 58 + pub fn replace(&self, items: Vec<QueueItem>, index: usize) { 59 + let mut g = self.inner.write(); 60 + g.items = items; 61 + g.index = if g.items.is_empty() { 62 + None 63 + } else { 64 + Some(index.min(g.items.len().saturating_sub(1))) 65 + }; 66 + } 67 + 68 + pub fn append(&self, items: Vec<QueueItem>) { 69 + let mut g = self.inner.write(); 70 + let was_empty = g.items.is_empty(); 71 + g.items.extend(items); 72 + if was_empty && !g.items.is_empty() { 73 + g.index = Some(0); 74 + } 75 + } 76 + 77 + pub fn insert_next(&self, items: Vec<QueueItem>) { 78 + let mut g = self.inner.write(); 79 + let at = g.index.map(|i| i + 1).unwrap_or(0); 80 + for (offset, item) in items.into_iter().enumerate() { 81 + g.items.insert(at + offset, item); 82 + } 83 + if g.index.is_none() && !g.items.is_empty() { 84 + g.index = Some(0); 85 + } 86 + } 87 + 88 + pub fn advance(&self) -> Option<usize> { 89 + let mut g = self.inner.write(); 90 + let next = match g.index { 91 + Some(i) if i + 1 < g.items.len() => Some(i + 1), 92 + None if !g.items.is_empty() => Some(0), 93 + _ => None, 94 + }; 95 + g.index = next; 96 + next 97 + } 98 + 99 + pub fn back(&self) -> Option<usize> { 100 + let mut g = self.inner.write(); 101 + let prev = match g.index { 102 + Some(i) if i > 0 => Some(i - 1), 103 + _ => g.index, 104 + }; 105 + g.index = prev; 106 + prev 107 + } 108 + 109 + pub fn set_index(&self, i: usize) { 110 + let mut g = self.inner.write(); 111 + if i < g.items.len() { 112 + g.index = Some(i); 113 + } 114 + } 115 + 116 + pub fn clear(&self) { 117 + let mut g = self.inner.write(); 118 + g.items.clear(); 119 + g.index = None; 120 + } 121 + 122 + pub fn remove(&self, i: usize) { 123 + let mut g = self.inner.write(); 124 + if i >= g.items.len() { 125 + return; 126 + } 127 + g.items.remove(i); 128 + if g.items.is_empty() { 129 + g.index = None; 130 + } else if let Some(cur) = g.index { 131 + if i < cur { 132 + g.index = Some(cur - 1); 133 + } else if i == cur && cur >= g.items.len() { 134 + g.index = Some(g.items.len() - 1); 135 + } 136 + } 137 + } 138 + }
+82
crates/fin-player/src/renderer.rs
··· 1 + use async_trait::async_trait; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + use crate::queue::QueueItem; 5 + 6 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 7 + #[serde(rename_all = "lowercase")] 8 + pub enum PlaybackStatus { 9 + Idle, 10 + Buffering, 11 + Playing, 12 + Paused, 13 + Stopped, 14 + } 15 + 16 + #[derive(Debug, Clone, Serialize, Deserialize)] 17 + pub struct PlaybackState { 18 + pub status: PlaybackStatus, 19 + pub position_secs: f64, 20 + pub duration_secs: f64, 21 + pub volume: f32, 22 + pub now_playing: Option<QueueItem>, 23 + pub queue: Vec<QueueItem>, 24 + pub current_index: Option<usize>, 25 + } 26 + 27 + impl Default for PlaybackState { 28 + fn default() -> Self { 29 + Self { 30 + status: PlaybackStatus::Idle, 31 + position_secs: 0.0, 32 + duration_secs: 0.0, 33 + volume: 1.0, 34 + now_playing: None, 35 + queue: Vec::new(), 36 + current_index: None, 37 + } 38 + } 39 + } 40 + 41 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 42 + #[serde(rename_all = "lowercase")] 43 + pub enum RendererKind { 44 + Mpv, 45 + Chromecast, 46 + } 47 + 48 + impl RendererKind { 49 + pub fn label(&self) -> &'static str { 50 + match self { 51 + Self::Mpv => "mpv", 52 + Self::Chromecast => "chromecast", 53 + } 54 + } 55 + } 56 + 57 + /// Common interface implemented by both the local mpv renderer and the 58 + /// Chromecast renderer. Every method is async because Chromecast operations 59 + /// round-trip a TLS control connection. 60 + #[async_trait] 61 + pub trait Renderer: Send + Sync { 62 + fn kind(&self) -> RendererKind; 63 + 64 + /// Replace the queue and start playing at `start_index`. 65 + async fn play(&self, items: Vec<QueueItem>, start_index: usize) -> anyhow::Result<()>; 66 + 67 + /// Append items to the current queue without interrupting playback. 68 + async fn enqueue(&self, items: Vec<QueueItem>) -> anyhow::Result<()>; 69 + 70 + /// Insert items at the front of the queue (play next). 71 + async fn play_next(&self, items: Vec<QueueItem>) -> anyhow::Result<()>; 72 + 73 + async fn pause(&self) -> anyhow::Result<()>; 74 + async fn resume(&self) -> anyhow::Result<()>; 75 + async fn stop(&self) -> anyhow::Result<()>; 76 + async fn next(&self) -> anyhow::Result<()>; 77 + async fn previous(&self) -> anyhow::Result<()>; 78 + async fn seek(&self, position_secs: f64) -> anyhow::Result<()>; 79 + async fn set_volume(&self, volume: f32) -> anyhow::Result<()>; 80 + 81 + fn state(&self) -> PlaybackState; 82 + }
+25
crates/fin-tui/Cargo.toml
··· 1 + [package] 2 + name = "fin-tui" 3 + version.workspace = true 4 + edition.workspace = true 5 + authors.workspace = true 6 + license.workspace = true 7 + repository.workspace = true 8 + 9 + [dependencies] 10 + anyhow.workspace = true 11 + thiserror.workspace = true 12 + serde.workspace = true 13 + serde_json.workspace = true 14 + tokio.workspace = true 15 + futures.workspace = true 16 + async-trait.workspace = true 17 + ratatui.workspace = true 18 + crossterm.workspace = true 19 + tracing.workspace = true 20 + unicode-width.workspace = true 21 + parking_lot.workspace = true 22 + chrono.workspace = true 23 + fin-config.workspace = true 24 + fin-jellyfin.workspace = true 25 + fin-player.workspace = true
+1203
crates/fin-tui/src/app.rs
··· 1 + use std::io; 2 + use std::sync::atomic::{AtomicU64, Ordering}; 3 + use std::sync::Arc; 4 + use std::time::Duration; 5 + 6 + use anyhow::{anyhow, Context, Result}; 7 + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 8 + use crossterm::execute; 9 + use crossterm::terminal::{ 10 + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 11 + }; 12 + use parking_lot::Mutex; 13 + use ratatui::backend::CrosstermBackend; 14 + use ratatui::layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}; 15 + use ratatui::style::{Modifier, Style}; 16 + use ratatui::text::{Line, Span}; 17 + use ratatui::widgets::{List, ListItem, ListState, Paragraph, Wrap}; 18 + use ratatui::{Frame, Terminal}; 19 + use tokio::sync::mpsc; 20 + use tracing::warn; 21 + 22 + use fin_config::{Config, RendererPref}; 23 + use fin_jellyfin::{BaseItem, ItemKind, JellyfinClient, StreamFormat}; 24 + use fin_player::{ 25 + discover_chromecasts, CastDevice, ChromecastRenderer, MpvRenderer, PlaybackState, QueueItem, 26 + Renderer, RendererKind, 27 + }; 28 + 29 + use crate::event::{spawn_event_loop, AppEvent}; 30 + use crate::screens::{item_row_line, Screen}; 31 + use crate::theme::{accent_style, base_style, muted_style, title_style, Palette}; 32 + use crate::widgets::{neon_block, NeonTabs, PlayerBar}; 33 + 34 + /// Everything the render loop needs. 35 + pub struct App { 36 + pub config: Arc<Mutex<Config>>, 37 + pub jellyfin: Arc<Mutex<Arc<JellyfinClient>>>, 38 + pub renderer: Arc<Mutex<Arc<dyn Renderer>>>, 39 + pub renderer_kind: Arc<Mutex<RendererKind>>, 40 + pub renderer_label: Arc<Mutex<String>>, 41 + screen: Screen, 42 + // shared display state 43 + home_recent: Arc<Mutex<Vec<BaseItem>>>, 44 + home_resume: Arc<Mutex<Vec<BaseItem>>>, 45 + music_items: Arc<Mutex<Vec<BaseItem>>>, 46 + video_items: Arc<Mutex<Vec<BaseItem>>>, 47 + playlists: Arc<Mutex<Vec<BaseItem>>>, 48 + playlist_items: Arc<Mutex<Vec<BaseItem>>>, 49 + open_playlist: Arc<Mutex<Option<BaseItem>>>, 50 + search_results: Arc<Mutex<Vec<BaseItem>>>, 51 + devices: Arc<Mutex<Vec<CastDevice>>>, 52 + search_generation: Arc<AtomicU64>, 53 + // ephemeral 54 + search_query: String, 55 + search_input_focused: bool, 56 + list_state: ListState, 57 + status_message: Arc<Mutex<Option<String>>>, 58 + playback_state: Arc<Mutex<PlaybackState>>, 59 + should_quit: bool, 60 + logo_pulse: u8, 61 + } 62 + 63 + impl App { 64 + pub fn new(config: Config, jellyfin: JellyfinClient, renderer: Arc<dyn Renderer>) -> Self { 65 + let kind = renderer.kind(); 66 + let label = match kind { 67 + RendererKind::Mpv => "this machine".into(), 68 + RendererKind::Chromecast => "Chromecast".into(), 69 + }; 70 + let mut list_state = ListState::default(); 71 + list_state.select(Some(0)); 72 + Self { 73 + config: Arc::new(Mutex::new(config)), 74 + jellyfin: Arc::new(Mutex::new(Arc::new(jellyfin))), 75 + renderer: Arc::new(Mutex::new(renderer)), 76 + renderer_kind: Arc::new(Mutex::new(kind)), 77 + renderer_label: Arc::new(Mutex::new(label)), 78 + screen: Screen::Home, 79 + home_recent: Arc::new(Mutex::new(vec![])), 80 + home_resume: Arc::new(Mutex::new(vec![])), 81 + music_items: Arc::new(Mutex::new(vec![])), 82 + video_items: Arc::new(Mutex::new(vec![])), 83 + playlists: Arc::new(Mutex::new(vec![])), 84 + playlist_items: Arc::new(Mutex::new(vec![])), 85 + open_playlist: Arc::new(Mutex::new(None)), 86 + search_results: Arc::new(Mutex::new(vec![])), 87 + devices: Arc::new(Mutex::new(vec![])), 88 + search_generation: Arc::new(AtomicU64::new(0)), 89 + search_query: String::new(), 90 + search_input_focused: true, 91 + list_state, 92 + status_message: Arc::new(Mutex::new(None)), 93 + playback_state: Arc::new(Mutex::new(PlaybackState::default())), 94 + should_quit: false, 95 + logo_pulse: 0, 96 + } 97 + } 98 + 99 + /// Handy accessor — the current Jellyfin client. Swapped out atomically 100 + /// when the user switches servers. 101 + fn jf(&self) -> Arc<JellyfinClient> { 102 + self.jellyfin.lock().clone() 103 + } 104 + 105 + fn current_list(&self) -> Vec<BaseItem> { 106 + match self.screen { 107 + Screen::Home => { 108 + let mut v = self.home_resume.lock().clone(); 109 + v.extend(self.home_recent.lock().clone()); 110 + v 111 + } 112 + Screen::Search => self.search_results.lock().clone(), 113 + Screen::Music => self.music_items.lock().clone(), 114 + Screen::Videos => self.video_items.lock().clone(), 115 + Screen::Playlists => { 116 + if self.open_playlist.lock().is_some() { 117 + self.playlist_items.lock().clone() 118 + } else { 119 + self.playlists.lock().clone() 120 + } 121 + } 122 + Screen::Queue => { 123 + let items = self.playback_state.lock().queue.clone(); 124 + items 125 + .into_iter() 126 + .map(|q| BaseItem { 127 + id: q.id, 128 + name: q.title, 129 + type_: if q.is_video { 130 + "Video".into() 131 + } else { 132 + "Audio".into() 133 + }, 134 + album: None, 135 + album_id: None, 136 + album_artist: None, 137 + artists: if q.subtitle.is_empty() { 138 + None 139 + } else { 140 + Some(vec![q.subtitle]) 141 + }, 142 + series_name: None, 143 + production_year: None, 144 + run_time_ticks: q.duration_secs.map(|s| (s * 10_000_000) as i64), 145 + media_type: None, 146 + index_number: None, 147 + parent_index_number: None, 148 + image_tags: None, 149 + is_folder: None, 150 + overview: None, 151 + }) 152 + .collect() 153 + } 154 + Screen::Devices | Screen::Settings => vec![], 155 + } 156 + } 157 + 158 + fn selected_item(&self) -> Option<BaseItem> { 159 + let list = self.current_list(); 160 + self.list_state 161 + .selected() 162 + .and_then(|i| list.get(i).cloned()) 163 + } 164 + 165 + fn set_status(&self, msg: impl Into<String>) { 166 + *self.status_message.lock() = Some(msg.into()); 167 + } 168 + 169 + async fn load_screen(&self) { 170 + match self.screen { 171 + Screen::Home => { 172 + let jf = self.jf(); 173 + let resume = self.home_resume.clone(); 174 + let recent = self.home_recent.clone(); 175 + let status = self.status_message.clone(); 176 + tokio::spawn(async move { 177 + match jf.resume(12).await { 178 + Ok(v) => *resume.lock() = v, 179 + Err(e) => *status.lock() = Some(format!("resume: {}", e)), 180 + } 181 + match jf.latest(None, 24).await { 182 + Ok(v) => *recent.lock() = v, 183 + Err(e) => *status.lock() = Some(format!("latest: {}", e)), 184 + } 185 + }); 186 + } 187 + Screen::Music => { 188 + let jf = self.jf(); 189 + let out = self.music_items.clone(); 190 + let status = self.status_message.clone(); 191 + tokio::spawn(async move { 192 + match jf 193 + .items(None, &["MusicAlbum"], true, Some("SortName"), Some(200)) 194 + .await 195 + { 196 + Ok(v) => *out.lock() = v, 197 + Err(e) => *status.lock() = Some(format!("music: {}", e)), 198 + } 199 + }); 200 + } 201 + Screen::Videos => { 202 + let jf = self.jf(); 203 + let out = self.video_items.clone(); 204 + let status = self.status_message.clone(); 205 + tokio::spawn(async move { 206 + match jf 207 + .items( 208 + None, 209 + &["Movie", "Series"], 210 + true, 211 + Some("SortName"), 212 + Some(200), 213 + ) 214 + .await 215 + { 216 + Ok(v) => *out.lock() = v, 217 + Err(e) => *status.lock() = Some(format!("videos: {}", e)), 218 + } 219 + }); 220 + } 221 + Screen::Playlists => { 222 + let jf = self.jf(); 223 + let out = self.playlists.clone(); 224 + let status = self.status_message.clone(); 225 + tokio::spawn(async move { 226 + match jf.playlists().await { 227 + Ok(v) => *out.lock() = v, 228 + Err(e) => *status.lock() = Some(format!("playlists: {}", e)), 229 + } 230 + }); 231 + } 232 + Screen::Devices => { 233 + let out = self.devices.clone(); 234 + let status = self.status_message.clone(); 235 + tokio::spawn(async move { 236 + *status.lock() = Some("Scanning for Chromecasts…".into()); 237 + match discover_chromecasts(Duration::from_secs(4)).await { 238 + Ok(v) => { 239 + *status.lock() = Some(format!("Found {} device(s).", v.len())); 240 + *out.lock() = v; 241 + } 242 + Err(e) => *status.lock() = Some(format!("scan: {}", e)), 243 + } 244 + }); 245 + } 246 + _ => {} 247 + } 248 + } 249 + 250 + /// fzf-style instant search — fires on every keystroke. 251 + /// Uses a generation counter so a slower response for an older query 252 + /// can never overwrite results from a newer one. 253 + fn run_search(&self) { 254 + let query = self.search_query.trim().to_string(); 255 + let gen = self.search_generation.fetch_add(1, Ordering::SeqCst) + 1; 256 + if query.is_empty() { 257 + self.search_results.lock().clear(); 258 + return; 259 + } 260 + let jf = self.jf(); 261 + let out = self.search_results.clone(); 262 + let status = self.status_message.clone(); 263 + let generation = self.search_generation.clone(); 264 + tokio::spawn(async move { 265 + // small debounce lets fast typists coalesce keystrokes 266 + tokio::time::sleep(Duration::from_millis(90)).await; 267 + if generation.load(Ordering::SeqCst) != gen { 268 + return; 269 + } 270 + match jf 271 + .search( 272 + &query, 273 + &[ 274 + "Audio", 275 + "MusicAlbum", 276 + "MusicArtist", 277 + "Movie", 278 + "Series", 279 + "Episode", 280 + ], 281 + 50, 282 + ) 283 + .await 284 + { 285 + Ok(v) => { 286 + if generation.load(Ordering::SeqCst) == gen { 287 + *status.lock() = Some(format!("{} match(es) for “{}”", v.len(), query)); 288 + *out.lock() = v; 289 + } 290 + } 291 + Err(e) => { 292 + if generation.load(Ordering::SeqCst) == gen { 293 + *status.lock() = Some(format!("search: {}", e)); 294 + } 295 + } 296 + } 297 + }); 298 + } 299 + 300 + fn base_item_to_queue(&self, item: &BaseItem, format: StreamFormat) -> Result<QueueItem> { 301 + let url = self.jf().stream_url(item, format)?; 302 + let is_video = item.kind().is_video(); 303 + let content_type = if is_video { 304 + "video/mp4".to_string() 305 + } else { 306 + "audio/mpeg".to_string() 307 + }; 308 + let image_url = item 309 + .image_tags 310 + .as_ref() 311 + .and_then(|v| v.get("Primary")) 312 + .and_then(|v| v.as_str()) 313 + .map(|tag| self.jf().image_url(&item.id, tag, 480)); 314 + Ok(QueueItem { 315 + id: item.id.clone(), 316 + title: item.name.clone(), 317 + subtitle: item.subtitle(), 318 + stream_url: url, 319 + image_url, 320 + duration_secs: item.duration_secs(), 321 + is_video, 322 + content_type, 323 + }) 324 + } 325 + 326 + async fn expand_playable(&self, item: BaseItem) -> Result<Vec<BaseItem>> { 327 + match item.kind() { 328 + ItemKind::MusicAlbum => { 329 + self.jf() 330 + .items( 331 + Some(&item.id), 332 + &["Audio"], 333 + false, 334 + Some("SortName"), 335 + Some(500), 336 + ) 337 + .await 338 + } 339 + ItemKind::MusicArtist => { 340 + self.jf() 341 + .items(Some(&item.id), &["Audio"], true, Some("Album"), Some(500)) 342 + .await 343 + } 344 + ItemKind::Playlist => self.jf().playlist_items(&item.id).await, 345 + ItemKind::Series => { 346 + self.jf() 347 + .items( 348 + Some(&item.id), 349 + &["Episode"], 350 + true, 351 + Some("SortName"), 352 + Some(500), 353 + ) 354 + .await 355 + } 356 + _ => Ok(vec![item]), 357 + } 358 + } 359 + 360 + async fn play_selected(&mut self, mode: PlayMode) { 361 + let Some(item) = self.selected_item() else { 362 + self.set_status("Nothing selected."); 363 + return; 364 + }; 365 + // If we're in the Playlists screen with no playlist open, opening the 366 + // playlist should load its contents instead of playing. 367 + if self.screen == Screen::Playlists && self.open_playlist.lock().is_none() { 368 + self.open_playlist_selected(item).await; 369 + return; 370 + } 371 + 372 + let items = match self.expand_playable(item).await { 373 + Ok(v) => v, 374 + Err(e) => { 375 + self.set_status(format!("expand: {}", e)); 376 + return; 377 + } 378 + }; 379 + if items.is_empty() { 380 + self.set_status("Nothing playable here."); 381 + return; 382 + } 383 + let format = match *self.renderer_kind.lock() { 384 + RendererKind::Mpv => StreamFormat::Direct, 385 + RendererKind::Chromecast => StreamFormat::Hls, 386 + }; 387 + let mut queue_items = Vec::with_capacity(items.len()); 388 + for it in &items { 389 + match self.base_item_to_queue(it, format) { 390 + Ok(q) => queue_items.push(q), 391 + Err(e) => warn!(?e, "skip item"), 392 + } 393 + } 394 + if queue_items.is_empty() { 395 + self.set_status("No playable stream URLs."); 396 + return; 397 + } 398 + let renderer = self.renderer.lock().clone(); 399 + let title = queue_items 400 + .first() 401 + .map(|q| q.title.clone()) 402 + .unwrap_or_default(); 403 + let count = queue_items.len(); 404 + let result = match mode { 405 + PlayMode::PlayNow => renderer.play(queue_items, 0).await, 406 + PlayMode::Enqueue => renderer.enqueue(queue_items).await, 407 + PlayMode::PlayNext => renderer.play_next(queue_items).await, 408 + }; 409 + match result { 410 + Ok(()) => self.set_status(match mode { 411 + PlayMode::PlayNow => format!("▶ Playing “{}” ({} item(s))", title, count), 412 + PlayMode::Enqueue => format!("+ Queued {} item(s)", count), 413 + PlayMode::PlayNext => format!("↥ Playing next: {} item(s)", count), 414 + }), 415 + Err(e) => self.set_status(format!("renderer: {}", e)), 416 + } 417 + } 418 + 419 + async fn open_playlist_selected(&mut self, item: BaseItem) { 420 + let jf = self.jf(); 421 + let out = self.playlist_items.clone(); 422 + let open = self.open_playlist.clone(); 423 + let status = self.status_message.clone(); 424 + let id = item.id.clone(); 425 + *open.lock() = Some(item); 426 + tokio::spawn(async move { 427 + match jf.playlist_items(&id).await { 428 + Ok(v) => { 429 + *status.lock() = Some(format!("Loaded {} items", v.len())); 430 + *out.lock() = v; 431 + } 432 + Err(e) => *status.lock() = Some(format!("playlist: {}", e)), 433 + } 434 + }); 435 + self.list_state.select(Some(0)); 436 + } 437 + 438 + async fn use_selected_device(&self) -> Result<()> { 439 + let devices = self.devices.lock().clone(); 440 + let Some(sel) = self 441 + .list_state 442 + .selected() 443 + .and_then(|i| devices.get(i).cloned()) 444 + else { 445 + return Err(anyhow!("no device selected")); 446 + }; 447 + self.set_status(format!("Connecting to {}…", sel.display_name())); 448 + let renderer = ChromecastRenderer::connect(sel.clone()).await?; 449 + let arc: Arc<dyn Renderer> = Arc::new(renderer); 450 + *self.renderer.lock() = arc; 451 + *self.renderer_kind.lock() = RendererKind::Chromecast; 452 + *self.renderer_label.lock() = sel.display_name(); 453 + // persist preference 454 + { 455 + let mut cfg = self.config.lock(); 456 + cfg.renderer = RendererPref::Chromecast; 457 + cfg.last_chromecast = Some(sel.display_name()); 458 + let _ = cfg.save(); 459 + } 460 + self.set_status(format!("Streaming to {}.", sel.display_name())); 461 + Ok(()) 462 + } 463 + 464 + /// Switch to a saved server by name — updates config, rebuilds the 465 + /// Jellyfin client, and reloads the current screen. 466 + fn switch_server(&self, name: &str) -> Result<()> { 467 + let server = { 468 + let mut cfg = self.config.lock(); 469 + cfg.switch_to(name)?; 470 + cfg.save()?; 471 + cfg.current().cloned() 472 + }; 473 + let Some(server) = server else { 474 + return Err(anyhow!("no active server after switch")); 475 + }; 476 + let client = JellyfinClient::with_credentials( 477 + &server.url, 478 + &server.device_id, 479 + &server.user_id, 480 + &server.access_token, 481 + )?; 482 + *self.jellyfin.lock() = Arc::new(client); 483 + // Clear cached content — belongs to the *previous* server. 484 + self.home_recent.lock().clear(); 485 + self.home_resume.lock().clear(); 486 + self.music_items.lock().clear(); 487 + self.video_items.lock().clear(); 488 + self.playlists.lock().clear(); 489 + self.playlist_items.lock().clear(); 490 + *self.open_playlist.lock() = None; 491 + self.search_results.lock().clear(); 492 + self.set_status(format!( 493 + "◉ Switched to `{}` ({}, as {})", 494 + server.name, server.url, server.user_name 495 + )); 496 + Ok(()) 497 + } 498 + 499 + fn cycle_server(&self) { 500 + let new_name = { 501 + let mut cfg = self.config.lock(); 502 + cfg.cycle_next().map(|s| s.name.clone()) 503 + }; 504 + let Some(name) = new_name else { 505 + self.set_status("No other server to switch to."); 506 + return; 507 + }; 508 + if let Err(e) = self.switch_server(&name) { 509 + self.set_status(format!("switch: {}", e)); 510 + } 511 + } 512 + 513 + async fn switch_to_mpv(&self) { 514 + let renderer = MpvRenderer::new(None); 515 + let arc: Arc<dyn Renderer> = Arc::new(renderer); 516 + *self.renderer.lock() = arc; 517 + *self.renderer_kind.lock() = RendererKind::Mpv; 518 + *self.renderer_label.lock() = "this machine".into(); 519 + { 520 + let mut cfg = self.config.lock(); 521 + cfg.renderer = RendererPref::Mpv; 522 + let _ = cfg.save(); 523 + } 524 + self.set_status("Streaming to local mpv."); 525 + } 526 + } 527 + 528 + #[derive(Debug, Clone, Copy)] 529 + enum PlayMode { 530 + PlayNow, 531 + Enqueue, 532 + PlayNext, 533 + } 534 + 535 + pub async fn run_tui(app: App) -> Result<()> { 536 + enable_raw_mode()?; 537 + let mut stdout = io::stdout(); 538 + execute!(stdout, EnterAlternateScreen)?; 539 + let backend = CrosstermBackend::new(stdout); 540 + let mut terminal = Terminal::new(backend)?; 541 + 542 + let mut app = app; 543 + let mut events = spawn_event_loop(Duration::from_millis(200)); 544 + app.load_screen().await; 545 + 546 + let result = event_loop(&mut app, &mut terminal, &mut events).await; 547 + 548 + disable_raw_mode()?; 549 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; 550 + terminal.show_cursor()?; 551 + result 552 + } 553 + 554 + async fn event_loop( 555 + app: &mut App, 556 + terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, 557 + events: &mut mpsc::UnboundedReceiver<AppEvent>, 558 + ) -> Result<()> { 559 + while !app.should_quit { 560 + // Refresh live playback state each tick. 561 + *app.playback_state.lock() = app.renderer.lock().state(); 562 + 563 + terminal.draw(|f| draw(f, app))?; 564 + 565 + if let Some(ev) = events.recv().await { 566 + match ev { 567 + AppEvent::Tick => { 568 + app.logo_pulse = app.logo_pulse.wrapping_add(1); 569 + } 570 + AppEvent::Resize(_, _) => {} 571 + AppEvent::Key(k) => handle_key(app, k).await?, 572 + } 573 + } else { 574 + break; 575 + } 576 + } 577 + Ok(()) 578 + } 579 + 580 + async fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> { 581 + // fzf-style search: the input eats printable keys and updates results on 582 + // every keystroke. Arrow keys still navigate the results list; Enter plays 583 + // the highlighted item. 584 + if app.screen == Screen::Search && app.search_input_focused { 585 + match key.code { 586 + KeyCode::Esc => { 587 + app.search_input_focused = false; 588 + } 589 + KeyCode::Enter => { 590 + app.play_selected(PlayMode::PlayNow).await; 591 + } 592 + KeyCode::Down => { 593 + let len = app.current_list().len(); 594 + let i = app.list_state.selected().unwrap_or(0); 595 + if len > 0 { 596 + app.list_state.select(Some((i + 1).min(len - 1))); 597 + } 598 + } 599 + KeyCode::Up => { 600 + let i = app.list_state.selected().unwrap_or(0); 601 + app.list_state.select(Some(i.saturating_sub(1))); 602 + } 603 + KeyCode::Backspace => { 604 + app.search_query.pop(); 605 + app.list_state.select(Some(0)); 606 + app.run_search(); 607 + } 608 + KeyCode::Tab => { 609 + app.search_input_focused = false; 610 + } 611 + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 612 + app.should_quit = true; 613 + } 614 + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { 615 + app.search_query.clear(); 616 + app.run_search(); 617 + } 618 + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { 619 + app.play_selected(PlayMode::Enqueue).await; 620 + } 621 + KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { 622 + app.play_selected(PlayMode::PlayNext).await; 623 + } 624 + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { 625 + app.search_query.push(c); 626 + app.list_state.select(Some(0)); 627 + app.run_search(); 628 + } 629 + _ => {} 630 + } 631 + return Ok(()); 632 + } 633 + 634 + match (key.code, key.modifiers) { 635 + (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { 636 + app.should_quit = true; 637 + } 638 + (KeyCode::Tab, _) | (KeyCode::Right, KeyModifiers::CONTROL) => { 639 + app.screen = app.screen.next(); 640 + app.list_state.select(Some(0)); 641 + app.load_screen().await; 642 + } 643 + (KeyCode::BackTab, _) | (KeyCode::Left, KeyModifiers::CONTROL) => { 644 + app.screen = app.screen.prev(); 645 + app.list_state.select(Some(0)); 646 + app.load_screen().await; 647 + } 648 + (KeyCode::Char('1'), _) => { 649 + app.screen = Screen::Home; 650 + app.list_state.select(Some(0)); 651 + app.load_screen().await; 652 + } 653 + (KeyCode::Char('2'), _) => { 654 + app.screen = Screen::Search; 655 + app.list_state.select(Some(0)); 656 + app.search_input_focused = true; 657 + } 658 + (KeyCode::Char('3'), _) => { 659 + app.screen = Screen::Music; 660 + app.list_state.select(Some(0)); 661 + app.load_screen().await; 662 + } 663 + (KeyCode::Char('4'), _) => { 664 + app.screen = Screen::Videos; 665 + app.list_state.select(Some(0)); 666 + app.load_screen().await; 667 + } 668 + (KeyCode::Char('5'), _) => { 669 + app.screen = Screen::Playlists; 670 + *app.open_playlist.lock() = None; 671 + app.list_state.select(Some(0)); 672 + app.load_screen().await; 673 + } 674 + (KeyCode::Char('6'), _) => { 675 + app.screen = Screen::Queue; 676 + app.list_state.select(Some(0)); 677 + } 678 + (KeyCode::Char('7'), _) => { 679 + app.screen = Screen::Devices; 680 + app.list_state.select(Some(0)); 681 + app.load_screen().await; 682 + } 683 + (KeyCode::Char('8'), _) => { 684 + app.screen = Screen::Settings; 685 + app.list_state.select(Some(0)); 686 + } 687 + (KeyCode::Char('/'), _) => { 688 + app.screen = Screen::Search; 689 + app.search_input_focused = true; 690 + } 691 + (KeyCode::Char('r'), _) => { 692 + app.load_screen().await; 693 + app.set_status("Refreshed."); 694 + } 695 + (KeyCode::Esc, _) => { 696 + if app.screen == Screen::Playlists && app.open_playlist.lock().is_some() { 697 + *app.open_playlist.lock() = None; 698 + app.playlist_items.lock().clear(); 699 + app.list_state.select(Some(0)); 700 + } 701 + } 702 + (KeyCode::Down, _) | (KeyCode::Char('j'), _) => { 703 + let len = app.current_list().len(); 704 + let i = app.list_state.selected().unwrap_or(0); 705 + if len > 0 { 706 + app.list_state.select(Some((i + 1).min(len - 1))); 707 + } 708 + } 709 + (KeyCode::Up, _) | (KeyCode::Char('k'), _) => { 710 + let i = app.list_state.selected().unwrap_or(0); 711 + app.list_state.select(Some(i.saturating_sub(1))); 712 + } 713 + (KeyCode::PageDown, _) => { 714 + let len = app.current_list().len(); 715 + let i = app.list_state.selected().unwrap_or(0); 716 + if len > 0 { 717 + app.list_state.select(Some((i + 10).min(len - 1))); 718 + } 719 + } 720 + (KeyCode::PageUp, _) => { 721 + let i = app.list_state.selected().unwrap_or(0); 722 + app.list_state.select(Some(i.saturating_sub(10))); 723 + } 724 + (KeyCode::Enter, _) => match app.screen { 725 + Screen::Devices => { 726 + if let Err(e) = app.use_selected_device().await { 727 + app.set_status(format!("device: {}", e)); 728 + } 729 + } 730 + Screen::Settings => { 731 + let name = { 732 + let cfg = app.config.lock(); 733 + app.list_state 734 + .selected() 735 + .and_then(|i| cfg.servers.get(i).map(|s| s.name.clone())) 736 + }; 737 + if let Some(name) = name { 738 + if let Err(e) = app.switch_server(&name) { 739 + app.set_status(format!("switch: {}", e)); 740 + } else { 741 + app.load_screen().await; 742 + } 743 + } 744 + } 745 + _ => app.play_selected(PlayMode::PlayNow).await, 746 + }, 747 + (KeyCode::Char('a'), _) => app.play_selected(PlayMode::Enqueue).await, 748 + (KeyCode::Char('n'), _) => app.play_selected(PlayMode::PlayNext).await, 749 + (KeyCode::Char(' '), _) | (KeyCode::Char('p'), _) => { 750 + let renderer = app.renderer.lock().clone(); 751 + let state = renderer.state(); 752 + let _ = match state.status { 753 + fin_player::PlaybackStatus::Playing => renderer.pause().await, 754 + _ => renderer.resume().await, 755 + }; 756 + } 757 + (KeyCode::Char('s'), _) => { 758 + let renderer = app.renderer.lock().clone(); 759 + let _ = renderer.stop().await; 760 + } 761 + (KeyCode::Char('>'), _) | (KeyCode::Char('l'), _) => { 762 + let renderer = app.renderer.lock().clone(); 763 + let _ = renderer.next().await; 764 + } 765 + (KeyCode::Char('<'), _) | (KeyCode::Char('h'), _) => { 766 + let renderer = app.renderer.lock().clone(); 767 + let _ = renderer.previous().await; 768 + } 769 + (KeyCode::Char('+'), _) | (KeyCode::Char('='), _) => { 770 + let renderer = app.renderer.lock().clone(); 771 + let v = renderer.state().volume + 0.05; 772 + let _ = renderer.set_volume(v.min(1.5)).await; 773 + } 774 + (KeyCode::Char('-'), _) | (KeyCode::Char('_'), _) => { 775 + let renderer = app.renderer.lock().clone(); 776 + let v = renderer.state().volume - 0.05; 777 + let _ = renderer.set_volume(v.max(0.0)).await; 778 + } 779 + (KeyCode::Char('m'), _) => { 780 + app.switch_to_mpv().await; 781 + } 782 + (KeyCode::Char('t'), _) => { 783 + app.cycle_server(); 784 + app.load_screen().await; 785 + } 786 + _ => {} 787 + } 788 + Ok(()) 789 + } 790 + 791 + fn draw(f: &mut Frame<'_>, app: &mut App) { 792 + let size = f.area(); 793 + f.render_widget(Paragraph::new("").style(base_style()), size); 794 + let chunks = Layout::default() 795 + .direction(Direction::Vertical) 796 + .constraints([ 797 + Constraint::Length(3), // header 798 + Constraint::Length(3), // tabs 799 + Constraint::Min(4), // main 800 + Constraint::Length(6), // player bar 801 + Constraint::Length(1), // status/help 802 + ]) 803 + .split(size); 804 + 805 + draw_header(f, chunks[0], app); 806 + draw_tabs(f, chunks[1], app); 807 + draw_body(f, chunks[2], app); 808 + draw_player_bar(f, chunks[3], app); 809 + draw_status_bar(f, chunks[4], app); 810 + } 811 + 812 + fn draw_header(f: &mut Frame<'_>, area: Rect, app: &App) { 813 + let pulse = ((app.logo_pulse as f32 * 0.15).sin() * 0.5 + 0.5) * 60.0 + 195.0; 814 + let r = 0u8; 815 + let g = pulse as u8; 816 + let b = (pulse * 0.85) as u8; 817 + let subtitle_col = ratatui::style::Color::Rgb(r, g, b); 818 + let (server, user, server_name, servers_total) = { 819 + let cfg = app.config.lock(); 820 + let cur = cfg.current(); 821 + ( 822 + cur.map(|s| s.url.clone()) 823 + .unwrap_or_else(|| "not logged in".into()), 824 + cur.map(|s| s.user_name.clone()) 825 + .unwrap_or_else(|| "guest".into()), 826 + cur.map(|s| s.name.clone()).unwrap_or_default(), 827 + cfg.servers.len(), 828 + ) 829 + }; 830 + let servers_badge = if servers_total > 1 { 831 + format!(" ⇄ {} servers", servers_total) 832 + } else { 833 + String::new() 834 + }; 835 + let line = Line::from(vec![ 836 + Span::styled( 837 + " ⚡ fin ", 838 + Style::default() 839 + .fg(Palette::PRIMARY) 840 + .add_modifier(Modifier::BOLD), 841 + ), 842 + Span::styled( 843 + "— a neon jellyfin client", 844 + Style::default().fg(subtitle_col), 845 + ), 846 + Span::styled( 847 + format!(" {} ", user), 848 + Style::default() 849 + .fg(Palette::ACCENT) 850 + .add_modifier(Modifier::BOLD), 851 + ), 852 + Span::styled(format!("@ {} ", server), muted_style()), 853 + Span::styled( 854 + if server_name.is_empty() { 855 + String::new() 856 + } else { 857 + format!("[{}]", server_name) 858 + }, 859 + Style::default().fg(Palette::HIGHLIGHT), 860 + ), 861 + Span::styled(servers_badge, Style::default().fg(Palette::SKY)), 862 + ]); 863 + let block = neon_block("", false); 864 + let inner = block.inner(area); 865 + f.render_widget(block, area); 866 + f.render_widget(Paragraph::new(line), inner); 867 + } 868 + 869 + fn draw_tabs(f: &mut Frame<'_>, area: Rect, app: &App) { 870 + let block = neon_block("", false); 871 + let inner = block.inner(area); 872 + f.render_widget(block, area); 873 + let labels: Vec<(&str, &str)> = Screen::ALL.iter().map(|s| (s.icon(), s.label())).collect(); 874 + let selected = Screen::ALL 875 + .iter() 876 + .position(|s| *s == app.screen) 877 + .unwrap_or(0); 878 + f.render_widget(NeonTabs::new(&labels, selected), inner); 879 + } 880 + 881 + fn draw_body(f: &mut Frame<'_>, area: Rect, app: &mut App) { 882 + match app.screen { 883 + Screen::Search => draw_search(f, area, app), 884 + Screen::Devices => draw_devices(f, area, app), 885 + Screen::Settings => draw_settings(f, area, app), 886 + Screen::Playlists if app.open_playlist.lock().is_none() => { 887 + let title = " ▤ Playlists "; 888 + draw_list(f, area, app, title); 889 + } 890 + Screen::Playlists => { 891 + let name = app 892 + .open_playlist 893 + .lock() 894 + .as_ref() 895 + .map(|p| p.name.clone()) 896 + .unwrap_or_default(); 897 + let title = format!(" ▤ {} — {} tracks ", name, app.playlist_items.lock().len()); 898 + draw_list_with_title(f, area, app, &title); 899 + } 900 + Screen::Home => draw_list( 901 + f, 902 + area, 903 + app, 904 + " ◉ Home — Continue Watching / Recently Added ", 905 + ), 906 + Screen::Music => draw_list(f, area, app, " ♪ Music — Albums "), 907 + Screen::Videos => draw_list(f, area, app, " ▶ Videos — Movies & Series "), 908 + Screen::Queue => draw_list(f, area, app, " ≡ Queue "), 909 + } 910 + } 911 + 912 + fn draw_list(f: &mut Frame<'_>, area: Rect, app: &mut App, title: &str) { 913 + draw_list_with_title(f, area, app, title); 914 + } 915 + 916 + fn draw_list_with_title(f: &mut Frame<'_>, area: Rect, app: &mut App, title: &str) { 917 + let items_data = app.current_list(); 918 + let items: Vec<ListItem> = items_data 919 + .iter() 920 + .enumerate() 921 + .map(|(i, it)| ListItem::new(item_row_line(it, Some(i) == app.list_state.selected()))) 922 + .collect(); 923 + if items.is_empty() { 924 + let block = neon_block(title, true); 925 + let inner = block.inner(area); 926 + f.render_widget(block, area); 927 + let msg = match app.screen { 928 + Screen::Queue => "Queue is empty — press Enter on an item to play, `a` to enqueue.", 929 + Screen::Home => "Loading… (press `r` to refresh)", 930 + Screen::Playlists => "No playlists yet.", 931 + _ => "Nothing here.", 932 + }; 933 + f.render_widget( 934 + Paragraph::new(Line::from(Span::styled(msg, muted_style()))) 935 + .alignment(Alignment::Center), 936 + inner.inner(Margin::new(2, 1)), 937 + ); 938 + return; 939 + } 940 + let list = List::new(items) 941 + .block(neon_block(title, true)) 942 + .highlight_style( 943 + Style::default() 944 + .bg(Palette::SURFACE) 945 + .fg(Palette::PRIMARY) 946 + .add_modifier(Modifier::BOLD), 947 + ) 948 + .highlight_symbol(" ▍ "); 949 + f.render_stateful_widget(list, area, &mut app.list_state); 950 + } 951 + 952 + fn draw_search(f: &mut Frame<'_>, area: Rect, app: &mut App) { 953 + let chunks = Layout::default() 954 + .direction(Direction::Vertical) 955 + .constraints([Constraint::Length(3), Constraint::Min(3)]) 956 + .split(area); 957 + let block = neon_block(" ⌕ Search ", app.search_input_focused); 958 + let inner = block.inner(chunks[0]); 959 + f.render_widget(block, chunks[0]); 960 + let indicator = if app.search_input_focused { 961 + Span::styled( 962 + "▍ ", 963 + Style::default() 964 + .fg(Palette::PRIMARY) 965 + .add_modifier(Modifier::BOLD), 966 + ) 967 + } else { 968 + Span::styled(" ", Style::default()) 969 + }; 970 + let line = Line::from(vec![ 971 + indicator, 972 + Span::styled( 973 + if app.search_query.is_empty() { 974 + "type to search music, movies, series…".to_string() 975 + } else { 976 + app.search_query.clone() 977 + }, 978 + if app.search_query.is_empty() { 979 + muted_style() 980 + } else { 981 + Style::default() 982 + .fg(Palette::FG) 983 + .add_modifier(Modifier::BOLD) 984 + }, 985 + ), 986 + Span::styled( 987 + if app.search_input_focused { "▊" } else { "" }, 988 + Style::default() 989 + .fg(Palette::PRIMARY) 990 + .add_modifier(Modifier::SLOW_BLINK), 991 + ), 992 + ]); 993 + f.render_widget(Paragraph::new(line), inner); 994 + draw_list_with_title(f, chunks[1], app, " ⌕ Results "); 995 + } 996 + 997 + fn draw_devices(f: &mut Frame<'_>, area: Rect, app: &mut App) { 998 + let devices = app.devices.lock().clone(); 999 + let items: Vec<ListItem> = devices 1000 + .iter() 1001 + .enumerate() 1002 + .map(|(i, d)| { 1003 + let selected = Some(i) == app.list_state.selected(); 1004 + let icon_col = if selected { 1005 + Palette::PRIMARY 1006 + } else { 1007 + Palette::ACCENT 1008 + }; 1009 + ListItem::new(Line::from(vec![ 1010 + Span::styled( 1011 + " 󰓐 ", 1012 + Style::default().fg(icon_col).add_modifier(Modifier::BOLD), 1013 + ), 1014 + Span::styled( 1015 + d.display_name(), 1016 + Style::default() 1017 + .fg(Palette::FG) 1018 + .add_modifier(Modifier::BOLD), 1019 + ), 1020 + Span::styled( 1021 + format!(" {} ", d.model), 1022 + Style::default().fg(Palette::MUTED), 1023 + ), 1024 + Span::styled( 1025 + format!("{}:{}", d.address, d.port), 1026 + Style::default().fg(Palette::SKY), 1027 + ), 1028 + ])) 1029 + }) 1030 + .collect(); 1031 + let title = " ◈ Chromecast Devices (Enter to select, r to rescan) "; 1032 + if items.is_empty() { 1033 + let block = neon_block(title, true); 1034 + let inner = block.inner(area); 1035 + f.render_widget(block, area); 1036 + f.render_widget( 1037 + Paragraph::new(Line::from(Span::styled( 1038 + "No devices found yet — press `r` to rescan.", 1039 + muted_style(), 1040 + ))) 1041 + .alignment(Alignment::Center), 1042 + inner.inner(Margin::new(2, 1)), 1043 + ); 1044 + return; 1045 + } 1046 + let list = List::new(items) 1047 + .block(neon_block(title, true)) 1048 + .highlight_style( 1049 + Style::default() 1050 + .bg(Palette::SURFACE) 1051 + .fg(Palette::PRIMARY) 1052 + .add_modifier(Modifier::BOLD), 1053 + ) 1054 + .highlight_symbol(" ▍ "); 1055 + f.render_stateful_widget(list, area, &mut app.list_state); 1056 + } 1057 + 1058 + fn draw_settings(f: &mut Frame<'_>, area: Rect, app: &mut App) { 1059 + let cfg_snapshot = app.config.lock().clone(); 1060 + let renderer_pref = cfg_snapshot.renderer.label(); 1061 + let last_cast = cfg_snapshot 1062 + .last_chromecast 1063 + .clone() 1064 + .unwrap_or_else(|| "—".into()); 1065 + let path = fin_config::config_path() 1066 + .map(|p| p.display().to_string()) 1067 + .unwrap_or_default(); 1068 + 1069 + let rows = Layout::default() 1070 + .direction(Direction::Vertical) 1071 + .constraints([Constraint::Length(6), Constraint::Min(4)]) 1072 + .split(area); 1073 + 1074 + // Top card — global settings. 1075 + let lines = vec![ 1076 + Line::from(vec![ 1077 + Span::styled(" Renderer ", title_style()), 1078 + Span::styled(renderer_pref, accent_style()), 1079 + Span::styled( 1080 + " (press m for mpv, 7 → Enter for a chromecast)", 1081 + muted_style(), 1082 + ), 1083 + ]), 1084 + Line::from(vec![ 1085 + Span::styled(" Last Cast ", title_style()), 1086 + Span::styled(last_cast, Style::default().fg(Palette::HIGHLIGHT)), 1087 + ]), 1088 + Line::from(vec![ 1089 + Span::styled(" Config File ", title_style()), 1090 + Span::styled(path, Style::default().fg(Palette::SKY)), 1091 + ]), 1092 + Line::from(Span::styled( 1093 + " All settings are also CLI flags — `fin --help`.", 1094 + muted_style(), 1095 + )), 1096 + ]; 1097 + let block = neon_block(" ⚙ Settings ", false); 1098 + let inner = block.inner(rows[0]); 1099 + f.render_widget(block, rows[0]); 1100 + f.render_widget( 1101 + Paragraph::new(lines).wrap(Wrap { trim: false }), 1102 + inner.inner(Margin::new(2, 0)), 1103 + ); 1104 + 1105 + // Bottom card — interactive server list. Enter switches, `t` cycles. 1106 + let current = cfg_snapshot.current_server.clone().unwrap_or_default(); 1107 + let items: Vec<ListItem> = cfg_snapshot 1108 + .servers 1109 + .iter() 1110 + .map(|s| { 1111 + let is_current = s.name == current; 1112 + let marker = if is_current { " ▍ " } else { " " }; 1113 + let name_style = if is_current { 1114 + Style::default() 1115 + .fg(Palette::PRIMARY) 1116 + .add_modifier(Modifier::BOLD) 1117 + } else { 1118 + Style::default().fg(Palette::FG) 1119 + }; 1120 + ListItem::new(Line::from(vec![ 1121 + Span::styled( 1122 + marker, 1123 + Style::default() 1124 + .fg(Palette::PRIMARY) 1125 + .add_modifier(Modifier::BOLD), 1126 + ), 1127 + Span::styled(format!("{:<18}", s.name), name_style), 1128 + Span::styled(format!(" {} ", s.url), Style::default().fg(Palette::SKY)), 1129 + Span::styled( 1130 + format!("as {}", s.user_name), 1131 + Style::default().fg(Palette::MUTED), 1132 + ), 1133 + ])) 1134 + }) 1135 + .collect(); 1136 + 1137 + let title = format!( 1138 + " ◉ Servers ({} saved — Enter to switch, t to cycle) ", 1139 + cfg_snapshot.servers.len() 1140 + ); 1141 + 1142 + if items.is_empty() { 1143 + let block = neon_block(&title, true); 1144 + let inner = block.inner(rows[1]); 1145 + f.render_widget(block, rows[1]); 1146 + f.render_widget( 1147 + Paragraph::new(Line::from(Span::styled( 1148 + " No servers yet — run `fin login <url>` to add one.", 1149 + muted_style(), 1150 + ))), 1151 + inner.inner(Margin::new(2, 1)), 1152 + ); 1153 + return; 1154 + } 1155 + 1156 + let list = List::new(items) 1157 + .block(neon_block(&title, true)) 1158 + .highlight_style( 1159 + Style::default() 1160 + .bg(Palette::SURFACE) 1161 + .fg(Palette::PRIMARY) 1162 + .add_modifier(Modifier::BOLD), 1163 + ) 1164 + .highlight_symbol(""); 1165 + f.render_stateful_widget(list, rows[1], &mut app.list_state); 1166 + } 1167 + 1168 + fn draw_player_bar(f: &mut Frame<'_>, area: Rect, app: &App) { 1169 + let renderer_kind = *app.renderer_kind.lock(); 1170 + let label = app.renderer_label.lock().clone(); 1171 + let state = app.playback_state.lock().clone(); 1172 + f.render_widget( 1173 + PlayerBar { 1174 + state: &state, 1175 + renderer: renderer_kind, 1176 + renderer_label: &label, 1177 + }, 1178 + area, 1179 + ); 1180 + } 1181 + 1182 + fn draw_status_bar(f: &mut Frame<'_>, area: Rect, app: &App) { 1183 + let msg = app.status_message.lock().clone(); 1184 + let help = " tab: screen ↑↓/jk: nav enter: play/switch a: queue n: next space: pause s: stop </>: skip +/-: vol m: mpv t: next server /: search q: quit "; 1185 + let text = msg.unwrap_or_else(|| help.to_string()); 1186 + f.render_widget( 1187 + Paragraph::new(Line::from(Span::styled( 1188 + format!(" {} ", text), 1189 + Style::default().fg(Palette::MUTED), 1190 + ))) 1191 + .style(Style::default().bg(Palette::SURFACE)), 1192 + area, 1193 + ); 1194 + } 1195 + 1196 + impl App { 1197 + pub fn config_snapshot(&self) -> Config { 1198 + self.config.lock().clone() 1199 + } 1200 + pub fn save_config(&self) -> Result<()> { 1201 + self.config.lock().save().context("saving config") 1202 + } 1203 + }
+56
crates/fin-tui/src/event.rs
··· 1 + use std::time::Duration; 2 + 3 + use anyhow::Result; 4 + use crossterm::event::{Event as CtEvent, EventStream, KeyEvent, KeyEventKind}; 5 + use futures::StreamExt; 6 + use tokio::sync::mpsc; 7 + use tokio::time::interval; 8 + 9 + /// Application-level events driving the render loop. 10 + #[derive(Debug, Clone)] 11 + pub enum AppEvent { 12 + Key(KeyEvent), 13 + Tick, 14 + Resize(u16, u16), 15 + } 16 + 17 + /// Spawn a task that muxes crossterm input events and a periodic tick. 18 + pub fn spawn_event_loop(tick: Duration) -> mpsc::UnboundedReceiver<AppEvent> { 19 + let (tx, rx) = mpsc::unbounded_channel(); 20 + let tx_tick = tx.clone(); 21 + tokio::spawn(async move { 22 + let mut ticker = interval(tick); 23 + loop { 24 + ticker.tick().await; 25 + if tx_tick.send(AppEvent::Tick).is_err() { 26 + break; 27 + } 28 + } 29 + }); 30 + tokio::spawn(async move { 31 + let mut events = EventStream::new(); 32 + while let Some(Ok(ev)) = events.next().await { 33 + let out = match ev { 34 + CtEvent::Key(k) if matches!(k.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { 35 + Some(AppEvent::Key(k)) 36 + } 37 + CtEvent::Resize(w, h) => Some(AppEvent::Resize(w, h)), 38 + _ => None, 39 + }; 40 + if let Some(e) = out { 41 + if tx.send(e).is_err() { 42 + break; 43 + } 44 + } 45 + } 46 + }); 47 + rx 48 + } 49 + 50 + pub async fn drain_all(rx: &mut mpsc::UnboundedReceiver<AppEvent>) -> Result<Vec<AppEvent>> { 51 + let mut out = Vec::new(); 52 + while let Ok(ev) = rx.try_recv() { 53 + out.push(ev); 54 + } 55 + Ok(out) 56 + }
+7
crates/fin-tui/src/lib.rs
··· 1 + pub mod app; 2 + pub mod event; 3 + pub mod screens; 4 + pub mod theme; 5 + pub mod widgets; 6 + 7 + pub use app::{run_tui, App};
+104
crates/fin-tui/src/screens/mod.rs
··· 1 + use ratatui::style::{Modifier, Style}; 2 + use ratatui::text::{Line, Span}; 3 + 4 + use fin_jellyfin::BaseItem; 5 + 6 + use crate::theme::Palette; 7 + 8 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 9 + pub enum Screen { 10 + Home, 11 + Search, 12 + Music, 13 + Videos, 14 + Playlists, 15 + Queue, 16 + Devices, 17 + Settings, 18 + } 19 + 20 + impl Screen { 21 + pub const ALL: &'static [Screen] = &[ 22 + Screen::Home, 23 + Screen::Search, 24 + Screen::Music, 25 + Screen::Videos, 26 + Screen::Playlists, 27 + Screen::Queue, 28 + Screen::Devices, 29 + Screen::Settings, 30 + ]; 31 + 32 + pub fn icon(&self) -> &'static str { 33 + match self { 34 + Screen::Home => "◉", 35 + Screen::Search => "⌕", 36 + Screen::Music => "♪", 37 + Screen::Videos => "▶", 38 + Screen::Playlists => "▤", 39 + Screen::Queue => "≡", 40 + Screen::Devices => "◈", 41 + Screen::Settings => "⚙", 42 + } 43 + } 44 + 45 + pub fn label(&self) -> &'static str { 46 + match self { 47 + Screen::Home => "Home", 48 + Screen::Search => "Search", 49 + Screen::Music => "Music", 50 + Screen::Videos => "Videos", 51 + Screen::Playlists => "Playlists", 52 + Screen::Queue => "Queue", 53 + Screen::Devices => "Devices", 54 + Screen::Settings => "Settings", 55 + } 56 + } 57 + 58 + pub fn next(&self) -> Self { 59 + let i = Self::ALL.iter().position(|s| s == self).unwrap_or(0); 60 + Self::ALL[(i + 1) % Self::ALL.len()] 61 + } 62 + 63 + pub fn prev(&self) -> Self { 64 + let i = Self::ALL.iter().position(|s| s == self).unwrap_or(0); 65 + Self::ALL[(i + Self::ALL.len() - 1) % Self::ALL.len()] 66 + } 67 + } 68 + 69 + pub fn item_row_line<'a>(item: &'a BaseItem, selected: bool) -> Line<'a> { 70 + let icon = item.kind().icon(); 71 + let (icon_col, main_style) = if selected { 72 + ( 73 + Palette::PRIMARY, 74 + Style::default() 75 + .fg(Palette::FG) 76 + .add_modifier(Modifier::BOLD), 77 + ) 78 + } else { 79 + (Palette::ACCENT, Style::default().fg(Palette::FG)) 80 + }; 81 + let sub = item.subtitle(); 82 + let mut spans: Vec<Span<'a>> = vec![ 83 + Span::styled( 84 + format!(" {} ", icon), 85 + Style::default().fg(icon_col).add_modifier(Modifier::BOLD), 86 + ), 87 + Span::styled(item.name.clone(), main_style), 88 + ]; 89 + if !sub.is_empty() { 90 + spans.push(Span::styled( 91 + format!(" {}", sub), 92 + Style::default().fg(Palette::MUTED), 93 + )); 94 + } 95 + if let Some(d) = item.duration_secs() { 96 + let mm = d / 60; 97 + let ss = d % 60; 98 + spans.push(Span::styled( 99 + format!(" {}:{:02}", mm, ss), 100 + Style::default().fg(Palette::SKY), 101 + )); 102 + } 103 + Line::from(spans) 104 + }
+94
crates/fin-tui/src/theme.rs
··· 1 + use ratatui::style::{Color, Modifier, Style}; 2 + 3 + /// Neon-electric palette — matches the pocketenv CLI theme. 4 + pub struct Palette; 5 + 6 + impl Palette { 7 + /// Primary accent — electric teal. 8 + pub const PRIMARY: Color = Color::Rgb(0, 232, 198); 9 + /// Secondary accent — bright cyan. 10 + pub const SECONDARY: Color = Color::Rgb(0, 198, 232); 11 + /// Deep violet neon accent used for selection. 12 + pub const ACCENT: Color = Color::Rgb(130, 100, 255); 13 + /// Vivid mint highlight. 14 + pub const HIGHLIGHT: Color = Color::Rgb(100, 232, 130); 15 + /// Muted foreground for secondary text. 16 + pub const MUTED: Color = Color::Rgb(160, 175, 195); 17 + /// Playful orange used for links / stream indicators. 18 + pub const LINK: Color = Color::Rgb(255, 160, 100); 19 + /// Sky blue. 20 + pub const SKY: Color = Color::Rgb(0, 210, 255); 21 + /// Neon pink used for warnings. 22 + pub const WARN: Color = Color::Rgb(255, 110, 220); 23 + /// Vivid red for errors. 24 + pub const ERROR: Color = Color::Rgb(255, 100, 100); 25 + /// Off-black background. 26 + pub const BG: Color = Color::Rgb(6, 8, 18); 27 + /// Slightly lighter surface for cards. 28 + pub const SURFACE: Color = Color::Rgb(12, 16, 30); 29 + /// Foreground text. 30 + pub const FG: Color = Color::Rgb(224, 235, 255); 31 + } 32 + 33 + pub fn base_style() -> Style { 34 + Style::default().fg(Palette::FG).bg(Palette::BG) 35 + } 36 + 37 + pub fn title_style() -> Style { 38 + Style::default() 39 + .fg(Palette::PRIMARY) 40 + .add_modifier(Modifier::BOLD) 41 + } 42 + 43 + pub fn muted_style() -> Style { 44 + Style::default().fg(Palette::MUTED) 45 + } 46 + 47 + pub fn accent_style() -> Style { 48 + Style::default() 49 + .fg(Palette::ACCENT) 50 + .add_modifier(Modifier::BOLD) 51 + } 52 + 53 + pub fn highlight_style() -> Style { 54 + Style::default() 55 + .fg(Palette::BG) 56 + .bg(Palette::PRIMARY) 57 + .add_modifier(Modifier::BOLD) 58 + } 59 + 60 + pub fn selection_style() -> Style { 61 + Style::default() 62 + .fg(Palette::ACCENT) 63 + .add_modifier(Modifier::BOLD | Modifier::REVERSED) 64 + } 65 + 66 + pub fn tab_active() -> Style { 67 + Style::default() 68 + .fg(Palette::PRIMARY) 69 + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) 70 + } 71 + 72 + pub fn tab_inactive() -> Style { 73 + Style::default().fg(Palette::MUTED) 74 + } 75 + 76 + pub fn border_style(active: bool) -> Style { 77 + if active { 78 + Style::default() 79 + .fg(Palette::PRIMARY) 80 + .add_modifier(Modifier::BOLD) 81 + } else { 82 + Style::default().fg(Palette::MUTED) 83 + } 84 + } 85 + 86 + pub fn status_playing() -> Style { 87 + Style::default() 88 + .fg(Palette::HIGHLIGHT) 89 + .add_modifier(Modifier::BOLD) 90 + } 91 + 92 + pub fn status_paused() -> Style { 93 + Style::default().fg(Palette::WARN) 94 + }
+22
crates/fin-tui/src/widgets/mod.rs
··· 1 + pub mod player_bar; 2 + pub mod tabs; 3 + 4 + pub use player_bar::PlayerBar; 5 + pub use tabs::NeonTabs; 6 + 7 + use ratatui::style::Style; 8 + use ratatui::symbols::border; 9 + use ratatui::widgets::{Block, Borders}; 10 + 11 + use crate::theme::{border_style, title_style, Palette}; 12 + 13 + /// Consistent rounded neon block used across screens. 14 + pub fn neon_block(title: &str, active: bool) -> Block<'_> { 15 + Block::default() 16 + .borders(Borders::ALL) 17 + .border_set(border::ROUNDED) 18 + .border_style(border_style(active)) 19 + .title_style(title_style()) 20 + .style(Style::default().bg(Palette::BG)) 21 + .title(title.to_string()) 22 + }
+178
crates/fin-tui/src/widgets/player_bar.rs
··· 1 + use ratatui::buffer::Buffer; 2 + use ratatui::layout::{Constraint, Direction, Layout, Rect}; 3 + use ratatui::style::{Modifier, Style}; 4 + use ratatui::symbols::border; 5 + use ratatui::text::{Line, Span}; 6 + use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Widget}; 7 + 8 + use fin_player::{PlaybackState, PlaybackStatus, RendererKind}; 9 + 10 + use crate::theme::{border_style, muted_style, title_style, Palette}; 11 + 12 + pub struct PlayerBar<'a> { 13 + pub state: &'a PlaybackState, 14 + pub renderer: RendererKind, 15 + pub renderer_label: &'a str, 16 + } 17 + 18 + fn fmt_time(secs: f64) -> String { 19 + if !secs.is_finite() || secs < 0.0 { 20 + return "0:00".into(); 21 + } 22 + let s = secs as u64; 23 + let (h, rem) = (s / 3600, s % 3600); 24 + let (m, sec) = (rem / 60, rem % 60); 25 + if h > 0 { 26 + format!("{}:{:02}:{:02}", h, m, sec) 27 + } else { 28 + format!("{}:{:02}", m, sec) 29 + } 30 + } 31 + 32 + fn status_icon(status: PlaybackStatus) -> (&'static str, Style) { 33 + match status { 34 + PlaybackStatus::Playing => ( 35 + "▶", 36 + Style::default() 37 + .fg(Palette::HIGHLIGHT) 38 + .add_modifier(Modifier::BOLD), 39 + ), 40 + PlaybackStatus::Paused => ( 41 + "⏸", 42 + Style::default() 43 + .fg(Palette::WARN) 44 + .add_modifier(Modifier::BOLD), 45 + ), 46 + PlaybackStatus::Buffering => ( 47 + "⋯", 48 + Style::default() 49 + .fg(Palette::SKY) 50 + .add_modifier(Modifier::BOLD), 51 + ), 52 + PlaybackStatus::Stopped | PlaybackStatus::Idle => ( 53 + "⏹", 54 + Style::default() 55 + .fg(Palette::MUTED) 56 + .add_modifier(Modifier::BOLD), 57 + ), 58 + } 59 + } 60 + 61 + impl<'a> Widget for PlayerBar<'a> { 62 + fn render(self, area: Rect, buf: &mut Buffer) { 63 + let block = Block::default() 64 + .borders(Borders::ALL) 65 + .border_set(border::ROUNDED) 66 + .border_style(border_style(true)) 67 + .title_style(title_style()) 68 + .style(Style::default().bg(Palette::BG)) 69 + .title(" ⚡ Now Playing "); 70 + let inner = block.inner(area); 71 + block.render(area, buf); 72 + 73 + let rows = Layout::default() 74 + .direction(Direction::Vertical) 75 + .constraints([ 76 + Constraint::Length(1), 77 + Constraint::Length(1), 78 + Constraint::Min(1), 79 + ]) 80 + .split(inner); 81 + 82 + let (icon, icon_style) = status_icon(self.state.status); 83 + let renderer_icon = match self.renderer { 84 + RendererKind::Mpv => "󰐋 mpv", 85 + RendererKind::Chromecast => "󰓐 chromecast", 86 + }; 87 + let (title_text, subtitle_text) = match &self.state.now_playing { 88 + Some(item) => (item.title.clone(), item.subtitle.clone()), 89 + None => ("Nothing playing".to_string(), String::new()), 90 + }; 91 + 92 + // Row 1: status icon + track title + right-aligned renderer + volume 93 + let title_line = Line::from(vec![ 94 + Span::styled(format!("{} ", icon), icon_style), 95 + Span::styled( 96 + title_text, 97 + Style::default() 98 + .fg(Palette::FG) 99 + .add_modifier(Modifier::BOLD), 100 + ), 101 + ]); 102 + let right_line = Line::from(vec![ 103 + Span::styled( 104 + format!("♪ {}% ", (self.state.volume * 100.0) as i32), 105 + Style::default().fg(Palette::HIGHLIGHT), 106 + ), 107 + Span::styled( 108 + format!("{} ", renderer_icon), 109 + Style::default() 110 + .fg(Palette::ACCENT) 111 + .add_modifier(Modifier::BOLD), 112 + ), 113 + Span::styled( 114 + format!("on {}", self.renderer_label), 115 + Style::default().fg(Palette::MUTED), 116 + ), 117 + ]); 118 + let cols = Layout::default() 119 + .direction(Direction::Horizontal) 120 + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) 121 + .split(rows[0]); 122 + Paragraph::new(title_line).render(cols[0], buf); 123 + Paragraph::new(right_line) 124 + .alignment(ratatui::layout::Alignment::Right) 125 + .render(cols[1], buf); 126 + 127 + // Row 2: subtitle 128 + let sub_line = Line::from(vec![Span::styled(subtitle_text, muted_style())]); 129 + Paragraph::new(sub_line).render(rows[1], buf); 130 + 131 + // Row 3: progress bar with elapsed/total on either side 132 + let cols2 = Layout::default() 133 + .direction(Direction::Horizontal) 134 + .constraints([ 135 + Constraint::Length(8), 136 + Constraint::Min(10), 137 + Constraint::Length(8), 138 + ]) 139 + .split(rows[2]); 140 + let elapsed = fmt_time(self.state.position_secs); 141 + let total = if self.state.duration_secs > 0.0 { 142 + fmt_time(self.state.duration_secs) 143 + } else if let Some(item) = &self.state.now_playing { 144 + item.duration_secs 145 + .map(|s| fmt_time(s as f64)) 146 + .unwrap_or_else(|| "--:--".into()) 147 + } else { 148 + "--:--".into() 149 + }; 150 + Paragraph::new(Line::from(Span::styled( 151 + elapsed, 152 + Style::default().fg(Palette::SKY), 153 + ))) 154 + .render(cols2[0], buf); 155 + let ratio = if self.state.duration_secs > 0.0 { 156 + (self.state.position_secs / self.state.duration_secs).clamp(0.0, 1.0) 157 + } else { 158 + 0.0 159 + }; 160 + Gauge::default() 161 + .gauge_style( 162 + Style::default() 163 + .fg(Palette::PRIMARY) 164 + .bg(Palette::SURFACE) 165 + .add_modifier(Modifier::BOLD), 166 + ) 167 + .ratio(ratio) 168 + .label("") 169 + .use_unicode(true) 170 + .render(cols2[1], buf); 171 + Paragraph::new(Line::from(Span::styled( 172 + total, 173 + Style::default().fg(Palette::SKY), 174 + ))) 175 + .alignment(ratatui::layout::Alignment::Right) 176 + .render(cols2[2], buf); 177 + } 178 + }
+56
crates/fin-tui/src/widgets/tabs.rs
··· 1 + use ratatui::buffer::Buffer; 2 + use ratatui::layout::Rect; 3 + use ratatui::style::{Color, Modifier, Style}; 4 + use ratatui::text::{Line, Span}; 5 + use ratatui::widgets::Widget; 6 + 7 + use crate::theme::Palette; 8 + 9 + pub struct NeonTabs<'a> { 10 + labels: &'a [(&'a str, &'a str)], // (icon, label) 11 + selected: usize, 12 + } 13 + 14 + impl<'a> NeonTabs<'a> { 15 + pub fn new(labels: &'a [(&'a str, &'a str)], selected: usize) -> Self { 16 + Self { labels, selected } 17 + } 18 + } 19 + 20 + impl<'a> Widget for NeonTabs<'a> { 21 + fn render(self, area: Rect, buf: &mut Buffer) { 22 + let mut spans: Vec<Span<'_>> = Vec::new(); 23 + for (i, (icon, label)) in self.labels.iter().enumerate() { 24 + let is_sel = i == self.selected; 25 + let bracket = if is_sel { "▍" } else { " " }; 26 + let bracket_col = if is_sel { 27 + Palette::PRIMARY 28 + } else { 29 + Palette::BG 30 + }; 31 + spans.push(Span::styled(bracket, Style::default().fg(bracket_col))); 32 + let (icon_col, text_style) = if is_sel { 33 + ( 34 + Palette::PRIMARY, 35 + Style::default() 36 + .fg(Palette::FG) 37 + .add_modifier(Modifier::BOLD), 38 + ) 39 + } else { 40 + (Palette::MUTED, Style::default().fg(Palette::MUTED)) 41 + }; 42 + spans.push(Span::styled( 43 + format!(" {} ", icon), 44 + Style::default().fg(icon_col).add_modifier(Modifier::BOLD), 45 + )); 46 + spans.push(Span::styled(format!("{} ", label), text_style)); 47 + if i + 1 < self.labels.len() { 48 + spans.push(Span::styled( 49 + "│ ", 50 + Style::default().fg(Color::Rgb(30, 40, 60)), 51 + )); 52 + } 53 + } 54 + Line::from(spans).render(area, buf); 55 + } 56 + }
+28
crates/fin/Cargo.toml
··· 1 + [package] 2 + name = "fin" 3 + version.workspace = true 4 + edition.workspace = true 5 + authors.workspace = true 6 + license.workspace = true 7 + repository.workspace = true 8 + description = "A neon-electric TUI Jellyfin client for mpv & Chromecast." 9 + 10 + [[bin]] 11 + name = "fin" 12 + path = "src/main.rs" 13 + 14 + [dependencies] 15 + anyhow.workspace = true 16 + clap.workspace = true 17 + tokio.workspace = true 18 + serde.workspace = true 19 + serde_json.workspace = true 20 + tracing.workspace = true 21 + tracing-subscriber.workspace = true 22 + directories.workspace = true 23 + toml.workspace = true 24 + rpassword.workspace = true 25 + fin-config.workspace = true 26 + fin-jellyfin.workspace = true 27 + fin-player.workspace = true 28 + fin-tui.workspace = true
+243
crates/fin/src/cli.rs
··· 1 + use clap::builder::styling::{AnsiColor, Color, RgbColor, Style, Styles}; 2 + use clap::{Args, Parser, Subcommand, ValueEnum}; 3 + use fin_config::RendererPref; 4 + 5 + /// Neon-electric palette applied to clap's help & error output. 6 + /// Mirrors the ratatui theme in `fin_tui::theme`. 7 + pub fn neon_styles() -> Styles { 8 + let primary = Color::Rgb(RgbColor(0, 232, 198)); // electric teal — section titles 9 + let sky = Color::Rgb(RgbColor(0, 210, 255)); // sky blue — flag literals 10 + let accent = Color::Rgb(RgbColor(130, 100, 255)); // violet — "Usage:" line 11 + let highlight = Color::Rgb(RgbColor(100, 232, 130)); // mint — valid values 12 + let link = Color::Rgb(RgbColor(255, 160, 100)); // orange — <PLACEHOLDER> tokens 13 + let error = Color::Rgb(RgbColor(255, 100, 100)); // red — "error:" prefix 14 + 15 + Styles::styled() 16 + .header(Style::new().bold().underline().fg_color(Some(primary))) 17 + .usage(Style::new().bold().fg_color(Some(accent))) 18 + .literal(Style::new().bold().fg_color(Some(sky))) 19 + .placeholder(Style::new().fg_color(Some(link))) 20 + .valid(Style::new().bold().fg_color(Some(highlight))) 21 + .invalid( 22 + Style::new() 23 + .bold() 24 + .fg_color(Some(Color::Ansi(AnsiColor::Yellow))), 25 + ) 26 + .error(Style::new().bold().fg_color(Some(error))) 27 + } 28 + 29 + /// A clap-friendly mirror of `fin_config::RendererPref`. Kept out of 30 + /// `fin-config` so that library doesn't pull in clap. 31 + #[derive(Debug, Clone, Copy, ValueEnum)] 32 + pub enum RendererArg { 33 + Mpv, 34 + Chromecast, 35 + } 36 + 37 + impl From<RendererArg> for RendererPref { 38 + fn from(a: RendererArg) -> Self { 39 + match a { 40 + RendererArg::Mpv => RendererPref::Mpv, 41 + RendererArg::Chromecast => RendererPref::Chromecast, 42 + } 43 + } 44 + } 45 + 46 + /// fin — a neon-electric TUI Jellyfin client for mpv & Chromecast. 47 + /// 48 + /// Every setting is exposed as both a CLI flag and a TOML key: 49 + /// 50 + /// fin --server https://media.example --renderer chromecast --chromecast "Living Room" 51 + /// 52 + /// or persist the equivalent settings in the config file (see `fin config --path`). 53 + #[derive(Debug, Parser, Clone)] 54 + #[command( 55 + author, 56 + version, 57 + about, 58 + long_about, 59 + propagate_version = true, 60 + arg_required_else_help = false, 61 + styles = neon_styles(), 62 + )] 63 + pub struct Cli { 64 + /// Increase log verbosity (-v, -vv). 65 + #[arg(short, long, action = clap::ArgAction::Count, global = true)] 66 + pub verbose: u8, 67 + 68 + /// Jellyfin server URL. Overrides the TOML `server.url`. 69 + #[arg(long, global = true, env = "FIN_SERVER")] 70 + pub server: Option<String>, 71 + 72 + /// Use a saved server by its name (see `fin server`). Overrides `current_server`. 73 + #[arg(long = "server-name", global = true, env = "FIN_SERVER_NAME")] 74 + pub server_name: Option<String>, 75 + 76 + /// Access token (skip `login` if you already have one). Overrides `server.access_token`. 77 + #[arg(long, global = true, env = "FIN_TOKEN")] 78 + pub token: Option<String>, 79 + 80 + /// User id. Overrides `server.user_id`. 81 + #[arg(long, global = true, env = "FIN_USER_ID")] 82 + pub user_id: Option<String>, 83 + 84 + /// User name (display only). Overrides `server.user_name`. 85 + #[arg(long, global = true)] 86 + pub user_name: Option<String>, 87 + 88 + /// Client device id. Overrides `server.device_id`. 89 + #[arg(long, global = true, env = "FIN_DEVICE_ID")] 90 + pub device_id: Option<String>, 91 + 92 + /// Renderer to use. Advanced form; usually `--mpv` / `--chromecast NAME` is enough. 93 + #[arg(long, global = true, value_enum, env = "FIN_RENDERER")] 94 + pub renderer: Option<RendererArg>, 95 + 96 + /// Force the local mpv renderer. 97 + #[arg(long, global = true, conflicts_with_all = ["chromecast", "renderer"])] 98 + pub mpv: bool, 99 + 100 + /// Cast to a Chromecast by display name. Implies `--renderer chromecast`. 101 + /// Pass an empty string to auto-pick the first device found. 102 + #[arg(long, global = true, env = "FIN_CHROMECAST", num_args = 0..=1, default_missing_value = "")] 103 + pub chromecast: Option<String>, 104 + 105 + #[command(subcommand)] 106 + pub command: Option<Command>, 107 + } 108 + 109 + #[derive(Debug, Subcommand, Clone)] 110 + pub enum Command { 111 + /// (default) launch the neon TUI. 112 + Tui, 113 + 114 + /// Authenticate against a Jellyfin server and save credentials. 115 + /// Servers are stored by name — pass `--name` to keep more than one. 116 + Login { 117 + /// Server URL — e.g. https://media.example 118 + url: String, 119 + /// Username (prompted if omitted). 120 + #[arg(long, short = 'u')] 121 + username: Option<String>, 122 + /// Password (prompted if omitted, hidden while typing). 123 + #[arg(long, short = 'p')] 124 + password: Option<String>, 125 + /// Short name for this server. Defaults to the URL host 126 + /// (e.g. `media.example.com`). Reusing an existing name updates it. 127 + #[arg(long, short = 'n')] 128 + name: Option<String>, 129 + }, 130 + 131 + /// Remove a saved server. If no `--name` is given, the current server is removed. 132 + Logout { 133 + #[arg(long, short = 'n')] 134 + name: Option<String>, 135 + }, 136 + 137 + /// Manage saved Jellyfin servers. 138 + #[command(subcommand)] 139 + Server(ServerCmd), 140 + 141 + /// Search the server from the shell. 142 + Search { 143 + /// Query string. 144 + query: String, 145 + /// Restrict to a kind: audio | music | video | movies (default: all). 146 + #[arg(long, short = 'k')] 147 + kind: Option<String>, 148 + /// Max results. 149 + #[arg(long, default_value_t = 25)] 150 + limit: u32, 151 + }, 152 + 153 + /// Search + play the first hit on the configured renderer. 154 + Play(PlayArgs), 155 + 156 + /// Search + append to the current renderer queue. 157 + Queue(PlayArgs), 158 + 159 + /// List Chromecasts on the local network. 160 + Devices { 161 + /// mDNS scan duration in seconds. 162 + #[arg(long, default_value_t = 4)] 163 + scan_seconds: u64, 164 + }, 165 + 166 + /// List playlists, or dump one when given `--list <id>`. 167 + Playlists { 168 + /// Playlist id to dump. 169 + #[arg(long)] 170 + list: Option<String>, 171 + }, 172 + 173 + /// Inspect or locate the config file. 174 + Config { 175 + /// Print the current config. 176 + #[arg(long)] 177 + show: bool, 178 + /// Print the config file path. 179 + #[arg(long)] 180 + path: bool, 181 + }, 182 + } 183 + 184 + #[derive(Debug, Subcommand, Clone)] 185 + pub enum ServerCmd { 186 + /// List all saved servers. The current one is marked ▍. 187 + #[command(alias = "ls")] 188 + List, 189 + /// Switch the active server by name. 190 + Switch { 191 + /// Server name (see `fin server list`). 192 + name: String, 193 + }, 194 + /// Remove a saved server. 195 + #[command(alias = "remove")] 196 + Rm { 197 + /// Server name (see `fin server list`). 198 + name: String, 199 + }, 200 + /// Rename a saved server. 201 + Rename { 202 + /// Old name. 203 + from: String, 204 + /// New name. 205 + to: String, 206 + }, 207 + } 208 + 209 + #[derive(Debug, Args, Clone)] 210 + pub struct PlayArgs { 211 + /// Search query. The first hit wins. 212 + pub query: String, 213 + /// Restrict to a kind: audio | music | video | movies. 214 + #[arg(long, short = 'k')] 215 + pub kind: Option<String>, 216 + /// Force HLS transcoded stream (default for Chromecast). 217 + #[arg(long)] 218 + pub hls: bool, 219 + } 220 + 221 + // `Queue` reuses `PlayArgs` semantics. Introducing a distinct type keeps the 222 + // derive-based subcommand parsing simple. 223 + #[derive(Debug, Args, Clone)] 224 + pub struct QueueArgs { 225 + /// Search query. The first hit wins. 226 + pub query: String, 227 + /// Restrict to a kind: audio | music | video | movies. 228 + #[arg(long, short = 'k')] 229 + pub kind: Option<String>, 230 + /// Force HLS transcoded stream (default for Chromecast). 231 + #[arg(long)] 232 + pub hls: bool, 233 + } 234 + 235 + impl From<PlayArgs> for QueueArgs { 236 + fn from(p: PlayArgs) -> Self { 237 + Self { 238 + query: p.query, 239 + kind: p.kind, 240 + hls: p.hls, 241 + } 242 + } 243 + }
+478
crates/fin/src/main.rs
··· 1 + mod cli; 2 + mod preflight; 3 + 4 + use std::io::{self, Write}; 5 + use std::sync::Arc; 6 + use std::time::Duration; 7 + 8 + use anyhow::{Context, Result}; 9 + use clap::Parser; 10 + use fin_config::{derive_server_name, Config, RendererPref, ServerConfig}; 11 + use fin_jellyfin::{ItemKind, JellyfinClient, StreamFormat}; 12 + use fin_player::{ 13 + discover_chromecasts, CastDevice, ChromecastRenderer, MpvRenderer, QueueItem, Renderer, 14 + RendererKind, 15 + }; 16 + use fin_tui::{run_tui, App}; 17 + use tracing_subscriber::EnvFilter; 18 + 19 + use crate::cli::{Cli, Command, PlayArgs, ServerCmd}; 20 + 21 + #[tokio::main] 22 + async fn main() -> Result<()> { 23 + let cli = Cli::parse(); 24 + init_tracing(cli.verbose); 25 + 26 + // preflight: mpv must be installed no matter what renderer the user picks 27 + preflight::ensure_mpv()?; 28 + 29 + // Merge CLI flags into the on-disk config so any setting can be overridden inline. 30 + let mut config = load_and_merge(&cli)?; 31 + 32 + match cli.command.clone().unwrap_or(Command::Tui) { 33 + Command::Tui => run_tui_cmd(config).await, 34 + Command::Login { 35 + url, 36 + username, 37 + password, 38 + name, 39 + } => cmd_login(&mut config, url, username, password, name).await, 40 + Command::Logout { name } => cmd_logout(&mut config, name), 41 + Command::Server(cmd) => cmd_server(&mut config, cmd), 42 + Command::Search { query, kind, limit } => cmd_search(&config, &query, kind, limit).await, 43 + Command::Play(args) => cmd_play(&config, args, false).await, 44 + Command::Queue(args) => cmd_play(&config, args, true).await, 45 + Command::Devices { scan_seconds } => cmd_devices(scan_seconds).await, 46 + Command::Playlists { list } => cmd_playlists(&config, list).await, 47 + Command::Config { show, path } => cmd_config(&config, show, path), 48 + } 49 + } 50 + 51 + fn init_tracing(verbose: u8) { 52 + let level = match verbose { 53 + 0 => "warn,fin=info", 54 + 1 => "info,fin=debug", 55 + _ => "debug", 56 + }; 57 + let _ = tracing_subscriber::fmt() 58 + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| level.into())) 59 + .with_target(false) 60 + .with_writer(io::stderr) 61 + .try_init(); 62 + } 63 + 64 + fn load_and_merge(cli: &Cli) -> Result<Config> { 65 + let mut cfg = Config::load()?; 66 + 67 + // 1) Pick which server this invocation should use. 68 + if let Some(name) = cli.server_name.as_deref() { 69 + // Non-fatal: if the name doesn't exist yet, we let the actual 70 + // command (e.g. `login`) create it. 71 + if cfg.find_server(name).is_some() { 72 + cfg.switch_to(name)?; 73 + } else { 74 + cfg.current_server = Some(name.to_string()); 75 + } 76 + } 77 + 78 + // 2) Layer inline flag overrides onto the *current* server, if any. 79 + // `--server URL` on its own (no `--server-name`) points to an 80 + // ephemeral server used for this run only, so we upsert one under 81 + // the URL host as its name. 82 + if let Some(url) = &cli.server { 83 + let name = cli 84 + .server_name 85 + .clone() 86 + .unwrap_or_else(|| derive_server_name(url)); 87 + let existing = cfg.find_server(&name).cloned(); 88 + let merged = ServerConfig { 89 + name: name.clone(), 90 + url: url.clone(), 91 + user_id: cli 92 + .user_id 93 + .clone() 94 + .or_else(|| existing.as_ref().map(|s| s.user_id.clone())) 95 + .unwrap_or_default(), 96 + user_name: cli 97 + .user_name 98 + .clone() 99 + .or_else(|| existing.as_ref().map(|s| s.user_name.clone())) 100 + .unwrap_or_default(), 101 + access_token: cli 102 + .token 103 + .clone() 104 + .or_else(|| existing.as_ref().map(|s| s.access_token.clone())) 105 + .unwrap_or_default(), 106 + device_id: cli 107 + .device_id 108 + .clone() 109 + .or_else(|| existing.as_ref().map(|s| s.device_id.clone())) 110 + .unwrap_or_default(), 111 + }; 112 + cfg.add_or_update_server(merged); 113 + } else { 114 + // No URL override — still let per-field flags patch the current server. 115 + if let Some(cur_name) = cfg.current_server.clone() { 116 + if let Some(server) = cfg.servers.iter_mut().find(|s| s.name == cur_name) { 117 + if let Some(t) = &cli.token { 118 + server.access_token = t.clone(); 119 + } 120 + if let Some(u) = &cli.user_id { 121 + server.user_id = u.clone(); 122 + } 123 + if let Some(u) = &cli.user_name { 124 + server.user_name = u.clone(); 125 + } 126 + if let Some(d) = &cli.device_id { 127 + server.device_id = d.clone(); 128 + } 129 + } 130 + } 131 + } 132 + 133 + // Shortcut flags win over `--renderer` and the on-disk pref. 134 + // Default (when nothing is set) → mpv. 135 + if cli.mpv { 136 + cfg.renderer = RendererPref::Mpv; 137 + } else if let Some(name) = &cli.chromecast { 138 + cfg.renderer = RendererPref::Chromecast; 139 + if !name.is_empty() { 140 + cfg.last_chromecast = Some(name.clone()); 141 + } 142 + } else if let Some(r) = cli.renderer { 143 + cfg.renderer = r.into(); 144 + } 145 + Ok(cfg) 146 + } 147 + 148 + fn make_client(cfg: &Config) -> Result<JellyfinClient> { 149 + let server = cfg.require_current()?; 150 + JellyfinClient::with_credentials( 151 + &server.url, 152 + &server.device_id, 153 + &server.user_id, 154 + &server.access_token, 155 + ) 156 + } 157 + 158 + async fn build_renderer(cfg: &Config) -> Result<(Arc<dyn Renderer>, String)> { 159 + match cfg.renderer { 160 + RendererPref::Mpv => { 161 + let r = MpvRenderer::new(None); 162 + Ok((Arc::new(r), "this machine".to_string())) 163 + } 164 + RendererPref::Chromecast => { 165 + // Try to reconnect to the previously used device, otherwise 166 + // pick the first one we discover. 167 + let devices = discover_chromecasts(Duration::from_secs(3)).await?; 168 + let target = pick_chromecast(&devices, cfg.last_chromecast.as_deref())?; 169 + let name = target.display_name(); 170 + let r = ChromecastRenderer::connect(target).await?; 171 + Ok((Arc::new(r), name)) 172 + } 173 + } 174 + } 175 + 176 + fn pick_chromecast(devices: &[CastDevice], preferred: Option<&str>) -> Result<CastDevice> { 177 + if devices.is_empty() { 178 + anyhow::bail!("no Chromecasts found on the local network"); 179 + } 180 + if let Some(name) = preferred { 181 + if let Some(d) = devices 182 + .iter() 183 + .find(|d| d.display_name().eq_ignore_ascii_case(name)) 184 + { 185 + return Ok(d.clone()); 186 + } 187 + } 188 + Ok(devices[0].clone()) 189 + } 190 + 191 + async fn run_tui_cmd(cfg: Config) -> Result<()> { 192 + let client = make_client(&cfg)?; 193 + let (renderer, _) = build_renderer(&cfg).await?; 194 + let app = App::new(cfg, client, renderer); 195 + run_tui(app).await 196 + } 197 + 198 + async fn cmd_login( 199 + config: &mut Config, 200 + url: String, 201 + username: Option<String>, 202 + password: Option<String>, 203 + name: Option<String>, 204 + ) -> Result<()> { 205 + let url = normalize_url(&url); 206 + let server_name = name.unwrap_or_else(|| derive_server_name(&url)); 207 + let username = match username { 208 + Some(u) => u, 209 + None => prompt_line("Username: ")?, 210 + }; 211 + let password = match password { 212 + Some(p) => p, 213 + None => prompt_password("Password: ")?, 214 + }; 215 + let mut client = JellyfinClient::new(&url)?; 216 + let auth = client.login(&username, &password).await?; 217 + let server = ServerConfig { 218 + name: server_name.clone(), 219 + url, 220 + user_id: auth.user.id.clone(), 221 + user_name: auth.user.name.clone(), 222 + access_token: auth.access_token.clone(), 223 + device_id: client.device_id().to_string(), 224 + }; 225 + config.add_or_update_server(server); 226 + config.save()?; 227 + println!( 228 + "✓ signed in as {} on `{}` — now the active server", 229 + auth.user.name, server_name 230 + ); 231 + if config.servers.len() > 1 { 232 + println!( 233 + " ({} servers saved — switch anytime with `fin server switch <name>`)", 234 + config.servers.len() 235 + ); 236 + } 237 + Ok(()) 238 + } 239 + 240 + fn cmd_logout(config: &mut Config, name: Option<String>) -> Result<()> { 241 + let target = name 242 + .or_else(|| config.current_server.clone()) 243 + .context("no server to log out of")?; 244 + config.remove_server(&target)?; 245 + config.save()?; 246 + println!("✓ removed `{}`", target); 247 + if let Some(cur) = &config.current_server { 248 + println!(" now active: `{}`", cur); 249 + } else { 250 + println!(" no servers left — run `fin login <url>` to add one"); 251 + } 252 + Ok(()) 253 + } 254 + 255 + fn cmd_server(config: &mut Config, sub: ServerCmd) -> Result<()> { 256 + match sub { 257 + ServerCmd::List => { 258 + if config.servers.is_empty() { 259 + println!("no servers yet — run `fin login <url>` to add one"); 260 + return Ok(()); 261 + } 262 + let cur = config.current_server.as_deref(); 263 + for s in &config.servers { 264 + let marker = if Some(s.name.as_str()) == cur { 265 + "▍" 266 + } else { 267 + " " 268 + }; 269 + println!( 270 + "{} {:<20} {:<45} as {}", 271 + marker, s.name, s.url, s.user_name 272 + ); 273 + } 274 + } 275 + ServerCmd::Switch { name } => { 276 + config.switch_to(&name)?; 277 + config.save()?; 278 + let s = config.current().unwrap(); 279 + println!( 280 + "✓ active server → `{}` ({}, as {})", 281 + s.name, s.url, s.user_name 282 + ); 283 + } 284 + ServerCmd::Rm { name } => { 285 + config.remove_server(&name)?; 286 + config.save()?; 287 + println!("✓ removed `{}`", name); 288 + } 289 + ServerCmd::Rename { from, to } => { 290 + let Some(idx) = config.servers.iter().position(|s| s.name == from) else { 291 + anyhow::bail!("no server named `{}`", from); 292 + }; 293 + if config.servers.iter().any(|s| s.name == to) { 294 + anyhow::bail!("a server named `{}` already exists", to); 295 + } 296 + config.servers[idx].name = to.clone(); 297 + if config.current_server.as_deref() == Some(from.as_str()) { 298 + config.current_server = Some(to.clone()); 299 + } 300 + config.save()?; 301 + println!("✓ `{}` → `{}`", from, to); 302 + } 303 + } 304 + Ok(()) 305 + } 306 + 307 + async fn cmd_search(cfg: &Config, query: &str, kind: Option<String>, limit: u32) -> Result<()> { 308 + let client = make_client(cfg)?; 309 + let types: Vec<&str> = match kind.as_deref() { 310 + Some("audio") | Some("music") => vec!["Audio", "MusicAlbum", "MusicArtist"], 311 + Some("video") | Some("movies") => vec!["Movie", "Series", "Episode"], 312 + _ => vec![ 313 + "Audio", 314 + "MusicAlbum", 315 + "MusicArtist", 316 + "Movie", 317 + "Series", 318 + "Episode", 319 + ], 320 + }; 321 + let items = client.search(query, &types, limit).await?; 322 + for it in &items { 323 + println!("{} {} {}", it.kind().icon(), it.name, it.subtitle()); 324 + } 325 + if items.is_empty() { 326 + eprintln!("no matches"); 327 + } 328 + Ok(()) 329 + } 330 + 331 + async fn cmd_play(cfg: &Config, args: PlayArgs, queue: bool) -> Result<()> { 332 + let client = make_client(cfg)?; 333 + let format = if args.hls || cfg.renderer == RendererPref::Chromecast { 334 + StreamFormat::Hls 335 + } else { 336 + StreamFormat::Direct 337 + }; 338 + let types: Vec<&str> = match args.kind.as_deref() { 339 + Some("audio") | Some("music") => vec!["Audio", "MusicAlbum"], 340 + Some("video") | Some("movie") | Some("movies") => vec!["Movie", "Episode"], 341 + _ => vec!["Audio", "MusicAlbum", "Movie", "Episode"], 342 + }; 343 + let hits = client.search(&args.query, &types, 10).await?; 344 + let Some(hit) = hits.into_iter().next() else { 345 + anyhow::bail!("nothing matched '{}'", args.query); 346 + }; 347 + let items = match hit.kind() { 348 + ItemKind::MusicAlbum => { 349 + client 350 + .items( 351 + Some(&hit.id), 352 + &["Audio"], 353 + false, 354 + Some("SortName"), 355 + Some(500), 356 + ) 357 + .await? 358 + } 359 + _ => vec![hit], 360 + }; 361 + let mut queue_items = Vec::new(); 362 + for it in items { 363 + let url = client.stream_url(&it, format)?; 364 + let is_video = it.kind().is_video(); 365 + queue_items.push(QueueItem { 366 + id: it.id.clone(), 367 + title: it.name.clone(), 368 + subtitle: it.subtitle(), 369 + stream_url: url, 370 + image_url: None, 371 + duration_secs: it.duration_secs(), 372 + is_video, 373 + content_type: if is_video { 374 + "video/mp4".into() 375 + } else { 376 + "audio/mpeg".into() 377 + }, 378 + }); 379 + } 380 + let (renderer, label) = build_renderer(cfg).await?; 381 + if queue { 382 + renderer.enqueue(queue_items.clone()).await?; 383 + println!("+ queued {} item(s) on {}", queue_items.len(), label); 384 + } else { 385 + renderer.play(queue_items.clone(), 0).await?; 386 + println!( 387 + "▶ playing “{}” on {}", 388 + queue_items.first().map(|q| q.title.as_str()).unwrap_or(""), 389 + label 390 + ); 391 + } 392 + // For chromecast, keep the process alive so playback keeps flowing. 393 + if renderer.kind() == RendererKind::Chromecast { 394 + println!("Ctrl+C to disconnect."); 395 + tokio::signal::ctrl_c().await?; 396 + } 397 + Ok(()) 398 + } 399 + 400 + async fn cmd_devices(scan_seconds: u64) -> Result<()> { 401 + println!("scanning for Chromecasts ({}s)…", scan_seconds); 402 + let devices = discover_chromecasts(Duration::from_secs(scan_seconds)).await?; 403 + if devices.is_empty() { 404 + eprintln!("no devices found"); 405 + return Ok(()); 406 + } 407 + for d in devices { 408 + println!( 409 + "󰓐 {} [{}] {}:{}", 410 + d.display_name(), 411 + d.model, 412 + d.address, 413 + d.port 414 + ); 415 + } 416 + Ok(()) 417 + } 418 + 419 + async fn cmd_playlists(cfg: &Config, list_id: Option<String>) -> Result<()> { 420 + let client = make_client(cfg)?; 421 + match list_id { 422 + None => { 423 + let pls = client.playlists().await?; 424 + for p in pls { 425 + println!("▤ {} ({})", p.name, p.id); 426 + } 427 + } 428 + Some(id) => { 429 + let items = client.playlist_items(&id).await?; 430 + for it in items { 431 + println!("{} {} {}", it.kind().icon(), it.name, it.subtitle()); 432 + } 433 + } 434 + } 435 + Ok(()) 436 + } 437 + 438 + fn cmd_config(cfg: &Config, show: bool, path: bool) -> Result<()> { 439 + if path { 440 + println!("{}", fin_config::config_path()?.display()); 441 + return Ok(()); 442 + } 443 + if show { 444 + let text = toml::to_string_pretty(cfg).context("serialize config")?; 445 + print!("{}", text); 446 + return Ok(()); 447 + } 448 + println!( 449 + "config file: {}\nuse --show to print, --path to print just the path", 450 + fin_config::config_path()?.display() 451 + ); 452 + Ok(()) 453 + } 454 + 455 + fn normalize_url(url: &str) -> String { 456 + let trimmed = url.trim().trim_end_matches('/'); 457 + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { 458 + trimmed.to_string() 459 + } else { 460 + format!("http://{}", trimmed) 461 + } 462 + } 463 + 464 + fn prompt_line(msg: &str) -> Result<String> { 465 + print!("{}", msg); 466 + io::stdout().flush().ok(); 467 + let mut buf = String::new(); 468 + io::stdin().read_line(&mut buf)?; 469 + Ok(buf.trim().to_string()) 470 + } 471 + 472 + /// Read a secret from the TTY without echoing it. Falls back to the standard 473 + /// prompt only when stdin is not a TTY (e.g. piped input from a script) so 474 + /// automation still works. 475 + fn prompt_password(msg: &str) -> Result<String> { 476 + let secret = rpassword::prompt_password(msg).context("reading password")?; 477 + Ok(secret) 478 + }
+31
crates/fin/src/preflight.rs
··· 1 + use std::process::Command; 2 + 3 + use anyhow::{bail, Result}; 4 + 5 + /// Verify that mpv is on `$PATH`. fin uses mpv both as its local renderer and 6 + /// as a fallback probe, so we refuse to start without it. 7 + pub fn ensure_mpv() -> Result<()> { 8 + let ok = Command::new("mpv") 9 + .arg("--version") 10 + .stdout(std::process::Stdio::null()) 11 + .stderr(std::process::Stdio::null()) 12 + .status() 13 + .map(|s| s.success()) 14 + .unwrap_or(false); 15 + if ok { 16 + return Ok(()); 17 + } 18 + let hint = if cfg!(target_os = "macos") { 19 + "brew install mpv" 20 + } else if cfg!(target_os = "linux") { 21 + "sudo apt install mpv # or: sudo pacman -S mpv" 22 + } else if cfg!(target_os = "windows") { 23 + "winget install mpv # or: scoop install mpv" 24 + } else { 25 + "install mpv from https://mpv.io" 26 + }; 27 + bail!( 28 + "mpv is required but was not found on PATH.\n\nInstall it and try again:\n {}\n", 29 + hint 30 + ); 31 + }
+116
flake.lock
··· 1 + { 2 + "nodes": { 3 + "advisory-db": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1782982322, 7 + "narHash": "sha256-+c+RMomeBqiXTajKR/Nrt/hddGDz8yks1bBm8B4+Crc=", 8 + "owner": "rustsec", 9 + "repo": "advisory-db", 10 + "rev": "6cd8a86e08d8deb276cf46746cb789de39145481", 11 + "type": "github" 12 + }, 13 + "original": { 14 + "owner": "rustsec", 15 + "repo": "advisory-db", 16 + "type": "github" 17 + } 18 + }, 19 + "crane": { 20 + "locked": { 21 + "lastModified": 1781825982, 22 + "narHash": "sha256-SlXKwIRIhrOSAcTjCB3ftPLzJWZStQIPS7J1FlZPnKk=", 23 + "owner": "ipetkov", 24 + "repo": "crane", 25 + "rev": "469fd08d0bcf6926321fa973c6777fbc87785dd7", 26 + "type": "github" 27 + }, 28 + "original": { 29 + "owner": "ipetkov", 30 + "repo": "crane", 31 + "type": "github" 32 + } 33 + }, 34 + "fenix": { 35 + "inputs": { 36 + "nixpkgs": [ 37 + "nixpkgs" 38 + ], 39 + "rust-analyzer-src": [] 40 + }, 41 + "locked": { 42 + "lastModified": 1782900513, 43 + "narHash": "sha256-GHrsl1+ysDFgmDQtQSqWS7TiYD2Q58VlwLvnf1+crJM=", 44 + "owner": "nix-community", 45 + "repo": "fenix", 46 + "rev": "16810aa8f4ad89ca480b1513774e8b6f485fe368", 47 + "type": "github" 48 + }, 49 + "original": { 50 + "owner": "nix-community", 51 + "repo": "fenix", 52 + "type": "github" 53 + } 54 + }, 55 + "flake-utils": { 56 + "inputs": { 57 + "systems": "systems" 58 + }, 59 + "locked": { 60 + "lastModified": 1731533236, 61 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 62 + "owner": "numtide", 63 + "repo": "flake-utils", 64 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 65 + "type": "github" 66 + }, 67 + "original": { 68 + "owner": "numtide", 69 + "repo": "flake-utils", 70 + "type": "github" 71 + } 72 + }, 73 + "nixpkgs": { 74 + "locked": { 75 + "lastModified": 1782723713, 76 + "narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=", 77 + "owner": "NixOS", 78 + "repo": "nixpkgs", 79 + "rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8", 80 + "type": "github" 81 + }, 82 + "original": { 83 + "owner": "NixOS", 84 + "ref": "nixos-unstable", 85 + "repo": "nixpkgs", 86 + "type": "github" 87 + } 88 + }, 89 + "root": { 90 + "inputs": { 91 + "advisory-db": "advisory-db", 92 + "crane": "crane", 93 + "fenix": "fenix", 94 + "flake-utils": "flake-utils", 95 + "nixpkgs": "nixpkgs" 96 + } 97 + }, 98 + "systems": { 99 + "locked": { 100 + "lastModified": 1681028828, 101 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 102 + "owner": "nix-systems", 103 + "repo": "default", 104 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 105 + "type": "github" 106 + }, 107 + "original": { 108 + "owner": "nix-systems", 109 + "repo": "default", 110 + "type": "github" 111 + } 112 + } 113 + }, 114 + "root": "root", 115 + "version": 7 116 + }
+170
flake.nix
··· 1 + { 2 + description = "fin - a neon-electric Jellyfin TUI client for mpv & Chromecast"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 + 7 + crane = { 8 + url = "github:ipetkov/crane"; 9 + inputs.nixpkgs.follows = "nixpkgs"; 10 + }; 11 + 12 + fenix = { 13 + url = "github:nix-community/fenix"; 14 + inputs.nixpkgs.follows = "nixpkgs"; 15 + inputs.rust-analyzer-src.follows = ""; 16 + }; 17 + 18 + flake-utils.url = "github:numtide/flake-utils"; 19 + 20 + advisory-db = { 21 + url = "github:rustsec/advisory-db"; 22 + flake = false; 23 + }; 24 + }; 25 + 26 + outputs = { self, nixpkgs, crane, fenix, flake-utils, advisory-db, ... }: 27 + flake-utils.lib.eachDefaultSystem (system: 28 + let 29 + pkgs = import nixpkgs { 30 + inherit system; 31 + }; 32 + 33 + inherit (pkgs) lib; 34 + 35 + craneLib = crane.mkLib pkgs; 36 + 37 + src = craneLib.cleanCargoSource ./.; 38 + 39 + # fin uses rustls end-to-end, so no openssl. 40 + # mpv is a *runtime* requirement (we spawn it for local playback and 41 + # the CLI does a preflight `mpv --version` at startup). We fold it 42 + # into PATH via a makeWrapper post-fixup below. 43 + commonArgs = { 44 + inherit src; 45 + 46 + pname = "fin"; 47 + version = "0.1.0"; 48 + strictDeps = true; 49 + 50 + # No native TLS or system libs needed — pure Rust deps. 51 + nativeBuildInputs = [ 52 + pkgs.pkg-config 53 + ]; 54 + 55 + buildInputs = lib.optionals pkgs.stdenv.isDarwin [ 56 + pkgs.libiconv 57 + pkgs.darwin.apple_sdk.frameworks.SystemConfiguration 58 + pkgs.darwin.apple_sdk.frameworks.Security 59 + ]; 60 + 61 + # Workspace has one bin target — build just that. 62 + cargoExtraArgs = "--locked --bin fin"; 63 + }; 64 + 65 + craneLibLLvmTools = craneLib.overrideToolchain 66 + (fenix.packages.${system}.complete.withComponents [ 67 + "cargo" 68 + "llvm-tools" 69 + "rustc" 70 + ]); 71 + 72 + # Cache the dependency graph separately from the crate source. 73 + cargoArtifacts = craneLib.buildDepsOnly commonArgs; 74 + 75 + finUnwrapped = craneLib.buildPackage (commonArgs // { 76 + inherit cargoArtifacts; 77 + doCheck = false; 78 + }); 79 + 80 + # Wrap the binary so `mpv` is always discoverable at runtime, even 81 + # when installed via `nix profile install`. 82 + fin = pkgs.symlinkJoin { 83 + name = "fin-${finUnwrapped.version}"; 84 + paths = [ finUnwrapped ]; 85 + nativeBuildInputs = [ pkgs.makeWrapper ]; 86 + postBuild = '' 87 + wrapProgram $out/bin/fin \ 88 + --prefix PATH : ${lib.makeBinPath [ pkgs.mpv ]} 89 + ''; 90 + meta = { 91 + description = "A neon-electric Jellyfin TUI client for mpv & Chromecast"; 92 + homepage = "https://github.com/tsirysndr/fin"; 93 + license = lib.licenses.mpl20; 94 + mainProgram = "fin"; 95 + platforms = lib.platforms.unix; 96 + }; 97 + }; 98 + 99 + in 100 + { 101 + checks = { 102 + inherit fin; 103 + 104 + fin-clippy = craneLib.cargoClippy (commonArgs // { 105 + inherit cargoArtifacts; 106 + cargoClippyExtraArgs = "--all-targets -- --deny warnings"; 107 + }); 108 + 109 + fin-doc = craneLib.cargoDoc (commonArgs // { 110 + inherit cargoArtifacts; 111 + }); 112 + 113 + fin-fmt = craneLib.cargoFmt { 114 + inherit src; 115 + }; 116 + 117 + fin-audit = craneLib.cargoAudit { 118 + inherit src advisory-db; 119 + }; 120 + 121 + fin-nextest = craneLib.cargoNextest (commonArgs // { 122 + inherit cargoArtifacts; 123 + partitions = 1; 124 + partitionType = "count"; 125 + }); 126 + } // lib.optionalAttrs (system == "x86_64-linux") { 127 + fin-coverage = craneLib.cargoTarpaulin (commonArgs // { 128 + inherit cargoArtifacts; 129 + }); 130 + }; 131 + 132 + packages = { 133 + default = fin; 134 + fin = fin; 135 + fin-unwrapped = finUnwrapped; 136 + 137 + fin-llvm-coverage = craneLibLLvmTools.cargoLlvmCov (commonArgs // { 138 + inherit cargoArtifacts; 139 + }); 140 + }; 141 + 142 + apps.default = flake-utils.lib.mkApp { 143 + drv = fin; 144 + name = "fin"; 145 + }; 146 + 147 + devShells.default = pkgs.mkShell { 148 + inputsFrom = builtins.attrValues self.checks.${system}; 149 + 150 + # Everything you need to `cargo run` and actually play back media. 151 + nativeBuildInputs = with pkgs; [ 152 + cargo 153 + rustc 154 + rustfmt 155 + clippy 156 + rust-analyzer 157 + mpv 158 + pkg-config 159 + ] ++ lib.optionals pkgs.stdenv.isDarwin [ 160 + libiconv 161 + darwin.apple_sdk.frameworks.SystemConfiguration 162 + darwin.apple_sdk.frameworks.Security 163 + ]; 164 + 165 + shellHook = '' 166 + echo "⚡ fin dev shell — mpv $(mpv --version | head -n1 | cut -d' ' -f2) ready" 167 + ''; 168 + }; 169 + }); 170 + }