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

Configure Feed

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

Initial Commit

author
Tsiry Sandratraina
date (Jul 2, 2026, 10:29 PM +0300) commit 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 + }