From b1f8f4cb018ff6ec56366303e1b1b9ccde84fca1 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Thu, 16 Jul 2026 20:29:51 +0300 Subject: [PATCH] init federation --- .cargo/config.toml | 3 + Cargo.lock | 2459 +++++++++++++++++++++++++------- Cargo.toml | 11 +- src/api/auth.rs | 281 ---- src/api/client.rs | 778 ---------- src/api/mod.rs | 3 - src/api/models.rs | 314 ---- src/app/action.rs | 20 +- src/app/cmdline.rs | 52 +- src/app/command.rs | 25 +- src/app/event.rs | 65 +- src/app/login.rs | 221 --- src/app/mod.rs | 1489 +++++++------------ src/app/popup.rs | 359 ++++- src/app/sso.rs | 134 -- src/app/state.rs | 242 ++-- src/app/update.rs | 457 ++++-- src/config/default_keymap.toml | 12 +- src/config/keymap.rs | 19 +- src/config/mod.rs | 47 - src/federation/audio.rs | 311 ++++ src/federation/mod.rs | 630 ++++++++ src/library/import.rs | 549 +++++++ src/library/mod.rs | 1018 +++++++++++++ src/library/models.rs | 173 +++ src/main.rs | 3 +- src/player/mod.rs | 10 +- src/ui/federation.rs | 138 ++ src/ui/global.rs | 79 +- src/ui/login.rs | 230 --- src/ui/mod.rs | 68 +- src/ui/playlists.rs | 20 +- src/ui/popup.rs | 216 +-- 33 files changed, 6417 insertions(+), 4019 deletions(-) create mode 100644 .cargo/config.toml delete mode 100644 src/api/auth.rs delete mode 100644 src/api/client.rs delete mode 100644 src/api/mod.rs delete mode 100644 src/api/models.rs delete mode 100644 src/app/login.rs delete mode 100644 src/app/sso.rs create mode 100644 src/federation/audio.rs create mode 100644 src/federation/mod.rs create mode 100644 src/library/import.rs create mode 100644 src/library/mod.rs create mode 100644 src/library/models.rs create mode 100644 src/ui/federation.rs delete mode 100644 src/ui/login.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..495b5eb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +# Local development override for the frid workspace; delete after pushing frid. +[patch.'https://gt.hexor.cy/ab/frid.git'] +music-dht = { path = "../../frid/crates/music-dht" } diff --git a/Cargo.lock b/Cargo.lock index ee34310..fd28a34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -45,6 +80,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -61,23 +105,20 @@ dependencies = [ ] [[package]] -name = "arboard" -version = "3.6.1" +name = "arc-swap" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ - "clipboard-win", - "log", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "parking_lot", - "percent-encoding", - "windows-sys 0.60.2", - "wl-clipboard-rs", - "x11rb", + "rustversion", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -253,6 +294,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "atomic" version = "0.6.1" @@ -262,12 +314,33 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -275,26 +348,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "aws-lc-rs" -version = "1.17.0" +name = "backon" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ - "aws-lc-sys", - "zeroize", + "fastrand 2.4.1", + "gloo-timers", + "tokio", ] [[package]] -name = "aws-lc-sys" -version = "0.41.0" +name = "base16ct" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] name = "base64" @@ -302,6 +370,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -329,6 +403,20 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block" version = "0.1.6" @@ -344,6 +432,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -418,8 +515,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -442,21 +537,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "clipboard-win" -version = "5.4.1" +name = "chacha20" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ - "error-code", + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] -name = "cmake" -version = "0.1.58" +name = "chrono" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "cc", + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", ] [[package]] @@ -528,6 +653,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.10.0" @@ -537,6 +674,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -640,6 +787,30 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -681,6 +852,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -725,6 +914,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -735,14 +933,85 @@ dependencies = [ "phf", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -758,13 +1027,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -775,12 +1055,49 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 2.0.117", +] + [[package]] name = "deltae" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -801,6 +1118,37 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -821,16 +1169,33 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", ] [[package]] @@ -867,6 +1232,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.0", + "block2", + "libc", "objc2", ] @@ -881,6 +1248,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "document-features" version = "0.2.12" @@ -891,27 +1269,36 @@ dependencies = [ ] [[package]] -name = "downcast-rs" -version = "1.2.1" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "dunce" -version = "1.0.5" +name = "ed25519" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "educe" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "enum-ordinalize", - "proc-macro2", - "quote", - "syn 2.0.117", + "pkcs8", + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", ] [[package]] @@ -920,6 +1307,18 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -930,19 +1329,10 @@ dependencies = [ ] [[package]] -name = "enum-ordinalize" -version = "4.3.2" +name = "enum-assoc" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" -dependencies = [ - "enum-ordinalize-derive", -] - -[[package]] -name = "enum-ordinalize-derive" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" dependencies = [ "proc-macro2", "quote", @@ -986,12 +1376,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - [[package]] name = "euclid" version = "0.22.14" @@ -1045,6 +1429,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1085,6 +1481,31 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "federation-net" +version = "0.1.0" +dependencies = [ + "blake3", + "data-encoding", + "futures", + "iroh", + "iroh-base", + "iroh-tickets", + "mainline", + "postcard", + "rand 0.9.5", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1114,12 +1535,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "flate2" version = "1.1.9" @@ -1130,6 +1545,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.9", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1172,18 +1598,11 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "furumi_tui" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", - "arboard", "core-foundation 0.10.1", "crokey", "crossterm", @@ -1191,14 +1610,14 @@ dependencies = [ "futures-util", "image", "libc", - "open", + "lofty", + "music-dht", "ratatui", - "reqwest", "rodio", + "rusqlite", "serde", "serde_json", "souvlaki", - "stream-download", "thiserror 2.0.18", "tokio", "toml", @@ -1208,6 +1627,34 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.1", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1215,6 +1662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1223,6 +1671,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -1286,6 +1745,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1296,6 +1756,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1306,16 +1781,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.4", - "windows-link", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1336,11 +1801,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1350,10 +1813,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] @@ -1366,6 +1842,46 @@ dependencies = [ "weezl", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1397,6 +1913,29 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.9", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1421,6 +1960,83 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.2", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + [[package]] name = "http" version = "1.4.2" @@ -1433,9 +2049,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1443,9 +2059,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1460,6 +2076,21 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -1470,9 +2101,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1512,12 +2145,36 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.6.5", "tokio", "tower-service", "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1612,6 +2269,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -1633,6 +2296,26 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.2", + "tokio", + "url", + "xmltree", +] + [[package]] name = "image" version = "0.25.10" @@ -1682,13 +2365,22 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instability" version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -1715,29 +2407,207 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.5", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" dependencies = [ - "once_cell", + "serde", ] [[package]] -name = "is-wsl" -version = "0.4.0" +name = "iroh" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +checksum = "5fca9b4b462c343ff88fc0af4096c186f939b602a0bc08723536ef2c31c93971" dependencies = [ - "is-docker", - "once_cell", + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.2", + "reqwest", + "rustc-hash", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830a582cd54410dc1aa71d4786a82c3297d7b0165accd8b6dbbb3b240b48140d" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.2", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.2", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "iroh-relay" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru 0.18.0", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.2", + "reqwest", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", ] [[package]] @@ -1829,16 +2699,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.100" @@ -1900,6 +2760,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.7" @@ -1948,12 +2819,57 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "log" version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + [[package]] name = "lru" version = "0.18.0" @@ -1969,6 +2885,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "mac_address" version = "1.1.8" @@ -1988,6 +2910,28 @@ dependencies = [ "libc", ] +[[package]] +name = "mainline" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfad9601b9117218868271c05403853593d4817e7abeab5c95e11eee16382bb" +dependencies = [ + "crc", + "document-features", + "dyn-clone", + "ed25519-dalek", + "flume", + "futures-lite 2.6.1", + "getrandom 0.4.2", + "lru 0.16.4", + "serde", + "serde_bencode", + "serde_bytes", + "sha1_smol", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2006,12 +2950,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "mediatype" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" - [[package]] name = "memchr" version = "2.8.1" @@ -2070,6 +3008,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2080,6 +3035,77 @@ dependencies = [ "pxfm", ] +[[package]] +name = "music-dht" +version = "0.1.0" +dependencies = [ + "blake3", + "data-encoding", + "federation-net", + "futures", + "postcard", + "rand 0.9.5", + "rusqlite", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", + "unicode-normalization", +] + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite 2.6.1", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2109,6 +3135,118 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2 0.6.5", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result", + "wmi", +] + [[package]] name = "nix" version = "0.26.4" @@ -2145,12 +3283,65 @@ dependencies = [ ] [[package]] -name = "nom" -version = "8.0.0" +name = "noq" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" dependencies = [ - "memchr", + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -2267,18 +3458,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.13.0", - "objc2", - "objc2-core-graphics", - "objc2-foundation", -] - [[package]] name = "objc2-audio-toolbox" version = "0.3.2" @@ -2341,16 +3520,17 @@ dependencies = [ ] [[package]] -name = "objc2-core-graphics" +name = "objc2-core-wlan" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" dependencies = [ "bitflags 2.13.0", - "dispatch2", "objc2", "objc2-core-foundation", - "objc2-io-surface", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", ] [[package]] @@ -2373,32 +3553,64 @@ dependencies = [ ] [[package]] -name = "objc2-io-surface" +name = "objc2-security" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ "bitflags 2.13.0", "objc2", "objc2-core-foundation", ] +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "ogg_pager" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] -name = "open" -version = "5.3.5" +name = "opaque-debug" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl-probe" @@ -2431,16 +3643,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "palette" version = "0.7.6" @@ -2465,6 +3667,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + [[package]] name = "parking" version = "2.2.1" @@ -2495,10 +3707,19 @@ dependencies = [ ] [[package]] -name = "pathdiff" -version = "0.2.3" +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] [[package]] name = "percent-encoding" @@ -2546,18 +3767,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] -name = "petgraph" -version = "0.8.3" +name = "pharos" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" dependencies = [ - "fixedbitset 0.5.7", - "hashbrown 0.15.5", - "indexmap", + "futures", + "rustc_version", ] [[package]] @@ -2612,6 +3832,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2629,12 +3869,35 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -2684,11 +3947,80 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" +dependencies = [ + "base64", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.2", + "serde", + "smallvec", + "socket2 0.6.5", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "potential_utf" @@ -2714,6 +4046,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2766,69 +4109,13 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2 0.6.4", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.4", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.45" @@ -2863,14 +4150,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2910,10 +4208,19 @@ dependencies = [ ] [[package]] -name = "rangemap" -version = "1.7.1" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] [[package]] name = "ratatui" @@ -2943,7 +4250,7 @@ dependencies = [ "indoc", "itertools", "kasuari", - "lru", + "lru 0.18.0", "palette", "serde", "strum", @@ -3074,12 +4381,9 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", - "serde", - "serde_json", "sync_wrapper", "tokio", "tokio-rustls", @@ -3094,6 +4398,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -3122,10 +4432,35 @@ dependencies = [ ] [[package]] -name = "rustc-hash" -version = "2.1.2" +name = "rsqlite-vfs" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3178,12 +4513,13 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ - "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3204,9 +4540,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -3245,7 +4581,6 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3281,6 +4616,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -3310,12 +4651,28 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -3326,6 +4683,26 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bencode" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e" +dependencies = [ + "serde", + "serde_bytes", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3379,6 +4756,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3386,10 +4773,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -3397,8 +4790,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3447,6 +4851,12 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3455,9 +4865,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -3469,6 +4879,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -3499,14 +4918,20 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + [[package]] name = "souvlaki" version = "0.8.3" @@ -3526,6 +4951,54 @@ dependencies = [ "zvariant", ] +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3538,26 +5011,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "stream-download" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45067938077d0ef5f48c490527742eb0c9cb5e26e1b80e3bcf57d3a83ef50554" -dependencies = [ - "bytes", - "educe", - "futures-util", - "mediatype", - "parking_lot", - "rangemap", - "reqwest", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "strict" version = "0.2.0" @@ -3797,6 +5250,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.27.0" @@ -3817,7 +5297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", - "nom 7.1.3", + "nom", "phf", "phf_codegen", ] @@ -3843,7 +5323,7 @@ dependencies = [ "fancy-regex", "filedescriptor", "finl_unicode", - "fixedbitset 0.4.2", + "fixedbitset", "hex", "lazy_static", "libc", @@ -3856,7 +5336,7 @@ dependencies = [ "pest", "pest_derive", "phf", - "sha2", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -3929,12 +5409,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", + "js-sys", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3943,6 +5426,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3955,9 +5448,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3978,7 +5471,8 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2 0.6.4", + "signal-hook-registry", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -4004,6 +5498,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4013,10 +5519,34 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.2", + "http", + "httparse", + "rand 0.10.2", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -4136,6 +5666,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4191,17 +5722,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "tree_magic_mini" -version = "3.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" -dependencies = [ - "memchr", - "nom 8.0.0", - "petgraph", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -4237,6 +5757,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4266,6 +5795,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -4282,6 +5821,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4314,6 +5854,49 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + [[package]] name = "version_check" version = "0.9.5" @@ -4480,76 +6063,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wayland-backend" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" -dependencies = [ - "cc", - "downcast-rs", - "rustix 1.1.4", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" -dependencies = [ - "bitflags 2.13.0", - "rustix 1.1.4", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "pkg-config", -] - [[package]] name = "web-sys" version = "0.3.100" @@ -4572,9 +6085,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -4603,7 +6125,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -4657,6 +6179,12 @@ dependencies = [ "wezterm-dynamic", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -4780,6 +6308,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4834,15 +6373,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -4891,30 +6421,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -4942,12 +6455,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4966,12 +6473,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4990,24 +6491,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5026,12 +6515,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5050,12 +6533,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5074,12 +6551,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5098,12 +6569,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" @@ -5217,21 +6682,18 @@ dependencies = [ ] [[package]] -name = "wl-clipboard-rs" -version = "0.9.3" +name = "wmi" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" dependencies = [ - "libc", + "chrono", + "futures", "log", - "os_pipe", - "rustix 1.1.4", + "serde", "thiserror 2.0.18", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", + "windows 0.62.2", + "windows-core", ] [[package]] @@ -5241,22 +6703,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "x11rb" -version = "0.13.2" +name = "ws_stream_wasm" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" dependencies = [ - "gethostname", - "rustix 1.1.4", - "x11rb-protocol", + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - [[package]] name = "xdg-home" version = "1.3.0" @@ -5267,6 +6731,21 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5399,9 +6878,23 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 24c23b0..99aa20e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,22 +9,23 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.102" -arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] } crokey = "1.4.0" crossterm = { version = "0.29.0", features = ["event-stream"] } directories = "6.0.0" futures-util = "0.3.32" image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] } -open = "5.3.5" +lofty = "0.22" +# P2P federation: library index in a shared DHT + audio streaming between +# peers (same protocol as furumi-fd). +music-dht = { git = "https://gt.hexor.cy/ab/frid.git" } ratatui = "0.30.1" -reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] } rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] } +rusqlite = { version = "0.39", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] } -stream-download = { version = "0.24.1", default-features = false, features = ["reqwest-rustls", "temp-storage"] } thiserror = "2.0.18" -tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time"] } +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] } toml = "1.1.2" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/src/api/auth.rs b/src/api/auth.rs deleted file mode 100644 index a14d484..0000000 --- a/src/api/auth.rs +++ /dev/null @@ -1,281 +0,0 @@ -use std::fs; -use std::io::ErrorKind; -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; - -use anyhow::{Context as _, Result, bail}; -use serde::{Deserialize, Serialize}; - -use super::models::{TokensResponse, User}; - -/// Margin before access-token expiry at which we refresh proactively, -/// mirroring the Android/macOS clients. -pub const EXPIRY_SKEW_SECONDS: i64 = 60; - -/// Persisted session, same shape as the macOS client's AuthSession. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthSession { - pub server_base_url: String, - pub user: User, - pub access_token: String, - pub refresh_token: String, - pub token_type: String, - pub expires_at_epoch_seconds: i64, -} - -impl AuthSession { - pub fn new(server_base_url: String, user: User, tokens: TokensResponse) -> Self { - Self { - server_base_url, - user, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - token_type: tokens.token_type, - expires_at_epoch_seconds: now_epoch_seconds() + tokens.expires_in_seconds, - } - } - - pub fn apply_tokens(&mut self, tokens: TokensResponse) { - self.access_token = tokens.access_token; - self.refresh_token = tokens.refresh_token; - self.token_type = tokens.token_type; - self.expires_at_epoch_seconds = now_epoch_seconds() + tokens.expires_in_seconds; - } - - pub fn access_token_expired(&self) -> bool { - now_epoch_seconds() + EXPIRY_SKEW_SECONDS >= self.expires_at_epoch_seconds - } - - pub fn seconds_until_access_expiry(&self) -> i64 { - self.expires_at_epoch_seconds - now_epoch_seconds() - } -} - -pub fn now_epoch_seconds() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -pub fn now_epoch_millis() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -pub fn session_path() -> Option { - crate::config::project_dirs().map(|dirs| dirs.config_dir().join("credentials.json")) -} - -pub fn load_session() -> Option { - let Some(path) = session_path() else { - tracing::warn!("cannot determine config directory; no stored auth session loaded"); - return None; - }; - let text = match fs::read_to_string(&path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => { - tracing::debug!(path = %path.display(), "credentials file not found"); - return None; - } - Err(err) => { - tracing::warn!(path = %path.display(), %err, "failed to read credentials file"); - return None; - } - }; - match serde_json::from_str::(&text) { - Ok(session) => { - tracing::info!( - path = %path.display(), - user_id = session.user.id, - user = %session.user.name, - server = %session.server_base_url, - expires_at_epoch_seconds = session.expires_at_epoch_seconds, - seconds_until_expiry = session.seconds_until_access_expiry(), - "loaded stored auth session" - ); - Some(session) - } - Err(err) => { - tracing::warn!(path = %path.display(), %err, "ignoring unreadable credentials file"); - None - } - } -} - -pub fn save_session(session: &AuthSession) -> Result<()> { - let path = session_path().context("cannot determine config directory")?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; - } - let text = serde_json::to_string_pretty(session)?; - tracing::info!( - path = %path.display(), - user_id = session.user.id, - user = %session.user.name, - server = %session.server_base_url, - expires_at_epoch_seconds = session.expires_at_epoch_seconds, - seconds_until_expiry = session.seconds_until_access_expiry(), - "persisting auth session" - ); - write_private(&path, &text).with_context(|| format!("writing {}", path.display()))?; - tracing::debug!(path = %path.display(), "auth session persisted"); - Ok(()) -} - -pub fn delete_session() { - if let Some(path) = session_path() { - match fs::remove_file(&path) { - Ok(()) => tracing::info!(path = %path.display(), "deleted stored auth session"), - Err(err) if err.kind() == ErrorKind::NotFound => { - tracing::debug!(path = %path.display(), "stored auth session already absent"); - } - Err(err) => { - tracing::warn!(path = %path.display(), %err, "failed to delete stored auth session") - } - } - } -} - -#[cfg(unix)] -fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> { - use std::io::Write as _; - use std::os::unix::fs::OpenOptionsExt as _; - let mut file = fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .mode(0o600) - .open(path)?; - file.write_all(text.as_bytes()) -} - -#[cfg(not(unix))] -fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> { - fs::write(path, text) -} - -/// Same normalization rules as the Android client's ServerConfig: -/// add https:// when no scheme, require http(s) with a host, reject -/// credentials/query/fragment, lowercase the host, trim trailing slashes. -pub fn normalize_base_url(raw: &str) -> Result { - let trimmed = raw.trim().trim_end_matches('/'); - if trimmed.is_empty() { - bail!("server URL is empty"); - } - let with_scheme = if trimmed.contains("://") { - trimmed.to_string() - } else { - format!("https://{trimmed}") - }; - let url = reqwest::Url::parse(&with_scheme).context("invalid server URL")?; - if !matches!(url.scheme(), "http" | "https") { - bail!("server URL must use http or https"); - } - let host = url.host_str().filter(|h| !h.is_empty()); - let Some(host) = host else { - bail!("server URL has no host"); - }; - if !url.username().is_empty() || url.password().is_some() { - bail!("server URL must not contain credentials"); - } - if url.query().is_some() || url.fragment().is_some() { - bail!("server URL must not contain a query or fragment"); - } - let mut normalized = format!("{}://{}", url.scheme(), host.to_ascii_lowercase()); - if let Some(port) = url.port() { - normalized.push_str(&format!(":{port}")); - } - let path = url.path().trim_end_matches('/'); - normalized.push_str(path); - Ok(normalized) -} - -/// Accepts what the user pastes after browser SSO: either the full -/// `furumi://auth/callback?code=furu_mx_...` link (copied from the -/// "Open Furumi" button) or the bare `furu_mx_...` code. -pub fn extract_sso_code(input: &str) -> Result { - let input = input.trim(); - if input.is_empty() { - bail!("paste the link or code first"); - } - if input.starts_with("furu_mx_") { - return Ok(input.to_string()); - } - if let Ok(url) = reqwest::Url::parse(input) { - if let Some((_, error)) = url.query_pairs().find(|(k, _)| k == "error") { - bail!("SSO failed: {error}"); - } - if let Some((_, code)) = url.query_pairs().find(|(k, _)| k == "code") { - return Ok(code.into_owned()); - } - } - bail!("no furu_mx_ code found in the pasted text"); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_adds_https_and_strips_slash() { - assert_eq!( - normalize_base_url(" Music.Hexor.cy/ ").unwrap(), - "https://music.hexor.cy" - ); - } - - #[test] - fn normalize_keeps_port_and_path() { - assert_eq!( - normalize_base_url("http://localhost:8000/furumi/").unwrap(), - "http://localhost:8000/furumi" - ); - } - - #[test] - fn normalize_rejects_bad_urls() { - assert!(normalize_base_url("").is_err()); - assert!(normalize_base_url("ftp://x").is_err()); - assert!(normalize_base_url("https://user:pw@host").is_err()); - assert!(normalize_base_url("https://host?x=1").is_err()); - } - - #[test] - fn sso_code_from_deep_link() { - let code = extract_sso_code("furumi://auth/callback?code=furu_mx_abc123").unwrap(); - assert_eq!(code, "furu_mx_abc123"); - } - - #[test] - fn sso_code_bare() { - assert_eq!(extract_sso_code(" furu_mx_x ").unwrap(), "furu_mx_x"); - } - - #[test] - fn sso_error_is_reported() { - let err = extract_sso_code("furumi://auth/callback?error=provider_denied") - .unwrap_err() - .to_string(); - assert!(err.contains("provider_denied")); - } - - #[test] - fn expiry_uses_skew() { - let session = AuthSession { - server_base_url: "https://x".into(), - user: User { - id: 1, - name: "n".into(), - role: "user".into(), - }, - access_token: "a".into(), - refresh_token: "r".into(), - token_type: "Bearer".into(), - expires_at_epoch_seconds: now_epoch_seconds() + 30, - }; - assert!(session.access_token_expired()); - } -} diff --git a/src/api/client.rs b/src/api/client.rs deleted file mode 100644 index 4f48e88..0000000 --- a/src/api/client.rs +++ /dev/null @@ -1,778 +0,0 @@ -use serde::Serialize; -use serde::de::DeserializeOwned; -use tokio::sync::Mutex; - -use super::auth::{self, AuthSession}; -use super::models::{ - ApiErrorBody, ArtistDetail, ArtistsPage, DevicePlaybackState, DevicePollResponse, - LikesResponse, LoginResponse, MeResponse, PlaylistCard, PlaylistDetail, ReleaseDetail, - SearchResults, TokensResponse, TrackItem, -}; - -#[derive(Debug, thiserror::Error)] -pub enum ApiError { - #[error("{0}")] - Server(String), - /// Refresh token rejected or expired — the user must sign in again. - #[error("session expired, please sign in again")] - SessionExpired, - #[error("network error: {0}")] - Network(#[from] reqwest::Error), - #[error("{0}")] - Other(#[from] anyhow::Error), -} - -pub fn http_client() -> reqwest::Client { - reqwest::Client::builder() - .user_agent(format!( - "furumi-tui/{} ({})", - env!("CARGO_PKG_VERSION"), - std::env::consts::OS - )) - .connect_timeout(std::time::Duration::from_secs(10)) - .timeout(std::time::Duration::from_secs(30)) - .build() - .expect("reqwest client config is static") -} - -pub fn device_name() -> String { - format!("furumi-tui ({})", std::env::consts::OS) -} - -pub fn device_user_agent() -> String { - format!( - "FurumiTUI/{} {}", - env!("CARGO_PKG_VERSION"), - std::env::consts::OS - ) -} - -#[derive(Serialize)] -struct PasswordLoginRequest<'a> { - username: &'a str, - password: &'a str, - device_name: String, -} - -#[derive(Serialize)] -struct SsoExchangeRequest<'a> { - code: &'a str, - device_name: String, -} - -#[derive(Serialize)] -struct RefreshRequest<'a> { - refresh_token: &'a str, -} - -#[derive(Serialize)] -struct LogoutRequest<'a> { - refresh_token: &'a str, -} - -pub async fn login_password( - http: &reqwest::Client, - base_url: &str, - username: &str, - password: &str, -) -> Result { - let device_name = device_name(); - tracing::info!(%base_url, %device_name, "password login request started"); - let response = match http - .post(format!("{base_url}/api/auth/password")) - .json(&PasswordLoginRequest { - username, - password, - device_name, - }) - .send() - .await - { - Ok(response) => response, - Err(err) => { - tracing::warn!(%base_url, %err, "password login request failed"); - return Err(ApiError::Network(err)); - } - }; - let status = response.status(); - let login: LoginResponse = match parse_response(response).await { - Ok(login) => login, - Err(err) => { - tracing::warn!(%base_url, %status, %err, "password login rejected"); - return Err(err); - } - }; - let session = AuthSession::new(base_url.to_string(), login.user, login.tokens); - tracing::info!( - %base_url, - user_id = session.user.id, - user = %session.user.name, - expires_at_epoch_seconds = session.expires_at_epoch_seconds, - seconds_until_expiry = session.seconds_until_access_expiry(), - "password login succeeded" - ); - Ok(session) -} - -pub async fn login_sso_exchange( - http: &reqwest::Client, - base_url: &str, - code: &str, -) -> Result { - let device_name = device_name(); - tracing::info!(%base_url, %device_name, "SSO exchange request started"); - let response = match http - .post(format!("{base_url}/api/auth/sso/exchange")) - .json(&SsoExchangeRequest { code, device_name }) - .send() - .await - { - Ok(response) => response, - Err(err) => { - tracing::warn!(%base_url, %err, "SSO exchange request failed"); - return Err(ApiError::Network(err)); - } - }; - let status = response.status(); - let login: LoginResponse = match parse_response(response).await { - Ok(login) => login, - Err(err) => { - tracing::warn!(%base_url, %status, %err, "SSO exchange rejected"); - return Err(err); - } - }; - let session = AuthSession::new(base_url.to_string(), login.user, login.tokens); - tracing::info!( - %base_url, - user_id = session.user.id, - user = %session.user.name, - expires_at_epoch_seconds = session.expires_at_epoch_seconds, - seconds_until_expiry = session.seconds_until_access_expiry(), - "SSO exchange succeeded" - ); - Ok(session) -} - -/// Browser entry point for SSO. redirect_uri is either our loopback -/// listener (`http://127.0.0.1:{port}/callback`) or the `furumi://` deep -/// link as a manual-paste fallback. -pub fn sso_start_url(base_url: &str, redirect_uri: &str) -> String { - let mut url = reqwest::Url::parse(&format!("{base_url}/auth/mobile/oidc/start")) - .expect("base_url is pre-validated"); - url.query_pairs_mut() - .append_pair("redirect_uri", redirect_uri); - url.to_string() -} - -async fn refresh_tokens( - http: &reqwest::Client, - base_url: &str, - refresh_token: &str, -) -> Result { - tracing::info!(%base_url, "refresh token request started"); - let response = match http - .post(format!("{base_url}/api/auth/refresh")) - .json(&RefreshRequest { refresh_token }) - .send() - .await - { - Ok(response) => response, - Err(err) => { - tracing::warn!(%base_url, %err, "refresh token request failed"); - return Err(ApiError::Network(err)); - } - }; - let status = response.status(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - tracing::warn!(%base_url, %status, "refresh token rejected by server"); - return Err(ApiError::SessionExpired); - } - let tokens: TokensResponse = match parse_response(response).await { - Ok(tokens) => tokens, - Err(err) => { - tracing::warn!(%base_url, %status, %err, "refresh token request returned error"); - return Err(err); - } - }; - tracing::info!( - %base_url, - %status, - expires_in_seconds = tokens.expires_in_seconds, - "refresh token request succeeded" - ); - Ok(tokens) -} - -/// Mirrors the backend's PlaybackStateDto. -#[derive(Debug, Serialize)] -pub struct PlaybackStateBody { - pub current_track_id: Option, - pub position_ms: i32, - pub queue: Vec, - pub queue_position: i32, - pub shuffle: bool, - pub repeat_mode: String, - pub volume: f64, -} - -#[derive(Serialize)] -struct DevicePollRequest<'a> { - device_id: &'a str, - user_agent: String, - current_jam_id: Option<&'a str>, - playback_state: Option, -} - -#[derive(Serialize)] -struct DeviceActiveRequest<'a> { - device_id: &'a str, - current_device_id: &'a str, -} - -#[derive(Serialize)] -struct DeviceCommandRequest<'a> { - target_device_id: Option<&'a str>, - jam_id: Option<&'a str>, - command: &'a str, - payload: &'a serde_json::Value, -} - -/// Percent-encode a query-string value. -fn url_encode(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - for byte in value.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(byte as char); - } - _ => out.push_str(&format!("%{byte:02X}")), - } - } - out -} - -async fn parse_response(response: reqwest::Response) -> Result { - let status = response.status(); - if status.is_success() { - return Ok(response.json().await?); - } - let message = match response.json::().await { - Ok(body) => body.error, - Err(_) => format!("server returned {status}"), - }; - Err(ApiError::Server(message)) -} - -fn stream_error_is_auth_related(err: &ApiError) -> bool { - let ApiError::Server(message) = err else { - return false; - }; - let message = message.to_ascii_lowercase(); - message.contains("401") - || message.contains("unauthorized") - || message.contains("authentication") -} - -/// Authenticated API client. Owns the session; refreshes the access token -/// proactively (60s skew) and once more on 401, persisting rotated tokens. -/// The session mutex makes concurrent refreshes single-flight. -pub struct ApiClient { - http: reqwest::Client, - base_url: String, - session: Mutex, -} - -impl ApiClient { - pub fn new(http: reqwest::Client, session: AuthSession) -> Self { - Self { - http, - base_url: session.server_base_url.clone(), - session: Mutex::new(session), - } - } - - pub fn base_url(&self) -> &str { - &self.base_url - } - - pub async fn me(&self) -> Result { - self.get_json("/api/player/me").await - } - - pub async fn artists(&self, page: i64, limit: i64) -> Result { - self.get_json(&format!("/api/player/artists?page={page}&limit={limit}")) - .await - } - - pub async fn artist(&self, id: i64) -> Result { - self.get_json(&format!("/api/player/artists/{id}")).await - } - - pub async fn release(&self, id: i64) -> Result { - self.get_json(&format!("/api/player/releases/{id}")).await - } - - pub async fn search(&self, query: &str, limit: i64) -> Result { - self.get_json(&format!( - "/api/player/search?q={}&limit={limit}", - url_encode(query) - )) - .await - } - - /// Open an audio stream for playback: background download backed by a - /// temp file, exposing blocking Read+Seek for the decoder; seeking into - /// not-yet-downloaded ranges uses HTTP Range requests. - /// - /// The download client carries the bearer token valid at start; on very - /// long tracks a Range request after token expiry (15 min) can fail — - /// acceptable for now, a refreshing middleware can replace this later. - pub async fn open_stream( - &self, - path: &str, - ) -> Result<(crate::player::TrackReader, Option), ApiError> { - let url: reqwest::Url = format!("{}{path}", self.base_url) - .parse() - .map_err(|err| ApiError::Server(format!("bad stream url: {err}")))?; - tracing::info!(path, "opening authenticated stream"); - let token = self.fresh_access_token().await?; - match self.open_stream_with_token(url.clone(), &token).await { - Ok(stream) => { - tracing::debug!(path, "authenticated stream opened"); - Ok(stream) - } - Err(err) if stream_error_is_auth_related(&err) => { - tracing::warn!(path, %err, "stream rejected bearer token; refreshing and retrying"); - let retry_token = self.refresh_after_rejection(&token).await?; - let result = self.open_stream_with_token(url, &retry_token).await; - if let Err(retry_err) = &result { - tracing::warn!(path, %retry_err, "stream open failed after auth refresh"); - } - result - } - Err(err) => { - tracing::warn!(path, %err, "stream open failed"); - Err(err) - } - } - } - - async fn open_stream_with_token( - &self, - url: reqwest::Url, - token: &str, - ) -> Result<(crate::player::TrackReader, Option), ApiError> { - use stream_download::Settings; - use stream_download::http::HttpStream; - use stream_download::source::SourceStream as _; - use stream_download::storage::temp::TempStorageProvider; - - let mut headers = reqwest::header::HeaderMap::new(); - let value = format!("Bearer {token}") - .parse() - .map_err(|_| ApiError::Server("invalid token header".to_string()))?; - headers.insert(reqwest::header::AUTHORIZATION, value); - let client = reqwest::Client::builder() - .default_headers(headers) - .build() - .map_err(ApiError::Network)?; - - let stream = HttpStream::new(client, url) - .await - .map_err(|err| ApiError::Server(format!("stream open failed: {err}")))?; - let byte_len = stream.content_length(); - let reader = stream_download::StreamDownload::from_stream( - stream, - TempStorageProvider::new(), - Settings::default(), - ) - .await - .map_err(|err| ApiError::Server(format!("stream start failed: {err}")))?; - Ok((reader, byte_len)) - } - - /// Raw bytes (cover art, artist images) from a server-relative path. - pub async fn get_bytes(&self, path: &str) -> Result, ApiError> { - let url = format!("{}{path}", self.base_url); - let response = self - .send_authed(&url, |client, url, token| { - client.get(url).bearer_auth(token) - }) - .await?; - let status = response.status(); - if !status.is_success() { - return Err(ApiError::Server(format!("server returned {status}"))); - } - Ok(response.bytes().await?.to_vec()) - } - - pub async fn playlists(&self) -> Result, ApiError> { - self.get_json("/api/player/playlists").await - } - - pub async fn playlist(&self, id: i64) -> Result { - self.get_json(&format!("/api/player/playlists/{id}")).await - } - - pub async fn create_playlist(&self, title: &str) -> Result { - #[derive(Serialize)] - struct Body<'a> { - title: &'a str, - } - self.post_json("/api/player/playlists", &Body { title }) - .await - } - - pub async fn add_tracks_to_playlist( - &self, - playlist_id: i64, - track_ids: &[i64], - ) -> Result<(), ApiError> { - #[derive(Serialize)] - struct Body<'a> { - track_ids: &'a [i64], - } - let _: serde_json::Value = self - .post_json( - &format!("/api/player/playlists/{playlist_id}/tracks"), - &Body { track_ids }, - ) - .await?; - Ok(()) - } - - pub async fn likes(&self) -> Result, ApiError> { - let response: LikesResponse = self.get_json("/api/player/likes").await?; - Ok(response.track_ids) - } - - pub async fn toggle_like(&self, track_id: i64) -> Result { - #[derive(serde::Deserialize)] - struct Body { - liked: bool, - } - let body: Body = self - .post_json(&format!("/api/player/likes/toggle/{track_id}"), &()) - .await?; - Ok(body.liked) - } - - #[allow( - dead_code, - reason = "device-sync state restore needs id→track resolution" - )] - pub async fn tracks_by_ids(&self, track_ids: &[i64]) -> Result, ApiError> { - #[derive(Serialize)] - struct Body<'a> { - track_ids: &'a [i64], - } - self.post_json("/api/player/tracks-by-ids", &Body { track_ids }) - .await - } - - /// Tell last.fm (via the server) what is playing right now. Called at - /// track start; the completed-play scrobble goes through /history. - pub async fn lastfm_now_playing(&self, track_id: i64) -> Result<(), ApiError> { - #[derive(Serialize)] - struct Body { - track_id: i64, - } - let _: serde_json::Value = self - .post_json("/api/player/lastfm/now-playing", &Body { track_id }) - .await?; - Ok(()) - } - - /// Report a finished/aborted listen to the play history. - /// Body shape is the backend's HistoryEntry; `completed` marks a full - /// play (vs a manual skip). - pub async fn report_history( - &self, - track_id: i64, - started_at: Option, - listened_seconds: i32, - completed: bool, - ) -> Result<(), ApiError> { - #[derive(Serialize)] - struct Body { - track_id: i64, - started_at: Option, - duration_listened: Option, - completed: bool, - } - let _: serde_json::Value = self - .post_json( - "/api/player/history", - &Body { - track_id, - started_at, - duration_listened: Some(listened_seconds), - completed, - }, - ) - .await?; - Ok(()) - } - - /// Persist playback state server-side (used for cross-device restore). - pub async fn push_state(&self, state: &PlaybackStateBody) -> Result<(), ApiError> { - let _: serde_json::Value = self.put_json("/api/player/state", state).await?; - Ok(()) - } - - pub async fn poll_device( - &self, - device_id: &str, - playback_state: Option, - ) -> Result { - self.post_json( - "/api/player/devices/poll", - &DevicePollRequest { - device_id, - user_agent: device_user_agent(), - current_jam_id: None, - playback_state, - }, - ) - .await - } - - pub async fn select_device( - &self, - target_device_id: &str, - current_device_id: &str, - ) -> Result { - self.post_json( - "/api/player/devices/active", - &DeviceActiveRequest { - device_id: target_device_id, - current_device_id, - }, - ) - .await - } - - pub async fn send_device_command( - &self, - target_device_id: Option<&str>, - command: &str, - payload: &serde_json::Value, - ) -> Result<(), ApiError> { - let _: serde_json::Value = self - .post_json( - "/api/player/devices/command", - &DeviceCommandRequest { - target_device_id, - jam_id: None, - command, - payload, - }, - ) - .await?; - Ok(()) - } - - /// Revoke this device's session server-side. Best effort: local - /// credentials are deleted regardless of the outcome. - pub async fn logout(&self) -> Result { - let (access_token, refresh_token) = { - let session = self.session.lock().await; - (session.access_token.clone(), session.refresh_token.clone()) - }; - tracing::info!(base_url = %self.base_url, "logout request started"); - let response = self - .http - .post(format!("{}/api/auth/logout", self.base_url)) - .bearer_auth(access_token) - .json(&LogoutRequest { - refresh_token: &refresh_token, - }) - .send() - .await?; - let status = response.status(); - - #[derive(serde::Deserialize)] - struct LogoutResponse { - revoked: bool, - } - let body: LogoutResponse = match parse_response(response).await { - Ok(body) => body, - Err(err) => { - tracing::warn!(base_url = %self.base_url, %status, %err, "logout request failed"); - return Err(err); - } - }; - tracing::info!(base_url = %self.base_url, %status, revoked = body.revoked, "logout request succeeded"); - Ok(body.revoked) - } - - pub async fn get_json(&self, path: &str) -> Result { - self.json_request::<(), T>(reqwest::Method::GET, path, None) - .await - } - - pub async fn post_json( - &self, - path: &str, - body: &B, - ) -> Result { - self.json_request(reqwest::Method::POST, path, Some(body)) - .await - } - - pub async fn put_json( - &self, - path: &str, - body: &B, - ) -> Result { - self.json_request(reqwest::Method::PUT, path, Some(body)) - .await - } - - async fn json_request( - &self, - method: reqwest::Method, - path: &str, - body: Option<&B>, - ) -> Result { - let url = format!("{}{path}", self.base_url); - let response = self - .send_authed(&url, |client, url, token| { - let mut request = client.request(method.clone(), url).bearer_auth(token); - if let Some(body) = body { - request = request.json(body); - } - request - }) - .await; - let response = match response { - Ok(response) => response, - Err(err) => { - tracing::warn!(%err, %method, path, "api request failed"); - return Err(err); - } - }; - let status = response.status(); - let result = parse_response(response).await; - if let Err(err) = &result { - tracing::warn!(%err, %status, %method, path, "api response error"); - } else { - tracing::debug!(%status, %method, path, "api ok"); - } - result - } - - /// Send a request with a fresh bearer token; on 401, refresh once and - /// retry. `build` is called per attempt because RequestBuilder is not - /// reusable after send. - async fn send_authed(&self, url: &str, build: F) -> Result - where - F: Fn(&reqwest::Client, &str, &str) -> reqwest::RequestBuilder, - { - let token = self.fresh_access_token().await?; - let response = build(&self.http, url, &token).send().await?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - tracing::warn!(%url, "authenticated request returned 401; refreshing token and retrying"); - let token = self.refresh_after_rejection(&token).await?; - let response = build(&self.http, url, &token).send().await?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - tracing::warn!(%url, "authenticated request still returned 401 after token refresh"); - } - return Ok(response); - } - Ok(response) - } - - async fn fresh_access_token(&self) -> Result { - let mut session = self.session.lock().await; - let seconds_until_expiry = session.seconds_until_access_expiry(); - if session.access_token_expired() { - tracing::info!( - user_id = session.user.id, - user = %session.user.name, - expires_at_epoch_seconds = session.expires_at_epoch_seconds, - seconds_until_expiry, - "access token expired or near expiry; refreshing" - ); - self.refresh_locked(&mut session).await?; - } - Ok(session.access_token.clone()) - } - - /// A 401 with a token another task already rotated just retries with the - /// current token; otherwise this task performs the refresh itself. - async fn refresh_after_rejection(&self, rejected_token: &str) -> Result { - let mut session = self.session.lock().await; - if session.access_token != rejected_token { - tracing::info!( - user_id = session.user.id, - user = %session.user.name, - "rejected access token was already rotated by another task" - ); - return Ok(session.access_token.clone()); - } - tracing::warn!( - user_id = session.user.id, - user = %session.user.name, - seconds_until_expiry = session.seconds_until_access_expiry(), - "access token was rejected; refreshing" - ); - self.refresh_locked(&mut session).await?; - Ok(session.access_token.clone()) - } - - async fn refresh_locked(&self, session: &mut AuthSession) -> Result<(), ApiError> { - let user_id = session.user.id; - let user = session.user.name.clone(); - let previous_expires_at = session.expires_at_epoch_seconds; - let previous_seconds_until_expiry = session.seconds_until_access_expiry(); - tracing::info!( - user_id, - user = %user, - server = %self.base_url, - previous_expires_at_epoch_seconds = previous_expires_at, - previous_seconds_until_expiry, - "refreshing access token" - ); - let result = refresh_tokens(&self.http, &self.base_url, &session.refresh_token).await; - match result { - Ok(tokens) => { - let expires_in_seconds = tokens.expires_in_seconds; - session.apply_tokens(tokens); - if let Err(err) = auth::save_session(session) { - tracing::warn!(%err, "failed to persist rotated tokens"); - } - tracing::info!( - user_id = session.user.id, - user = %session.user.name, - server = %self.base_url, - expires_in_seconds, - expires_at_epoch_seconds = session.expires_at_epoch_seconds, - seconds_until_expiry = session.seconds_until_access_expiry(), - "access token refreshed" - ); - Ok(()) - } - Err(ApiError::SessionExpired) => { - tracing::warn!( - user_id, - user = %user, - server = %self.base_url, - "refresh token expired or rejected; clearing stored session" - ); - auth::delete_session(); - Err(ApiError::SessionExpired) - } - Err(err) => { - tracing::warn!( - user_id, - user = %user, - server = %self.base_url, - %err, - "access token refresh failed" - ); - Err(err) - } - } - } -} diff --git a/src/api/mod.rs b/src/api/mod.rs deleted file mode 100644 index c44c82c..0000000 --- a/src/api/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod auth; -pub mod client; -pub mod models; diff --git a/src/api/models.rs b/src/api/models.rs deleted file mode 100644 index 3dbffaf..0000000 --- a/src/api/models.rs +++ /dev/null @@ -1,314 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct User { - pub id: i64, - pub name: String, - pub role: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct TokensResponse { - pub access_token: String, - pub refresh_token: String, - pub token_type: String, - pub expires_in_seconds: i64, -} - -#[derive(Debug, Deserialize)] -pub struct LoginResponse { - pub user: User, - pub tokens: TokensResponse, -} - -#[derive(Debug, Deserialize)] -#[allow( - dead_code, - reason = "rendered by the profile view in a later milestone" -)] -pub struct MeStats { - pub liked_tracks: i64, - pub playlists: i64, - pub plays: i64, - pub listened_minutes: i64, -} - -#[derive(Debug, Deserialize)] -#[allow( - dead_code, - reason = "rendered by the profile view in a later milestone" -)] -pub struct MeResponse { - pub id: i64, - pub name: String, - pub role: String, - pub stats: MeStats, -} - -#[derive(Debug, Deserialize)] -pub struct ApiErrorBody { - pub error: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ArtistCard { - #[allow(dead_code, reason = "opens the artist view in the next milestone")] - pub id: i64, - pub name: String, - /// Relative path like `/api/player/cover/{file_id}/medium`. - pub image_url: Option, - pub release_count: i64, - pub track_count: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ArtistRef { - #[allow(dead_code, reason = "navigation to artists from track rows later")] - pub id: i64, - pub name: String, -} - -/// Serialize keeps every field the backend sent us, so device-sync payloads -/// (play_from_index, queue_add) carry full track objects like the web does. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrackItem { - #[allow(dead_code, reason = "playback engine consumes this in milestone 3")] - pub id: i64, - pub title: String, - /// Absent in the artist-appearance variant of track payloads. - #[serde(default)] - pub track_number: Option, - #[serde(default)] - pub disc_number: Option, - pub duration_seconds: f64, - #[serde(default)] - pub artists: Vec, - #[serde(default)] - pub featured_artists: Vec, - #[allow(dead_code, reason = "jump-to-release navigation later")] - #[serde(default)] - pub release_id: i64, - #[serde(default)] - pub release_title: String, - #[allow(dead_code, reason = "shown in queue/now-playing later")] - pub release_year: Option, - /// Server-relative path to `/api/player/stream/{id}`. - #[serde(default)] - pub stream_url: String, - #[allow(dead_code, reason = "now-playing artwork in milestone 3")] - pub cover_url: Option, - #[serde(default)] - pub uploader_name: String, - pub audio_format: Option, - pub audio_bitrate: Option, - pub audio_sample_rate: Option, - pub audio_bit_depth: Option, - pub file_size_bytes: Option, - pub lastfm_listeners: Option, - #[allow(dead_code, reason = "popularity column later")] - pub lastfm_playcount: Option, - pub lastfm_rating: Option, - pub lastfm_updated_at: Option, -} - -impl TrackItem { - pub fn artist_line(&self) -> String { - let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect(); - if !self.featured_artists.is_empty() { - names.push("feat."); - names.extend(self.featured_artists.iter().map(|a| a.name.as_str())); - } - names.join(", ") - } - - pub fn duration_label(&self) -> String { - let total = self.duration_seconds.round() as i64; - format!("{}:{:02}", total / 60, total % 60) - } - - /// Full tech line for the status bar, including the sample rate. - pub fn tech_label_full(&self) -> String { - let mut parts = Vec::new(); - if let Some(format) = &self.audio_format { - parts.push(format.to_uppercase()); - } - if let Some(bitrate) = self.audio_bitrate { - parts.push(format!("{bitrate}kbps")); - } - if let Some(rate) = self.audio_sample_rate { - parts.push(format!("{:.1}kHz", f64::from(rate) / 1000.0)); - } - if let Some(bytes) = self.file_size_bytes { - parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0)); - } - parts.join(" · ") - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ReleaseCard { - pub id: i64, - pub title: String, - pub release_type: String, - pub year: Option, - pub cover_url: Option, - pub track_count: i64, -} - -#[derive(Debug, Deserialize)] -pub struct ArtistDetail { - #[allow(dead_code, reason = "cache key is held by the caller")] - pub id: i64, - pub name: String, - pub image_url: Option, - pub total_track_count: i64, - pub total_play_count: i64, - pub top_tracks: Vec, - pub releases: Vec, - /// Tracks where this artist is featured (the only content for artists - /// without own releases). - #[serde(default)] - pub featured_tracks: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct UploaderSummary { - pub name: String, - #[allow(dead_code, reason = "per-uploader stats for a later detail popup")] - pub track_count: i64, -} - -#[derive(Debug, Deserialize)] -pub struct ReleaseDetail { - #[allow(dead_code, reason = "cache key is held by the caller")] - pub id: i64, - pub title: String, - pub release_type: String, - pub year: Option, - pub cover_url: Option, - pub artists: Vec, - pub tracks: Vec, - pub uploaders: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PlaylistCard { - pub id: i64, - pub title: String, - pub track_count: i64, - pub is_own: bool, - pub owner_name: Option, - pub is_public: bool, - #[allow(dead_code, reason = "save/unsave playlists later")] - pub is_saved: bool, - #[allow(dead_code, reason = "playlist kinds get distinct icons later")] - pub kind: String, -} - -#[derive(Debug, Deserialize)] -pub struct PlaylistDetail { - #[allow(dead_code, reason = "cache key is held by the caller")] - pub id: i64, - pub title: String, - #[allow(dead_code, reason = "shown in a detail header later")] - pub description: Option, - pub tracks: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct LikesResponse { - pub track_ids: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct DeviceDto { - pub id: String, - pub name: String, - pub kind: String, - #[allow(dead_code, reason = "server-side flag; we compare ids directly")] - pub is_current: bool, - pub is_active: bool, - #[allow(dead_code, reason = "freshness display later")] - pub last_seen_ms: i64, -} - -#[derive(Debug, Deserialize)] -pub struct DeviceCommandDto { - #[allow(dead_code, reason = "commands are applied in poll order")] - pub id: Option, - pub command: String, - #[serde(default)] - pub payload: serde_json::Value, -} - -/// Mirrors the backend's PlayerDevicePlaybackStateDto; tracks stay raw JSON -/// so unknown fields survive the round trip between clients. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct DevicePlaybackState { - #[serde(default)] - pub track: Option, - #[serde(default)] - pub tracks: Vec, - #[serde(default)] - pub index: i32, - #[serde(default)] - pub position_seconds: f64, - #[serde(default)] - pub duration_seconds: f64, - #[serde(default)] - pub paused: bool, - #[serde(default)] - pub shuffle: bool, - #[serde(default = "default_repeat_mode")] - pub repeat_mode: String, - #[serde(default = "default_volume")] - pub volume: f64, - #[serde(default)] - pub updated_at_ms: i64, -} - -fn default_repeat_mode() -> String { - "off".to_string() -} - -fn default_volume() -> f64 { - 1.0 -} - -#[derive(Debug, Deserialize)] -pub struct DevicePollResponse { - #[allow(dead_code, reason = "echo of our own id")] - pub device_id: String, - pub active_device_id: Option, - #[serde(default)] - pub devices: Vec, - #[serde(default)] - pub commands: Vec, - #[serde(default)] - #[allow(dead_code, reason = "Jam control is out of scope for the TUI v1")] - pub current_jam_id: Option, - pub playback_state: Option, -} - -#[derive(Debug, Default, Deserialize)] -pub struct SearchResults { - pub artists: Vec, - pub releases: Vec, - pub tracks: Vec, -} - -impl SearchResults { - pub fn len(&self) -> usize { - self.artists.len() + self.releases.len() + self.tracks.len() - } -} - -#[derive(Debug, Deserialize)] -pub struct ArtistsPage { - pub items: Vec, - pub total: i64, - pub page: i64, - #[allow(dead_code, reason = "part of the server pagination envelope")] - pub per_page: i64, - pub has_more: bool, -} diff --git a/src/app/action.rs b/src/app/action.rs index 2065087..a24a51f 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -39,10 +39,10 @@ pub enum Action { NewPlaylist, ToggleHelp, ToggleViewMode, - OpenDevices, OpenCommandLine, OpenSearch, - Logout, + EditSelected, + DeleteSelected, } /// Help-window sections, in display order. @@ -50,15 +50,17 @@ pub enum Action { pub enum Category { Playback, Queue, + Library, Navigation, Search, System, } impl Category { - pub const ALL: [Category; 5] = [ + pub const ALL: [Category; 6] = [ Category::Playback, Category::Queue, + Category::Library, Category::Navigation, Category::Search, Category::System, @@ -68,6 +70,7 @@ impl Category { match self { Category::Playback => "Playback", Category::Queue => "Queue & playlists", + Category::Library => "Library", Category::Navigation => "Navigation", Category::Search => "Search & commands", Category::System => "System", @@ -111,9 +114,9 @@ impl Action { | Action::GoToTab(_) | Action::GoToRelease | Action::ToggleViewMode => Category::Navigation, + Action::EditSelected | Action::DeleteSelected => Category::Library, Action::OpenSearch | Action::OpenCommandLine => Category::Search, - Action::OpenDevices => Category::System, - Action::ToggleHelp | Action::Logout | Action::Quit => Category::System, + Action::ToggleHelp | Action::Quit => Category::System, } } @@ -121,7 +124,7 @@ impl Action { pub fn command_hint(&self) -> Option<&'static str> { match self { Action::Quit => Some(":q"), - Action::Logout => Some(":logout"), + Action::EditSelected => Some(":import adds files"), Action::PlayPause => Some(":play"), Action::NextTrack => Some(":next"), Action::PrevTrack => Some(":prev"), @@ -130,7 +133,6 @@ impl Action { Action::ToggleShuffle => Some(":shuffle"), Action::CycleRepeat => Some(":repeat [off|one|all]"), Action::ClearQueue => Some(":clear"), - Action::OpenDevices => Some(":devices"), Action::ToggleHelp => Some(":help"), Action::OpenSearch => Some("/text"), _ => None, @@ -174,10 +176,10 @@ impl Action { Action::NewPlaylist => "Create a playlist".into(), Action::ToggleHelp => "Show / hide keybindings".into(), Action::ToggleViewMode => "Toggle tiles / table view".into(), - Action::OpenDevices => "Connected devices".into(), Action::OpenCommandLine => "Command line (:help for commands)".into(), Action::OpenSearch => "Search artists, releases, tracks".into(), - Action::Logout => "Sign out".into(), + Action::EditSelected => "Edit the selected item".into(), + Action::DeleteSelected => "Delete the selected item".into(), } } } diff --git a/src/app/cmdline.rs b/src/app/cmdline.rs index 06638f8..ab2e9f0 100644 --- a/src/app/cmdline.rs +++ b/src/app/cmdline.rs @@ -4,7 +4,6 @@ use std::time::Duration; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use crate::api::client::ApiError; use crate::app::Runtime; use crate::app::command::{self, Command, Parsed}; use crate::app::event::AppEvent; @@ -59,7 +58,7 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) { match command { // One-shot commands have no live effect. Command::Quit - | Command::Logout + | Command::Import(_) | Command::Volume(_) | Command::Seek(_) | Command::SeekTo(_) @@ -70,7 +69,6 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) { | Command::Prev | Command::PlayPause | Command::Help - | Command::Devices | Command::Logs(_) => {} Command::Search(query) => { state.active_tab = Tab::Global; @@ -96,17 +94,17 @@ fn set_view_cursor_zero(state: &mut AppState) { /// Debounced, race-free search: every edit bumps the global sequence; the /// spawned task only queries if it is still the latest after the debounce, /// and the receiver drops responses that arrive out of date. -fn schedule_search(state: &mut AppState, runtime: &Runtime) { +pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) { let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1; let query = state.search.query.clone(); if query.is_empty() { state.search.loading = false; state.search.results = None; + state.search.fed_tracks.clear(); + state.search.fed_loading = false; return; } - let Some(api) = runtime.api.clone() else { - return; - }; + let library = Arc::clone(&runtime.library); state.search.loading = true; let tx = runtime.event_tx.clone(); let latest = Arc::clone(&runtime.search_seq); @@ -115,19 +113,32 @@ fn schedule_search(state: &mut AppState, runtime: &Runtime) { if latest.load(Ordering::SeqCst) != seq { return; } - let event = match api.search(&query, SEARCH_LIMIT).await { - Ok(results) => AppEvent::SearchLoaded { - seq, - result: Ok(results), - }, - Err(ApiError::SessionExpired) => AppEvent::SessionExpired, - Err(err) => AppEvent::SearchLoaded { - seq, - result: Err(err.to_string()), - }, - }; - let _ = tx.send(event); + let result = tokio::task::spawn_blocking(move || library.search(&query, SEARCH_LIMIT)) + .await + .map_err(|err| err.to_string()) + .and_then(|result| result.map_err(|err| format!("{err:#}"))); + let _ = tx.send(AppEvent::SearchLoaded { seq, result }); }); + + // The same query also runs against the federated network (when the + // node is up); its results render as a separate, marked section. + state.search.fed_tracks.clear(); + state.search.fed_loading = false; + if runtime.federation.settings().enabled { + state.search.fed_loading = true; + let fed = Arc::clone(&runtime.federation); + let query = state.search.query.clone(); + let tx = runtime.event_tx.clone(); + let latest = Arc::clone(&runtime.search_seq); + tokio::spawn(async move { + tokio::time::sleep(SEARCH_DEBOUNCE).await; + if latest.load(Ordering::SeqCst) != seq { + return; + } + let result = fed.search(&query).await.map_err(|err| format!("{err:#}")); + let _ = tx.send(AppEvent::FedSearchLoaded { seq, result }); + }); + } } /// Enter: close the line. Live commands already took effect (their view @@ -161,7 +172,7 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) { match command { Command::Search(_) => {} Command::Quit => state.should_quit = true, - Command::Logout => super::perform_logout(state, runtime), + Command::Import(path) => super::spawn_import(state, runtime, &path), Command::Volume(value) => { state.player.volume = value; super::perform_effect(state, runtime, Effect::SetVolume(value)); @@ -194,7 +205,6 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) { Command::Prev => run_action(state, runtime, Action::PrevTrack), Command::PlayPause => run_action(state, runtime, Action::PlayPause), Command::Help => state.help_visible = true, - Command::Devices => run_action(state, runtime, Action::OpenDevices), Command::Logs(level) => { if let Some(index) = level { state.logs.level_index = index.min(LOG_LEVELS.len() - 1); diff --git a/src/app/command.rs b/src/app/command.rs index ba54d70..1b276b2 100644 --- a/src/app/command.rs +++ b/src/app/command.rs @@ -22,8 +22,9 @@ pub enum Command { /// `:q` / `:quit` — exit immediately (explicit enough to skip the /// double-press confirmation). Quit, - /// `:logout` — sign out and return to the login screen. - Logout, + /// `:import ` — import an audio file or a directory into the + /// library. + Import(String), /// `:volume 40` (also `:vol`) — set the volume precisely. Volume(u8), /// `:seek +30` / `:seek -10` — relative seek in seconds. @@ -43,8 +44,6 @@ pub enum Command { PlayPause, /// `:help` — open the keybinding help. Help, - /// `:devices` — open the connected-devices picker. - Devices, /// `:logs [error|warn|info|debug|trace]` — jump to the Logs tab, /// optionally setting the severity filter. Logs(Option), @@ -74,7 +73,15 @@ pub fn parse(input: &str) -> Parsed { let arg = parts.next(); match name { "q" | "quit" => Parsed::Command(Command::Quit), - "logout" => Parsed::Command(Command::Logout), + "import" | "add" => { + let path = input.trim_start().split_once(char::is_whitespace); + match path.map(|(_, rest)| rest.trim()) { + Some(path) if !path.is_empty() => { + Parsed::Command(Command::Import(path.to_string())) + } + _ => Parsed::Invalid("usage: :import ".to_string()), + } + } "volume" | "vol" => match arg.and_then(|a| a.parse::().ok()) { Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)), _ => Parsed::Invalid("usage: :volume 0-100".to_string()), @@ -96,7 +103,6 @@ pub fn parse(input: &str) -> Parsed { "prev" => Parsed::Command(Command::Prev), "pause" | "play" => Parsed::Command(Command::PlayPause), "help" => Parsed::Command(Command::Help), - "devices" | "device" => Parsed::Command(Command::Devices), "logs" => match arg { None => Parsed::Command(Command::Logs(None)), Some(level) => match ["error", "warn", "info", "debug", "trace"] @@ -152,7 +158,11 @@ mod tests { fn parses_word_commands() { assert_eq!(parse("q"), Parsed::Command(Command::Quit)); assert_eq!(parse("quit"), Parsed::Command(Command::Quit)); - assert_eq!(parse("logout"), Parsed::Command(Command::Logout)); + assert_eq!( + parse("import ~/Music/My Album"), + Parsed::Command(Command::Import("~/Music/My Album".to_string())) + ); + assert!(matches!(parse("import"), Parsed::Invalid(_))); assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40))); assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0))); assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle)); @@ -162,7 +172,6 @@ mod tests { Parsed::Command(Command::Repeat(Some(RepeatArg::All))) ); assert_eq!(parse("clear"), Parsed::Command(Command::ClearQueue)); - assert_eq!(parse("devices"), Parsed::Command(Command::Devices)); assert_eq!(parse("logs debug"), Parsed::Command(Command::Logs(Some(3)))); } diff --git a/src/app/event.rs b/src/app/event.rs index 6e80fd5..5070c8d 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -1,25 +1,24 @@ use std::sync::Arc; -use crate::api::auth::AuthSession; -use crate::api::models::{ - ArtistDetail, ArtistsPage, DevicePollResponse, PlaylistCard, PlaylistDetail, ReleaseDetail, - SearchResults, TrackItem, -}; use crate::art::ArtImage; +use crate::library::models::{ + ArtistDetail, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults, + TrackItem, +}; -/// Events delivered to the main loop by background tasks (API fetches, the -/// playback engine, device sync). Tasks never touch AppState directly. +/// Events delivered to the main loop by background tasks (library queries, +/// the playback engine, imports). Tasks never touch AppState directly. #[derive(Debug)] pub enum AppEvent { StatusMessage(String), - LoginSucceeded(Box), - LoginFailed(String), - /// Loopback listener received the browser SSO callback. - SsoCallback(Result), - /// Refresh token rejected — stored credentials were deleted. - SessionExpired, - /// A page of the Global artists list arrived (or failed). + /// A page of the artists list arrived (or failed). ArtistsLoaded(Result), + /// A full reload after a library change: replaces the loaded artist + /// list wholesale, so the grid never flashes empty. + ArtistsReloaded { + page: ArtistsPage, + limit: i64, + }, ArtistViewLoaded { id: i64, result: Result, @@ -33,7 +32,7 @@ pub enum AppEvent { seq: u64, result: Result, }, - /// Artwork fetched and decoded for the shared art cache. + /// Artwork loaded and decoded for the shared art cache. ArtLoaded { key: String, art: Option>, @@ -41,7 +40,7 @@ pub enum AppEvent { Player(crate::player::PlayerEvent), /// A command from the OS media keys. Media(crate::media::MediaCommand), - /// Gapless prefetch could not open the stream; the normal track-switch + /// Gapless prefetch could not open the file; the normal track-switch /// path takes over when the current track ends. PrefetchFailed { pos: usize, @@ -57,11 +56,6 @@ pub enum AppEvent { track_id: i64, liked: bool, }, - /// Connected-devices poll result; carries device list, active id, - /// remote playback state and commands for this TUI. - DevicesPolled(Result), - /// Response from switching the active device. - DeviceActivated(Result), /// A release fetched for queueing (a / shift-a on a release). EnqueueTracks { tracks: Vec, @@ -77,4 +71,33 @@ pub enum AppEvent { playlist_title: String, result: Result<(), String>, }, + /// The library was mutated (import, edit, delete): cached views must be + /// dropped and reloaded lazily. + LibraryChanged { + message: Option, + }, + /// Progress of a running import, shown in the status bar. + ImportProgress { + done: usize, + total: usize, + current: String, + }, + /// Fresh copies of the queued tracks after a library change. Tracks + /// missing from the result were deleted and leave the queue. + QueueTracksRefreshed { + tracks: Vec, + }, + /// A status snapshot for the Federation tab. + FederationStatus(crate::federation::FedStatus), + /// Tracks found on the federated network for the live search. + FedSearchLoaded { + seq: u64, + result: Result, String>, + }, + /// A federated track finished downloading and is ready to play. + FedPlayReady { + result: Result, + }, + /// This peer's connection ticket, requested from the Federation tab. + FedTicket(Result), } diff --git a/src/app/login.rs b/src/app/login.rs deleted file mode 100644 index efde19f..0000000 --- a/src/app/login.rs +++ /dev/null @@ -1,221 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -use crate::api::{auth, client}; -use crate::app::Runtime; -use crate::app::event::AppEvent; -use crate::app::sso; -use crate::app::state::{AppState, LoginField, LoginForm, LoginMode}; - -pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { - if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { - state.should_quit = true; - return; - } - let form = &mut state.login; - if form.busy { - return; - } - match form.mode { - LoginMode::Form => handle_form_key(form, runtime, key), - LoginMode::SsoPending => handle_sso_key(form, runtime, key), - } -} - -/// Bracketed paste goes into whichever text field is focused. -pub fn handle_paste(state: &mut AppState, pasted: &str) { - let form = &mut state.login; - if form.busy { - return; - } - let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect(); - if let Some(field) = focused_text(form) { - field.push_str(&cleaned); - } -} - -fn handle_form_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) { - match key.code { - KeyCode::Tab | KeyCode::Down => form.focus = form.focus.next(), - KeyCode::BackTab | KeyCode::Up => form.focus = form.focus.prev(), - KeyCode::Backspace => { - if let Some(field) = focused_text(form) { - field.pop(); - } - } - KeyCode::Enter => match form.focus { - LoginField::ServerUrl | LoginField::Username => form.focus = form.focus.next(), - LoginField::Password | LoginField::SignInButton => { - submit_password(form, runtime); - } - LoginField::SsoButton => start_sso(form, runtime), - }, - KeyCode::Char(c) if is_typing(key) => { - if let Some(field) = focused_text(form) { - field.push(c); - } - } - _ => {} - } -} - -fn handle_sso_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) { - // Ctrl-shortcuts first: plain letters belong to the paste field. - if key.modifiers.contains(KeyModifiers::CONTROL) { - match key.code { - // Copy the full SSO URL — terminals can't copy a wrapped link - // in one piece, the clipboard can. - KeyCode::Char('l') => { - form.error = None; - match copy_to_clipboard(&form.sso_url) { - Ok(()) => form.error = Some("link copied to clipboard".to_string()), - Err(err) => { - tracing::warn!(%err, "clipboard copy failed"); - form.error = Some(format!("copy failed: {err}")); - } - } - } - KeyCode::Char('o') => { - if let Err(err) = open::that_detached(&form.sso_url) { - tracing::warn!(%err, "failed to reopen browser"); - form.error = Some("couldn't open a browser".to_string()); - } - } - _ => {} - } - return; - } - match key.code { - KeyCode::Esc => { - if let Some(listener) = runtime.sso.take() { - listener.abort(); - } - form.mode = LoginMode::Form; - form.sso_paste.clear(); - form.error = None; - } - KeyCode::Backspace => { - form.sso_paste.pop(); - } - KeyCode::Enter => submit_sso_code(form, runtime), - KeyCode::Char(c) if is_typing(key) => form.sso_paste.push(c), - _ => {} - } -} - -fn copy_to_clipboard(text: &str) -> Result<(), arboard::Error> { - arboard::Clipboard::new()?.set_text(text.to_string()) -} - -fn is_typing(key: KeyEvent) -> bool { - key.modifiers.difference(KeyModifiers::SHIFT).is_empty() -} - -fn focused_text(form: &mut LoginForm) -> Option<&mut String> { - if form.mode == LoginMode::SsoPending { - return Some(&mut form.sso_paste); - } - match form.focus { - LoginField::ServerUrl => Some(&mut form.server_url), - LoginField::Username => Some(&mut form.username), - LoginField::Password => Some(&mut form.password), - LoginField::SignInButton | LoginField::SsoButton => None, - } -} - -fn submit_password(form: &mut LoginForm, runtime: &Runtime) { - form.error = None; - let base_url = match auth::normalize_base_url(&form.server_url) { - Ok(url) => url, - Err(err) => return form.error = Some(err.to_string()), - }; - let username = form.username.trim().to_string(); - if username.is_empty() { - return form.error = Some("enter a username".to_string()); - } - if form.password.is_empty() { - return form.error = Some("enter a password".to_string()); - } - form.server_url = base_url.clone(); - form.busy = true; - - let password = form.password.clone(); - let http = runtime.http.clone(); - let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let result = client::login_password(&http, &base_url, &username, &password).await; - let _ = tx.send(login_event(result)); - }); -} - -fn start_sso(form: &mut LoginForm, runtime: &mut Runtime) { - form.error = None; - let base_url = match auth::normalize_base_url(&form.server_url) { - Ok(url) => url, - Err(err) => return form.error = Some(err.to_string()), - }; - form.server_url = base_url.clone(); - form.sso_paste.clear(); - - // Preferred flow: loopback listener, the browser redirect finishes the - // login hands-free. Fallback: furumi:// deep link + manual code paste. - if let Some(listener) = runtime.sso.take() { - listener.abort(); - } - match sso::start(runtime.event_tx.clone()) { - Ok(listener) => { - let redirect = format!("http://127.0.0.1:{}/callback", listener.port); - form.sso_url = client::sso_start_url(&base_url, &redirect); - form.sso_port = Some(listener.port); - runtime.sso = Some(listener); - } - Err(err) => { - tracing::warn!(%err, "loopback listener unavailable, falling back to manual paste"); - form.sso_url = client::sso_start_url(&base_url, "furumi://auth/callback"); - form.sso_port = None; - } - } - - form.mode = LoginMode::SsoPending; - if let Err(err) = open::that_detached(&form.sso_url) { - tracing::warn!(%err, "failed to open browser for SSO"); - form.error = Some("couldn't open a browser — use the URL below".to_string()); - } -} - -fn submit_sso_code(form: &mut LoginForm, runtime: &Runtime) { - form.error = None; - let code = match auth::extract_sso_code(&form.sso_paste) { - Ok(code) => code, - Err(err) => return form.error = Some(err.to_string()), - }; - spawn_sso_exchange(form, runtime, code); -} - -/// Used by both the manual paste path and the loopback callback event. -pub fn spawn_sso_exchange(form: &mut LoginForm, runtime: &Runtime, code: String) { - let base_url = form.server_url.clone(); - form.busy = true; - - let http = runtime.http.clone(); - let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let result = client::login_sso_exchange(&http, &base_url, &code).await; - let _ = tx.send(login_event(result)); - }); -} - -fn login_event(result: Result) -> AppEvent { - match result { - Ok(session) => { - tracing::info!(user = %session.user.name, server = %session.server_base_url, "signed in"); - if let Err(err) = auth::save_session(&session) { - tracing::warn!(%err, "failed to persist credentials"); - } - AppEvent::LoginSucceeded(Box::new(session)) - } - Err(err) => { - tracing::warn!(%err, "login failed"); - AppEvent::LoginFailed(err.to_string()) - } - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs index eedd3ab..d5bcb84 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,14 +2,13 @@ pub mod action; mod cmdline; pub mod command; pub mod event; -mod login; mod popup; -mod sso; pub mod state; pub mod update; +use std::path::PathBuf; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; use anyhow::Result; use crokey::KeyCombination; @@ -19,41 +18,42 @@ use ratatui::DefaultTerminal; use tokio::sync::mpsc; use tokio::time::MissedTickBehavior; -use crate::api::auth; -use crate::api::client::{ApiClient, ApiError, http_client}; use crate::config::keymap::{KeyResolution, Keymap}; +use crate::library::Library; use crate::player; use crate::ui; use event::AppEvent; -use state::{AppState, Screen}; +use state::AppState; use update::{Effect, update}; const TICK_INTERVAL: Duration = Duration::from_millis(250); -const DEVICE_POLL_INTERVAL: Duration = Duration::from_millis(500); -// A paused StreamDownload can later issue Range requests with the bearer it -// captured at open time. Reopen after idle so resume gets a freshly refreshed -// token instead of reviving a stale HTTP stream. -const STALE_STREAM_PAUSE_REOPEN_AFTER: Duration = Duration::from_secs(30); /// Handles shared by background tasks; AppState stays pure UI data. pub struct Runtime { pub event_tx: mpsc::UnboundedSender, - pub http: reqwest::Client, - pub api: Option>, - pub sso: Option, - /// Caps concurrent artwork downloads so they never starve API calls. + pub library: Arc, + pub federation: Arc, + /// When the last Federation-tab status snapshot was requested. + pub fed_status_at: Option, + /// Caps concurrent artwork loads so they never starve the disk. pub art_semaphore: Arc, /// Monotonic sequence for live search; stale responses are dropped. pub search_seq: Arc, pub player: player::Controller, pub player_start_pending: bool, - pub player_paused_since: Option, - pub last_state_push: Option, pub media_tx: std::sync::mpsc::Sender, pub last_media_push: Option, - pub device_id: String, - pub last_device_poll: Option, - pub device_poll_in_flight: bool, +} + +fn now_epoch_seconds() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn err_string(err: anyhow::Error) -> String { + format!("{err:#}") } pub async fn run( @@ -64,42 +64,41 @@ pub async fn run( mut event_rx: mpsc::UnboundedReceiver, media_tx: std::sync::mpsc::Sender, ) -> Result<()> { - let device_id = crate::config::load_or_create_device_id(); + let db_path = crate::library::default_db_path()?; + let library = Arc::new(Library::open(&db_path)?); + tracing::info!(path = %db_path.display(), "library opened"); + let mut state = AppState { status_message: startup_warning, ..AppState::default() }; - state.devices.device_id = device_id.clone(); + let federation = crate::federation::Federation::new(Arc::clone(&library)); + state.federation.settings = federation.settings(); let player_events = event_tx.clone(); let mut runtime = Runtime { event_tx, - http: http_client(), - api: None, - sso: None, + library, + federation, + fed_status_at: None, art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)), search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)), player: player::spawn(move |event| { let _ = player_events.send(AppEvent::Player(event)); }), player_start_pending: false, - player_paused_since: None, - last_state_push: None, media_tx, last_media_push: None, - device_id, - last_device_poll: None, - device_poll_in_flight: false, }; - match auth::load_session() { - Some(session) => { - state.user = Some(session.user.clone()); - let api = Arc::new(ApiClient::new(runtime.http.clone(), session)); - runtime.api = Some(Arc::clone(&api)); - spawn_session_check(&runtime, api); - } - None => state.screen = Screen::Login, + { + let fed = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + fed.start_if_enabled().await; + let status = fed.status().await; + let _ = tx.send(AppEvent::FederationStatus(status)); + }); } let mut input = EventStream::new(); @@ -118,32 +117,17 @@ pub async fn run( Some(app_event) = event_rx.recv() => handle_app_event(&mut state, &mut runtime, app_event), _ = tick.tick() => { expire_quit_confirmation(&mut state); - if state.player.current.is_some() - && state.devices.is_playback_device() - && !runtime.player_start_pending - { + if state.player.current.is_some() && !runtime.player_start_pending { state.player.position_secs = runtime.player.shared.position().as_secs_f64(); state.player.paused = runtime.player.shared.paused(); - } else if state.player.current.is_some() - && state.player.playing - && !state.player.paused - { - state.player.position_secs += TICK_INTERVAL.as_secs_f64(); - if let Some(track) = &state.player.current { - if track.duration_seconds > 0.0 { - state.player.position_secs = - state.player.position_secs.min(track.duration_seconds); - } - } } - maybe_poll_devices(&state, &mut runtime); maybe_prefetch_next(&mut state, &runtime); - maybe_push_state(&state, &mut runtime); push_media_update(&state, &mut runtime, false); } } if state.should_quit { + runtime.federation.shutdown().await; return Ok(()); } maintenance(&mut state, &mut runtime); @@ -165,13 +149,6 @@ fn artist_grid_capacity() -> usize { /// state needs — the first artists page, the next page when the selection /// nears the end, and artwork for loaded artists. fn maintenance(state: &mut AppState, runtime: &mut Runtime) { - let Some(api) = runtime.api.clone() else { - return; - }; - if state.screen != Screen::Main { - return; - } - { let global = &mut state.global; // Keep at least a full screen plus a margin loaded, and stay ahead @@ -181,6 +158,7 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { + ARTISTS_PREFETCH_MARGIN; if global.has_more && !global.loading + && !global.reloading && global.error.is_none() && global.artists.len() < needed { @@ -189,15 +167,11 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { let limit = *global .page_limit .get_or_insert_with(|| (needed as i64).clamp(48, 200)); - let api = Arc::clone(&api); + let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let event = match api.artists(page, limit).await { - Ok(page) => AppEvent::ArtistsLoaded(Ok(page)), - Err(ApiError::SessionExpired) => AppEvent::SessionExpired, - Err(err) => AppEvent::ArtistsLoaded(Err(err.to_string())), - }; - let _ = tx.send(event); + tokio::task::spawn_blocking(move || { + let result = library.artists(page, limit).map_err(err_string); + let _ = tx.send(AppEvent::ArtistsLoaded(result)); }); } } @@ -205,15 +179,11 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { // Liked ids load once per session — markers are shown everywhere. if !state.likes_loaded { state.likes_loaded = true; - let api = Arc::clone(&api); + let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let event = match api.likes().await { - Ok(ids) => AppEvent::LikesLoaded(Ok(ids)), - Err(ApiError::SessionExpired) => AppEvent::SessionExpired, - Err(err) => AppEvent::LikesLoaded(Err(err.to_string())), - }; - let _ = tx.send(event); + tokio::task::spawn_blocking(move || { + let result = library.likes().map_err(err_string); + let _ = tx.send(AppEvent::LikesLoaded(result)); }); } @@ -223,15 +193,11 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { if state.active_tab == state::Tab::Playlists || picker_open { if state.playlists.list.is_none() { state.playlists.list = Some(state::Loadable::Loading); - let api = Arc::clone(&api); + let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let event = match api.playlists().await { - Ok(list) => AppEvent::PlaylistsLoaded(Ok(list)), - Err(ApiError::SessionExpired) => AppEvent::SessionExpired, - Err(err) => AppEvent::PlaylistsLoaded(Err(err.to_string())), - }; - let _ = tx.send(event); + tokio::task::spawn_blocking(move || { + let result = library.playlists().map_err(err_string); + let _ = tx.send(AppEvent::PlaylistsLoaded(result)); }); } if let Some(opened) = state.playlists.opened { @@ -239,21 +205,11 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { if let std::collections::hash_map::Entry::Vacant(entry) = state.playlist_views.entry(id) { entry.insert(state::Loadable::Loading); - let api = Arc::clone(&api); + let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let event = match api.playlist(id).await { - Ok(detail) => AppEvent::PlaylistViewLoaded { - id, - result: Ok(detail), - }, - Err(ApiError::SessionExpired) => AppEvent::SessionExpired, - Err(err) => AppEvent::PlaylistViewLoaded { - id, - result: Err(err.to_string()), - }, - }; - let _ = tx.send(event); + tokio::task::spawn_blocking(move || { + let result = library.playlist(id).map_err(err_string); + let _ = tx.send(AppEvent::PlaylistViewLoaded { id, result }); }); } } @@ -267,21 +223,11 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { state.artist_views.entry(id) { entry.insert(state::Loadable::Loading); - let api = Arc::clone(&api); + let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let event = match api.artist(id).await { - Ok(detail) => AppEvent::ArtistViewLoaded { - id, - result: Ok(detail), - }, - Err(ApiError::SessionExpired) => AppEvent::SessionExpired, - Err(err) => AppEvent::ArtistViewLoaded { - id, - result: Err(err.to_string()), - }, - }; - let _ = tx.send(event); + tokio::task::spawn_blocking(move || { + let result = library.artist(id).map_err(err_string); + let _ = tx.send(AppEvent::ArtistViewLoaded { id, result }); }); } } @@ -290,21 +236,11 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { state.release_views.entry(id) { entry.insert(state::Loadable::Loading); - let api = Arc::clone(&api); + let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let event = match api.release(id).await { - Ok(detail) => AppEvent::ReleaseViewLoaded { - id, - result: Ok(detail), - }, - Err(ApiError::SessionExpired) => AppEvent::SessionExpired, - Err(err) => AppEvent::ReleaseViewLoaded { - id, - result: Err(err.to_string()), - }, - }; - let _ = tx.send(event); + tokio::task::spawn_blocking(move || { + let result = library.release(id).map_err(err_string); + let _ = tx.send(AppEvent::ReleaseViewLoaded { id, result }); }); } } @@ -312,157 +248,94 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { } } + // Refresh the Federation tab status while it is visible. + if state.active_tab == state::Tab::Federation { + let due = runtime + .fed_status_at + .is_none_or(|at| at.elapsed() > Duration::from_secs(2)); + if due { + runtime.fed_status_at = Some(std::time::Instant::now()); + fed_spawn_status(runtime); + } + } + // Artwork wanted by everything currently loaded, at its display size. let mut wanted: Vec<(String, u16, u16)> = Vec::new(); let tile = (state::ART_CELL_WIDTH, state::ART_CELL_HEIGHT); let header = (state::ART_HEADER_WIDTH, state::ART_HEADER_HEIGHT); for artist in &state.global.artists { - if let Some(url) = &artist.image_url { - wanted.push((url.clone(), tile.0, tile.1)); + if let Some(path) = &artist.image_path { + wanted.push((path.clone(), tile.0, tile.1)); } } for detail in state.artist_views.values() { if let state::Loadable::Ready(detail) = detail { - if let Some(url) = &detail.image_url { - wanted.push((url.clone(), header.0, header.1)); + if let Some(path) = &detail.image_path { + wanted.push((path.clone(), header.0, header.1)); } for release in &detail.releases { - if let Some(url) = &release.cover_url { - wanted.push((url.clone(), tile.0, tile.1)); + if let Some(path) = &release.cover_path { + wanted.push((path.clone(), tile.0, tile.1)); } } } } for detail in state.release_views.values() { - if let state::Loadable::Ready(detail) = detail { - if let Some(url) = &detail.cover_url { - wanted.push((url.clone(), header.0, header.1)); + if let state::Loadable::Ready(detail) = detail + && let Some(path) = &detail.cover_path { + wanted.push((path.clone(), header.0, header.1)); } - } } - for (url, width, height) in wanted { - let key = crate::art::cache_key(&url, width, height); + for (path, width, height) in wanted { + let key = crate::art::cache_key(&path, width, height); if state.art.contains_key(&key) { continue; } state.art.insert(key.clone(), state::ArtState::Loading); - spawn_art_fetch(runtime, Arc::clone(&api), key, url, width, height); + spawn_art_fetch(runtime, key, path, width, height); } } -fn maybe_poll_devices(state: &AppState, runtime: &mut Runtime) { - if state.screen != Screen::Main || runtime.device_poll_in_flight { - return; - } - let Some(api) = runtime.api.clone() else { - return; - }; - let due = runtime - .last_device_poll - .is_none_or(|at| at.elapsed() >= DEVICE_POLL_INTERVAL); - if !due { - return; - } - - runtime.last_device_poll = Some(std::time::Instant::now()); - runtime.device_poll_in_flight = true; - let device_id = runtime.device_id.clone(); - let playback_state = state - .devices - .is_playback_device() - .then(|| device_playback_state(state)) - .flatten(); - let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let event = match api.poll_device(&device_id, playback_state).await { - Ok(response) => AppEvent::DevicesPolled(Ok(response)), - Err(ApiError::SessionExpired) => AppEvent::SessionExpired, - Err(err) => AppEvent::DevicesPolled(Err(err.to_string())), - }; - let _ = tx.send(event); - }); -} - -fn device_playback_state(state: &AppState) -> Option { - let player = &state.player; - let current = player.current.as_ref()?; - if player.queue.is_empty() { - return None; - } - Some(crate::api::models::DevicePlaybackState { - track: serde_json::to_value(current).ok(), - tracks: player - .queue - .iter() - .filter_map(|track| serde_json::to_value(track).ok()) - .collect(), - index: player.queue_pos as i32, - position_seconds: player.position_secs, - duration_seconds: current.duration_seconds, - paused: player.paused || !player.playing, - shuffle: player.shuffle, - repeat_mode: player.repeat.label().to_string(), - volume: f64::from(player.volume) / 100.0, - updated_at_ms: auth::now_epoch_millis(), - }) -} - -fn spawn_art_fetch( - runtime: &Runtime, - api: Arc, - key: String, - url: String, - width: u16, - height: u16, -) { +/// Load and decode a local image file for the art cache. +fn spawn_art_fetch(runtime: &Runtime, key: String, path: String, width: u16, height: u16) { let tx = runtime.event_tx.clone(); let semaphore = Arc::clone(&runtime.art_semaphore); tokio::spawn(async move { let Ok(_permit) = semaphore.acquire_owned().await else { return; }; - let art = match api.get_bytes(&url).await { - Ok(bytes) => tokio::task::spawn_blocking(move || { - crate::art::decode_to_cells(&bytes, width, height) - }) - .await - .map_err(anyhow::Error::from) - .and_then(|r| r) - .map_err(|err| tracing::warn!(%err, url, "artwork decode failed")) - .ok() - .map(Arc::new), - Err(err) => { - tracing::warn!(%err, url, "artwork fetch failed"); - None - } - }; + let art = tokio::task::spawn_blocking(move || -> anyhow::Result { + let bytes = std::fs::read(&path)?; + crate::art::decode_to_cells(&bytes, width, height) + }) + .await + .map_err(anyhow::Error::from) + .and_then(|result| result) + .map_err(|err| tracing::warn!(%err, "artwork load failed")) + .ok() + .map(Arc::new); let _ = tx.send(AppEvent::ArtLoaded { key, art }); }); } /// Execute a side effect requested by update(). fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { - if perform_remote_effect(state, runtime, &effect) { - return; - } match effect { Effect::PlayCurrent => { play_current(state, runtime); - push_state_now(state, runtime); push_media_metadata(state, runtime); push_media_update(state, runtime, true); } Effect::TogglePause => { if state.player.paused { - pause_current_audio(state, runtime); + runtime.player.pause(); } else { - resume_current_audio(state, runtime); + runtime.player.resume(); } push_media_update(state, runtime, true); } Effect::StopPlayback => { runtime.player_start_pending = false; - runtime.player_paused_since = None; runtime.player.stop(); push_media_update(state, runtime, true); } @@ -474,52 +347,95 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { .seek(std::time::Duration::from_secs_f64(target)); } Effect::SetVolume(volume) => runtime.player.set_volume(player::amplitude(volume)), - Effect::SetOptions => { - push_state_now(state, runtime); - } + Effect::SetOptions => {} Effect::EnqueueRelease { id, next } => { - let Some(api) = runtime.api.clone() else { - return; - }; + let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - match api.release(id).await { - Ok(detail) => { - let _ = tx.send(AppEvent::EnqueueTracks { - tracks: detail.tracks, - next, - }); - } - Err(err) => { - tracing::warn!(%err, release = id, "queueing a release failed"); - let _ = tx.send(AppEvent::StatusMessage(format!("queue failed: {err}"))); - } + tokio::task::spawn_blocking(move || match library.release(id) { + Ok(detail) => { + let _ = tx.send(AppEvent::EnqueueTracks { + tracks: detail.tracks, + next, + }); + } + Err(err) => { + tracing::warn!(%err, release = id, "queueing a release failed"); + let _ = tx.send(AppEvent::StatusMessage(format!("queue failed: {err:#}"))); } }); } Effect::ToggleLikes { track_ids } => { + // Ephemeral federated tracks (negative ids) are not in the DB. + let track_ids: Vec = track_ids.into_iter().filter(|id| *id >= 0).collect(); if track_ids.is_empty() { return; } - let Some(api) = runtime.api.clone() else { - return; - }; + let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { + tokio::task::spawn_blocking(move || { for track_id in track_ids { - match api.toggle_like(track_id).await { + match library.toggle_like(track_id) { Ok(liked) => { let _ = tx.send(AppEvent::LikeToggled { track_id, liked }); } Err(err) => { tracing::warn!(%err, track_id, "like toggle failed"); - let _ = tx.send(AppEvent::StatusMessage(format!("like failed: {err}"))); + let _ = + tx.send(AppEvent::StatusMessage(format!("like failed: {err:#}"))); break; } } } }); } + Effect::RemoveFromPlaylist { + playlist_id, + track_ids, + } => { + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + tokio::task::spawn_blocking(move || { + let event = match library.remove_tracks_from_playlist(playlist_id, &track_ids) { + Ok(()) => AppEvent::LibraryChanged { + message: Some(format!("removed {} track(s)", track_ids.len())), + }, + Err(err) => AppEvent::StatusMessage(format!("remove failed: {err:#}")), + }; + let _ = tx.send(event); + }); + } + Effect::FedApplySettings => fed_apply_settings(state, runtime), + Effect::FedSyncNow => { + let fed = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let message = match fed.sync_now().await { + Ok(()) => "federation: library published".to_string(), + Err(err) => format!("federation sync failed: {err:#}"), + }; + let _ = tx.send(AppEvent::FederationStatus(fed.status().await)); + let _ = tx.send(AppEvent::StatusMessage(message)); + }); + } + Effect::FedShowTicket => { + let fed = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = fed.ticket().await.map_err(|err| format!("{err:#}")); + let _ = tx.send(AppEvent::FedTicket(result)); + }); + } + Effect::FedPlay(fed_track) => { + let fed = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = fed + .prepare_playback(&fed_track) + .await + .map_err(|err| format!("{err:#}")); + let _ = tx.send(AppEvent::FedPlayReady { result }); + }); + } Effect::RemoveQueueIndices { restart_paused, stop, @@ -527,124 +443,21 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { } => { if stop { runtime.player_start_pending = false; - runtime.player_paused_since = None; runtime.player.stop(); - push_state_now(state, runtime); push_media_update(state, runtime, true); } else if let Some(paused) = restart_paused { start_current_audio(state, runtime, 0.0, paused); - push_state_now(state, runtime); push_media_metadata(state, runtime); push_media_update(state, runtime, true); } else { - push_state_now(state, runtime); push_media_update(state, runtime, true); } } } } -fn perform_remote_effect(state: &mut AppState, runtime: &Runtime, effect: &Effect) -> bool { - let Some(target) = state.devices.remote_target_id().map(str::to_string) else { - return false; - }; - match effect { - Effect::PlayCurrent => { - if let Some(payload) = - device_playback_state(state).and_then(|state| serde_json::to_value(state).ok()) - { - send_device_command(runtime, target, "play_from_index", payload); - state.status_message = Some("sent play command to active device".into()); - } - true - } - Effect::TogglePause => { - let command = if state.player.paused { - "pause" - } else { - "resume" - }; - send_device_command(runtime, target, command, serde_json::json!({})); - true - } - Effect::StopPlayback => { - send_device_command(runtime, target, "queue_clear", serde_json::json!({})); - true - } - Effect::SeekBy(delta) => { - let target_time = (state.player.position_secs + *delta as f64).max(0.0); - state.player.position_secs = target_time; - send_device_command( - runtime, - target, - "seek", - serde_json::json!({ "time": target_time }), - ); - true - } - Effect::SetVolume(volume) => { - send_device_command( - runtime, - target, - "set_volume", - serde_json::json!({ "volume": f64::from(*volume) / 100.0 }), - ); - true - } - Effect::SetOptions => { - send_device_command( - runtime, - target, - "set_options", - serde_json::json!({ - "shuffle": state.player.shuffle, - "repeat_mode": state.player.repeat.label(), - }), - ); - true - } - Effect::RemoveQueueIndices { indices, .. } => { - let mut indices = indices.clone(); - indices.sort_unstable_by(|a, b| b.cmp(a)); - for index in indices { - send_device_command( - runtime, - target.clone(), - "queue_remove", - serde_json::json!({ "index": index }), - ); - } - true - } - Effect::EnqueueRelease { .. } | Effect::ToggleLikes { .. } => false, - } -} - -fn send_device_command( - runtime: &Runtime, - target_device_id: String, - command: &'static str, - payload: serde_json::Value, -) { - let Some(api) = runtime.api.clone() else { - return; - }; - let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let event = match api - .send_device_command(Some(&target_device_id), command, &payload) - .await - { - Ok(()) => AppEvent::StatusMessage(format!("sent {command} to active device")), - Err(ApiError::SessionExpired) => AppEvent::SessionExpired, - Err(err) => AppEvent::StatusMessage(format!("device command failed: {err}")), - }; - let _ = tx.send(event); - }); -} - -/// Start streaming `queue[queue_pos]`: open the authenticated HTTP stream in -/// a background task and hand the reader to the audio thread. +/// Start playing `queue[queue_pos]`: open the local file in a background +/// task and hand the reader to the audio thread. fn play_current(state: &mut AppState, runtime: &mut Runtime) { start_current_audio(state, runtime, 0.0, false); } @@ -658,9 +471,6 @@ fn start_current_audio( let Some(track) = state.player.queue.get(state.player.queue_pos).cloned() else { return; }; - let Some(api) = runtime.api.clone() else { - return; - }; // The track that was playing until now was cut short by this switch. let previous_started_at = state.player.track_started_at; let same_track_started_at = if let Some(previous) = state.player.current.take() { @@ -682,91 +492,49 @@ fn start_current_audio( state.player.playing = true; state.player.paused = paused; state.player.position_secs = position_secs.max(0.0); - state.player.track_started_at = - same_track_started_at.or_else(|| Some(auth::now_epoch_seconds())); + state.player.track_started_at = same_track_started_at.or_else(|| Some(now_epoch_seconds())); state.player.prefetched_pos = None; state.status_message = Some(format!("▶ {} — {}", track.title, track.artist_line())); - if !paused { - report_now_playing(runtime, track.id); - } runtime.player_start_pending = true; - if paused { - runtime.player_paused_since = Some(Instant::now()); - } else { - runtime.player_paused_since = None; - } let controller = runtime.player.clone(); let volume = player::amplitude(state.player.volume); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - match api.open_stream(&track.stream_url).await { - Ok((reader, byte_len)) => { - controller.play(reader, byte_len, volume); - if position_secs > 0.0 { - controller.seek(std::time::Duration::from_secs_f64(position_secs)); - } - if paused { - controller.pause(); - } + tokio::task::spawn_blocking(move || match open_track_file(&track.file_path) { + Ok((reader, byte_len)) => { + controller.play(reader, byte_len, volume); + if position_secs > 0.0 { + controller.seek(std::time::Duration::from_secs_f64(position_secs)); } - Err(ApiError::SessionExpired) => { - tracing::warn!( - track_id = track.id, - title = %track.title, - "playback stream open reported expired session" - ); - let _ = tx.send(AppEvent::SessionExpired); - } - Err(err) => { - let message = format!("playback failed: {err}"); - tracing::warn!( - track_id = track.id, - title = %track.title, - stream_url = %track.stream_url, - %err, - "playback stream open failed" - ); - let _ = tx.send(AppEvent::Player(player::PlayerEvent::Failed(message))); + if paused { + controller.pause(); } } + Err(err) => { + tracing::warn!( + track_id = track.id, + title = %track.title, + file = %track.file_path, + %err, + "cannot open track file" + ); + let _ = tx.send(AppEvent::Player(player::PlayerEvent::Failed(format!( + "playback failed: {err}" + )))); + } }); } -fn pause_current_audio(state: &mut AppState, runtime: &mut Runtime) { - state.player.paused = true; - runtime.player.pause(); - runtime.player_paused_since.get_or_insert_with(Instant::now); +fn open_track_file(path: &str) -> std::io::Result<(player::TrackReader, Option)> { + let file = std::fs::File::open(path)?; + let byte_len = file.metadata().ok().map(|meta| meta.len()); + Ok((std::io::BufReader::new(file), byte_len)) } -fn resume_current_audio(state: &mut AppState, runtime: &mut Runtime) { - state.player.paused = false; - let paused_for = runtime - .player_paused_since - .take() - .map(|elapsed| elapsed.elapsed()); - if paused_for.is_some_and(|elapsed| elapsed >= STALE_STREAM_PAUSE_REOPEN_AFTER) { - let position_secs = state.player.position_secs; - tracing::info!( - paused_for_seconds = paused_for.map_or(0.0, |elapsed| elapsed.as_secs_f64()), - position_secs, - "reopening playback stream after a long pause" - ); - runtime.player.stop(); - start_current_audio(state, runtime, position_secs, false); - } else { - runtime.player.resume(); - } -} - -/// Start streaming the next queue item ~30s before the current track ends -/// and append it in the audio thread, so rodio switches sources without a -/// device gap. +/// Open the next queue item ~30s before the current track ends and append +/// it in the audio thread, so rodio switches sources without a device gap. fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) { const PREFETCH_MARGIN_SECS: f64 = 30.0; - if !state.devices.is_playback_device() { - return; - } let player = &state.player; if !player.playing || player.paused || player.prefetched_pos.is_some() { return; @@ -785,83 +553,21 @@ fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) { let Some(next) = player.queue.get(next_pos).cloned() else { return; }; - let Some(api) = runtime.api.clone() else { - return; - }; state.player.prefetched_pos = Some(next_pos); tracing::debug!(title = %next.title, "prefetching next track"); let controller = runtime.player.clone(); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - match api.open_stream(&next.stream_url).await { - Ok((reader, byte_len)) => controller.enqueue(reader, byte_len), - Err(ApiError::SessionExpired) => { - tracing::warn!( - track_id = next.id, - title = %next.title, - "prefetch reported expired session" - ); - let _ = tx.send(AppEvent::SessionExpired); - } - Err(err) => { - tracing::warn!(%err, "prefetch failed; falling back to a normal switch"); - let _ = tx.send(AppEvent::PrefetchFailed { pos: next_pos }); - } + tokio::task::spawn_blocking(move || match open_track_file(&next.file_path) { + Ok((reader, byte_len)) => controller.enqueue(reader, byte_len), + Err(err) => { + tracing::warn!(%err, "prefetch failed; falling back to a normal switch"); + let _ = tx.send(AppEvent::PrefetchFailed { pos: next_pos }); } }); } -/// Persist playback state server-side: on track changes (called directly) -/// and every ~10s while something is playing (called from the tick). -fn maybe_push_state(state: &AppState, runtime: &mut Runtime) { - const PUSH_INTERVAL: Duration = Duration::from_secs(10); - if !state.player.playing || !state.devices.is_playback_device() { - return; - } - let due = runtime - .last_state_push - .is_none_or(|at| at.elapsed() >= PUSH_INTERVAL); - if due { - push_state_now(state, runtime); - } -} - -fn push_state_now(state: &AppState, runtime: &mut Runtime) { - let Some(api) = runtime.api.clone() else { - return; - }; - runtime.last_state_push = Some(std::time::Instant::now()); - let player = &state.player; - let body = crate::api::client::PlaybackStateBody { - current_track_id: player.current.as_ref().map(|t| t.id), - position_ms: (player.position_secs * 1000.0) as i32, - queue: player.queue.iter().map(|t| t.id).collect(), - queue_position: player.queue_pos as i32, - shuffle: player.shuffle, - repeat_mode: player.repeat.label().to_string(), - volume: f64::from(player.volume) / 100.0, - }; - tokio::spawn(async move { - if let Err(err) = api.push_state(&body).await { - tracing::warn!(%err, "state push failed"); - } - }); -} - -/// Announce the just-started track as "now playing" on last.fm. Quiet on -/// failure — last.fm may simply not be connected for this account. -fn report_now_playing(runtime: &Runtime, track_id: i64) { - let Some(api) = runtime.api.clone() else { - return; - }; - tokio::spawn(async move { - if let Err(err) = api.lastfm_now_playing(track_id).await { - tracing::debug!(%err, track_id, "lastfm now-playing failed"); - } - }); -} - -/// Fire-and-forget history report; listens shorter than 5s are noise. +/// Record a finished/aborted listen in the local history; listens shorter +/// than 5s are noise. fn report_history( runtime: &Runtime, track_id: i64, @@ -869,22 +575,54 @@ fn report_history( listened: i32, completed: bool, ) { - if listened < 5 { + // Ephemeral federated tracks are not library rows; no history for them. + if listened < 5 || track_id < 0 { return; } - let Some(api) = runtime.api.clone() else { - return; - }; - tokio::spawn(async move { - if let Err(err) = api - .report_history(track_id, started_at, listened, completed) - .await - { - tracing::warn!(%err, "history report failed"); + let library = Arc::clone(&runtime.library); + tokio::task::spawn_blocking(move || { + if let Err(err) = library.add_history(track_id, started_at, listened, completed) { + tracing::warn!(%err, "history write failed"); } }); } +/// Persist the Federation-tab settings and (re)start or stop the node. +pub(crate) fn fed_apply_settings(state: &mut AppState, runtime: &Runtime) { + let settings = state.federation.settings.clone(); + let fed = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + if let Err(err) = fed.apply_settings(settings).await { + let _ = tx.send(AppEvent::StatusMessage(format!("federation: {err:#}"))); + } + let _ = tx.send(AppEvent::FederationStatus(fed.status().await)); + }); +} + +/// Connect to a peer by its pasted ticket (manual peering). +pub(crate) fn fed_connect(runtime: &Runtime, ticket: String) { + let fed = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let message = match fed.connect(&ticket).await { + Ok(peer) => format!("federation: connected to {}…", &peer[..peer.len().min(10)]), + Err(err) => format!("federation: {err:#}"), + }; + let _ = tx.send(AppEvent::FederationStatus(fed.status().await)); + let _ = tx.send(AppEvent::StatusMessage(message)); + }); +} + +/// Request a fresh status snapshot for the Federation tab. +fn fed_spawn_status(runtime: &Runtime) { + let fed = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let _ = tx.send(AppEvent::FederationStatus(fed.status().await)); + }); +} + /// Clear a timed-out quit confirmation and its status-bar hint. fn expire_quit_confirmation(state: &mut AppState) { if state @@ -898,28 +636,6 @@ fn expire_quit_confirmation(state: &mut AppState) { } } -/// Validate the stored session in the background: a dead refresh token sends -/// the user back to the login screen instead of failing on first use. -fn spawn_session_check(runtime: &Runtime, api: Arc) { - let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - match api.me().await { - Ok(me) => { - let _ = tx.send(AppEvent::StatusMessage(format!("signed in as {}", me.name))); - } - Err(ApiError::SessionExpired) => { - let _ = tx.send(AppEvent::SessionExpired); - } - Err(err) => { - tracing::warn!(%err, "session check failed"); - let _ = tx.send(AppEvent::StatusMessage(format!( - "server unreachable: {err}" - ))); - } - } - }); -} - fn handle_terminal_event( state: &mut AppState, keymap: &mut Keymap, @@ -933,19 +649,21 @@ fn handle_terminal_event( if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; } - match state.screen { - Screen::Login => login::handle_key(state, runtime, key), - Screen::Main if state.popup.is_some() => popup::handle_key(state, runtime, key), - Screen::Main if state.cmdline.active => cmdline::handle_key(state, runtime, key), - Screen::Main => handle_main_key(state, keymap, runtime, key), + if state.popup.is_some() { + popup::handle_key(state, runtime, key); + } else if state.cmdline.active { + cmdline::handle_key(state, runtime, key); + } else { + handle_main_key(state, keymap, runtime, key); + } + } + TermEvent::Paste(pasted) => { + if state.popup.is_some() { + popup::handle_paste(state, &pasted); + } else if state.cmdline.active { + cmdline::handle_paste(state, runtime, &pasted); } } - TermEvent::Paste(pasted) => match state.screen { - Screen::Login => login::handle_paste(state, &pasted), - Screen::Main if state.popup.is_some() => popup::handle_paste(state, &pasted), - Screen::Main if state.cmdline.active => cmdline::handle_paste(state, runtime, &pasted), - Screen::Main => {} - }, _ => {} } } @@ -963,10 +681,7 @@ fn handle_main_key( // trace, not debug: on the Logs tab every keypress would // otherwise append a line and pollute what's being read. tracing::trace!(?action, "key resolved"); - // Logout needs the Runtime, which pure update() never touches. - if action == action::Action::Logout { - perform_logout(state, runtime); - } else if let Some(effect) = update(state, action) { + if let Some(effect) = update(state, action) { perform_effect(state, runtime, effect); } } @@ -975,431 +690,284 @@ fn handle_main_key( } } -/// Sign out: revoke the session server-side (best effort, in the background), -/// delete stored credentials, return to the login screen with the server -/// URL kept for convenience. -fn perform_logout(state: &mut AppState, runtime: &mut Runtime) { - let server_url = runtime.api.as_ref().map(|api| api.base_url().to_string()); - if let Some(api) = runtime.api.take() { - tokio::spawn(async move { - match api.logout().await { - Ok(revoked) => tracing::info!(revoked, "logged out"), - Err(err) => tracing::warn!(%err, "server-side logout failed"), +/// `:import ` — run a library import in the background, reporting +/// progress into the status bar. +pub(super) fn spawn_import(state: &mut AppState, runtime: &Runtime, path: &str) { + let expanded = expand_tilde(path); + state.status_message = Some(format!("importing {}…", expanded.display())); + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + tokio::task::spawn_blocking(move || { + let progress_tx = tx.clone(); + let mut last_refresh = std::time::Instant::now(); + let result = + crate::library::import::import_path(&library, &expanded, |done, total, name| { + let _ = progress_tx.send(AppEvent::ImportProgress { + done, + total, + current: name.to_string(), + }); + // Long imports show up in the views as they go, not only at + // the end: refresh about once a second. + if done < total && last_refresh.elapsed() >= Duration::from_secs(1) { + last_refresh = std::time::Instant::now(); + let _ = progress_tx.send(AppEvent::LibraryChanged { message: None }); + } + }); + let event = match result { + Ok(outcome) => { + for (file, error) in &outcome.failed { + tracing::warn!(file = %file.display(), error, "file was not imported"); + } + AppEvent::LibraryChanged { + message: Some(outcome.summary()), + } } - }); - } - auth::delete_session(); - runtime.last_device_poll = None; - runtime.device_poll_in_flight = false; - runtime.player.stop(); - state.player = state::PlayerBar::default(); - state.user = None; - state.login = state::LoginForm::default(); - if let Some(url) = server_url { - state.login.server_url = url; - } - reset_library_state(state); - state.screen = Screen::Login; - state.status_message = None; + Err(err) => AppEvent::StatusMessage(format!("import failed: {err:#}")), + }; + let _ = tx.send(event); + }); } -/// Drop everything fetched from the previous account/server. -fn reset_library_state(state: &mut AppState) { - state.global = state::GlobalTab::default(); - state.artist_views.clear(); - state.release_views.clear(); - state.playlists = state::PlaylistsTab::default(); - state.playlist_views.clear(); - state.queue_tab = state::QueueTab::default(); - state.pending_release_focus = None; - state.jump_origin = None; - state.popup = None; - let device_id = state.devices.device_id.clone(); - state.devices = state::DevicesState { - device_id, - ..state::DevicesState::default() - }; - state.likes.clear(); +fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") + && let Some(home) = std::env::home_dir() { + return home.join(rest); + } + PathBuf::from(path) +} + +/// The library changed (import, edit, delete): reload everything that is +/// currently on screen or cached, replacing data in place so nothing +/// flashes "loading" while the user keeps browsing. Runs repeatedly during +/// long imports, so every step must be cheap and non-disruptive. +fn on_library_changed(state: &mut AppState, runtime: &mut Runtime) { + refresh_artists(state, runtime); + + // Refresh every cached drill-down view in place; the handlers replace + // the entries when the fresh data arrives. + for id in state.artist_views.keys().copied().collect::>() { + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = library.artist(id).map_err(err_string); + let _ = tx.send(AppEvent::ArtistViewLoaded { id, result }); + }); + } + for id in state.release_views.keys().copied().collect::>() { + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = library.release(id).map_err(err_string); + let _ = tx.send(AppEvent::ReleaseViewLoaded { id, result }); + }); + } + for id in state.playlist_views.keys().copied().collect::>() { + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = library.playlist(id).map_err(err_string); + let _ = tx.send(AppEvent::PlaylistViewLoaded { id, result }); + }); + } + if state.playlists.list.is_some() { + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = library.playlists().map_err(err_string); + let _ = tx.send(AppEvent::PlaylistsLoaded(result)); + }); + } + // Likes reload on the next maintenance pass; the old set stays visible + // until then. state.likes_loaded = false; - state.search = state::SearchState::default(); - state.cmdline = state::Cmdline::default(); - state.art.clear(); -} -fn apply_devices_response( - state: &mut AppState, - runtime: &mut Runtime, - response: crate::api::models::DevicePollResponse, - from_activation: bool, -) { - let was_playback_device = state.devices.is_playback_device(); - state.devices.device_id = response.device_id; - state.devices.active_device_id = response.active_device_id; - state.devices.devices = response.devices; - state.devices.poll_error = None; - if from_activation { - state.devices.switching_to = None; - if matches!(state.popup, Some(state::Popup::Devices { .. })) { - state.popup = None; - } - } - - let is_playback_device = state.devices.is_playback_device(); - if was_playback_device && !is_playback_device { - runtime.player_start_pending = false; - runtime.player_paused_since = None; - runtime.player.stop(); - } - - if !is_playback_device { - if let Some(playback_state) = &response.playback_state { - apply_device_playback_state(state, runtime, playback_state, false); - } else { - runtime.player_start_pending = false; - runtime.player_paused_since = None; - runtime.player.stop(); - } - } else if from_activation { - if let Some(playback_state) = &response.playback_state { - apply_device_playback_state(state, runtime, playback_state, true); - } - } - - for command in response.commands { - execute_device_command(state, runtime, command); - } -} - -fn apply_device_playback_state( - state: &mut AppState, - runtime: &mut Runtime, - playback_state: &crate::api::models::DevicePlaybackState, - start_audio: bool, -) { - let mut tracks = tracks_from_values(&playback_state.tracks); - let track = playback_state - .track - .as_ref() - .and_then(track_from_value) - .or_else(|| { - usize::try_from(playback_state.index) - .ok() - .and_then(|index| tracks.get(index).cloned()) + // Fresh copies of whatever sits in the queue. + let ids: Vec = state.player.queue.iter().map(|track| track.id).collect(); + if !ids.is_empty() { + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + tokio::task::spawn_blocking(move || match library.tracks_by_ids(&ids) { + Ok(tracks) => { + let _ = tx.send(AppEvent::QueueTracksRefreshed { tracks }); + } + Err(err) => tracing::warn!(%err, "queue refresh failed"), }); - if tracks.is_empty() { - if let Some(track) = track.clone() { - tracks.push(track); - } - } - let mut index = usize::try_from(playback_state.index).unwrap_or(0); - if let Some(track) = &track { - index = tracks - .iter() - .position(|item| item.id == track.id) - .unwrap_or(index); - } - if !tracks.is_empty() { - index = index.min(tracks.len() - 1); - } else { - index = 0; } - state.player.queue = tracks; - state.player.queue_pos = index; - state.player.current = track.or_else(|| state.player.queue.get(index).cloned()); - state.player.playing = state.player.current.is_some(); - state.player.paused = playback_state.paused; - state.player.position_secs = playback_state.position_seconds.max(0.0); - state.player.prefetched_pos = None; - state.player.original_order = None; - state.player.shuffle = playback_state.shuffle; - state.player.repeat = repeat_from_label(&playback_state.repeat_mode); - state.player.volume = volume_percent(playback_state.volume); - state.queue_tab.cursor = state - .queue_tab - .cursor - .min(state.player.queue.len().saturating_sub(1)); - - if start_audio && state.player.current.is_some() { - start_current_audio( - state, - runtime, - playback_state.position_seconds, - playback_state.paused, - ); - push_media_metadata(state, runtime); - push_media_update(state, runtime, true); - } else { - runtime.player_start_pending = false; - runtime.player_paused_since = None; - runtime.player.stop(); + // A live search view shows stale rows now; run the query again. + if state + .global + .stack + .iter() + .any(|view| matches!(view, state::GlobalView::Search { .. })) + && !state.search.query.is_empty() + { + cmdline::schedule_search(state, runtime); } } -fn track_from_value(value: &serde_json::Value) -> Option { - serde_json::from_value(value.clone()) - .map_err(|err| tracing::warn!(%err, "invalid track in device payload")) - .ok() +/// Reload the artist grid atomically: fetch everything that is loaded now +/// as one page and swap it in when it arrives, so the grid never shows an +/// empty "loading" state in between. Pagination then continues from page 2. +fn refresh_artists(state: &mut AppState, runtime: &Runtime) { + let global = &mut state.global; + let needed = artist_grid_capacity() + ARTISTS_PREFETCH_MARGIN; + let limit = (global.artists.len().max(needed) as i64).clamp(48, 1000); + global.reloading = true; + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + tokio::task::spawn_blocking(move || { + let event = match library.artists(1, limit) { + Ok(page) => AppEvent::ArtistsReloaded { page, limit }, + Err(err) => AppEvent::ArtistsLoaded(Err(err_string(err))), + }; + let _ = tx.send(event); + }); } -fn tracks_from_values(values: &[serde_json::Value]) -> Vec { - values.iter().filter_map(track_from_value).collect() -} - -fn repeat_from_label(label: &str) -> state::RepeatMode { - match label { - "one" => state::RepeatMode::One, - "all" => state::RepeatMode::All, - _ => state::RepeatMode::Off, - } -} - -fn volume_percent(volume: f64) -> u8 { - (volume.clamp(0.0, 1.0) * 100.0).round() as u8 -} - -fn payload_playback_state(payload: &serde_json::Value) -> crate::api::models::DevicePlaybackState { - serde_json::from_value(payload.clone()).unwrap_or_default() -} - -fn payload_tracks(payload: &serde_json::Value) -> Vec { - if let Some(values) = payload.get("tracks").and_then(serde_json::Value::as_array) { - let tracks = tracks_from_values(values); - if !tracks.is_empty() { - return tracks; - } - } - payload - .get("track") - .and_then(track_from_value) - .into_iter() - .collect() -} - -fn payload_index(payload: &serde_json::Value, key: &str) -> Option { - payload - .get(key) - .and_then(serde_json::Value::as_i64) - .and_then(|value| usize::try_from(value).ok()) -} - -fn payload_f64(payload: &serde_json::Value, key: &str) -> Option { - payload.get(key).and_then(serde_json::Value::as_f64) -} - -fn execute_device_command( +/// Swap queue entries for their fresh library copies; tracks that were +/// deleted leave the queue. +fn apply_queue_refresh( state: &mut AppState, runtime: &mut Runtime, - command: crate::api::models::DeviceCommandDto, + tracks: Vec, ) { - let payload = command.payload; - tracing::debug!(command = %command.command, id = ?command.id, "device command"); - match command.command.as_str() { - "transfer_state" | "play_track" | "play_from_index" => { - let playback_state = payload_playback_state(&payload); - let start_audio = state.devices.is_playback_device(); - apply_device_playback_state(state, runtime, &playback_state, start_audio); - } - "pause" => { - pause_current_audio(state, runtime); - push_media_update(state, runtime, true); - } - "resume" | "play" => { - resume_current_audio(state, runtime); - push_media_update(state, runtime, true); - } - "seek" => { - if let Some(time) = - payload_f64(&payload, "time").or_else(|| payload_f64(&payload, "position_seconds")) - { - state.player.position_secs = time.max(0.0); - runtime.player.seek(std::time::Duration::from_secs_f64( - state.player.position_secs, - )); - } - } - "next" => { - apply_options_payload(state, &payload); - if let Some(effect) = update::update(state, action::Action::NextTrack) { - perform_effect(state, runtime, effect); - } - } - "prev" | "previous" => { - if let Some(effect) = update::update(state, action::Action::PrevTrack) { - perform_effect(state, runtime, effect); - } - } - "set_volume" | "volume" => { - if let Some(volume) = payload_f64(&payload, "volume") { - state.player.volume = volume_percent(volume); - runtime - .player - .set_volume(player::amplitude(state.player.volume)); - } - } - "set_options" => apply_options_payload(state, &payload), - "queue_add_end" => { - update::enqueue_tracks(state, payload_tracks(&payload), false); - } - "queue_add_next" => { - update::enqueue_tracks(state, payload_tracks(&payload), true); - } - "queue_remove" => { - if let Some(index) = payload_index(&payload, "index") { - remove_queue_index(state, runtime, index); - } - } - "queue_move" => { - if let (Some(from), Some(to)) = ( - payload_index(&payload, "from_index"), - payload_index(&payload, "to_index"), - ) { - move_queue_index(state, from, to); - } - } - "queue_clear" => { - state.player = state::PlayerBar::default(); - state.queue_tab.cursor = 0; - runtime.player.stop(); - push_media_update(state, runtime, true); - } - _ => {} - } -} - -fn apply_options_payload(state: &mut AppState, payload: &serde_json::Value) { - if let Some(shuffle) = payload.get("shuffle").and_then(serde_json::Value::as_bool) { - if shuffle != state.player.shuffle { - state.player.shuffle = shuffle; - if shuffle { - update::shuffle_upcoming(&mut state.player); - } else { - update::restore_queue_order(&mut state.player); - } - } - } - if let Some(repeat) = payload - .get("repeat_mode") - .and_then(serde_json::Value::as_str) - { - state.player.repeat = repeat_from_label(repeat); - } -} - -fn remove_queue_index(state: &mut AppState, runtime: &mut Runtime, index: usize) { - if index >= state.player.queue.len() { - return; - } + let by_id: std::collections::HashMap = + tracks.into_iter().map(|track| (track.id, track)).collect(); let current_id = state.player.current.as_ref().map(|track| track.id); - let removed_current = state + for track in &mut state.player.queue { + if let Some(fresh) = by_id.get(&track.id) { + *track = fresh.clone(); + } + } + let had_missing = state .player .queue - .get(index) - .is_some_and(|track| Some(track.id) == current_id); - let was_loaded = state.player.playing; - let was_paused = state.player.paused; - state.player.queue.remove(index); - state.player.prefetched_pos = None; - state.track_selection.clear(); + .iter() + .any(|track| !by_id.contains_key(&track.id)); + if had_missing { + state.player.queue.retain(|track| by_id.contains_key(&track.id)); + state.player.prefetched_pos = None; + } if state.player.queue.is_empty() { + if state.player.playing { + runtime.player_start_pending = false; + runtime.player.stop(); + } state.player = state::PlayerBar::default(); - runtime.player_start_pending = false; - runtime.player_paused_since = None; - runtime.player.stop(); - push_state_now(state, runtime); - push_media_update(state, runtime, true); + state.queue_tab.cursor = 0; return; } - state.player.queue_pos = current_id - .and_then(|id| state.player.queue.iter().position(|track| track.id == id)) - .unwrap_or_else(|| state.player.queue_pos.min(state.player.queue.len() - 1)); - state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1); - if removed_current && was_loaded { - start_current_audio(state, runtime, 0.0, was_paused); - push_media_metadata(state, runtime); - push_media_update(state, runtime, true); - } - push_state_now(state, runtime); -} - -fn move_queue_index(state: &mut AppState, from: usize, to: usize) { - if from >= state.player.queue.len() || to >= state.player.queue.len() || from == to { - return; - } - let current_id = state.player.current.as_ref().map(|track| track.id); - let track = state.player.queue.remove(from); - state.player.queue.insert(to, track); - if let Some(id) = current_id { - if let Some(position) = state.player.queue.iter().position(|track| track.id == id) { - state.player.queue_pos = position; + match current_id { + Some(id) if by_id.contains_key(&id) => { + if let Some(position) = state.player.queue.iter().position(|track| track.id == id) { + state.player.queue_pos = position; + } + state.player.current = by_id.get(&id).cloned(); + push_media_metadata(state, runtime); + } + Some(_) => { + // The playing track was deleted from the library. + runtime.player_start_pending = false; + runtime.player.stop(); + state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1); + state.player.current = None; + state.player.playing = false; + state.player.paused = false; + push_media_update(state, runtime, true); + } + None => { + state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1); } } - state.queue_tab.cursor = to; - state.player.prefetched_pos = None; } fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) { match event { AppEvent::StatusMessage(message) => state.status_message = Some(message), - AppEvent::LoginSucceeded(session) => { - if let Some(listener) = runtime.sso.take() { - listener.abort(); - } - state.status_message = Some(format!("signed in as {}", session.user.name)); - state.user = Some(session.user.clone()); - runtime.api = Some(Arc::new(ApiClient::new(runtime.http.clone(), *session))); - runtime.last_device_poll = None; - runtime.device_poll_in_flight = false; - state.login = state::LoginForm::default(); - state.screen = Screen::Main; + AppEvent::FederationStatus(status) => { + state.federation.status = Some(status); } - AppEvent::LoginFailed(message) => { - state.login.busy = false; - state.login.error = Some(message); - } - AppEvent::SsoCallback(result) => { - runtime.sso = None; - if state.screen != Screen::Login - || state.login.mode != state::LoginMode::SsoPending - || state.login.busy + AppEvent::FedSearchLoaded { seq, result } => { + if runtime + .search_seq + .load(std::sync::atomic::Ordering::SeqCst) + != seq { return; } + state.search.fed_loading = false; match result { - Ok(code) => login::spawn_sso_exchange(&mut state.login, runtime, code), - Err(message) => state.login.error = Some(message), + Ok(tracks) => state.search.fed_tracks = tracks, + Err(message) => tracing::warn!(%message, "federated search failed"), } } - AppEvent::SessionExpired => { - runtime.player_start_pending = false; - runtime.player_paused_since = None; - runtime.device_poll_in_flight = false; - state.user = None; - state.login = state::LoginForm::default(); - if let Some(api) = runtime.api.take() { - state.login.server_url = api.base_url().to_string(); + AppEvent::FedPlayReady { result } => match result { + Ok(playable) => { + if playable.imported { + // Save-on-listen imported the file; refresh the library + // views through the standard change path. + let _ = runtime.event_tx.send(AppEvent::LibraryChanged { + message: Some(format!("saved \"{}\" to the library", playable.track.title)), + }); + } + state.player.queue = vec![playable.track]; + state.player.queue_pos = 0; + update::on_new_queue(state); + perform_effect(state, runtime, Effect::PlayCurrent); } - state.login.error = Some("session expired — sign in again".to_string()); - runtime.player.stop(); - state.player = state::PlayerBar::default(); - reset_library_state(state); - state.screen = Screen::Login; - } + Err(message) => state.status_message = Some(format!("federation: {message}")), + }, + AppEvent::FedTicket(result) => match result { + Ok(ticket) => { + state.popup = Some(state::Popup::FedText { + title: "Federation ticket (share with a peer)".to_string(), + text: ticket, + }); + } + Err(message) => state.status_message = Some(message), + }, AppEvent::ArtistsLoaded(Ok(page)) => { let global = &mut state.global; + if global.reloading { + // A stale page of the pre-change pagination; the pending + // ArtistsReloaded swap supersedes it. + return; + } global.loading = false; global.total = page.total; global.has_more = page.has_more; global.next_page = page.page + 1; global.artists.extend(page.items); + if !global.has_more && !global.artists.is_empty() { + global.selected = global.selected.min(global.artists.len() - 1); + } } AppEvent::ArtistsLoaded(Err(message)) => { tracing::warn!(%message, "artists page load failed"); + state.global.reloading = false; state.global.loading = false; state.global.error = Some(message.clone()); state.status_message = Some(message); } + AppEvent::ArtistsReloaded { page, limit } => { + let global = &mut state.global; + global.reloading = false; + global.loading = false; + global.error = None; + global.total = page.total; + global.has_more = page.has_more; + global.next_page = 2; + global.page_limit = Some(limit); + global.artists = page.items; + if !global.artists.is_empty() { + global.selected = global.selected.min(global.artists.len() - 1); + } else { + global.selected = 0; + } + } AppEvent::ArtistViewLoaded { id, result } => { let entry = match result { Ok(detail) => state::Loadable::Ready(detail), @@ -1420,8 +988,8 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent }; state.release_views.insert(id, entry); // A Shift-J jump was waiting for this release: focus its track. - if let Some((release_id, track_id)) = state.pending_release_focus { - if release_id == id { + if let Some((release_id, track_id)) = state.pending_release_focus + && release_id == id { state.pending_release_focus = None; if let Some(state::Loadable::Ready(detail)) = state.release_views.get(&id) { let position = detail @@ -1431,14 +999,11 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent .unwrap_or(0); if let Some(state::GlobalView::Release { id: top, cursor }) = state.global.stack.last_mut() - { - if *top == release_id { + && *top == release_id { *cursor = position; } - } } } - } } AppEvent::SearchLoaded { seq, result } => { if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) { @@ -1481,10 +1046,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent state.player.queue_pos = next_pos.min(state.player.queue.len().saturating_sub(1)); state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); state.player.position_secs = 0.0; - state.player.track_started_at = Some(auth::now_epoch_seconds()); - if let Some(track) = &state.player.current { - report_now_playing(runtime, track.id); - } + state.player.track_started_at = Some(now_epoch_seconds()); push_media_metadata(state, runtime); push_media_update(state, runtime, true); } else { @@ -1494,11 +1056,9 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent perform_effect(state, runtime, effect); } } - push_state_now(state, runtime); } AppEvent::Player(player::PlayerEvent::Failed(message)) => { runtime.player_start_pending = false; - runtime.player_paused_since = None; tracing::error!(%message, "playback failed"); state.player.playing = false; state.player.paused = false; @@ -1511,7 +1071,11 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } AppEvent::PlaylistsLoaded(result) => { state.playlists.list = Some(match result { - Ok(list) => state::Loadable::Ready(list), + Ok(list) => { + state.playlists.selected = + state.playlists.selected.min(list.len().saturating_sub(1)); + state::Loadable::Ready(list) + } Err(message) => { tracing::warn!(%message, "playlists load failed"); state::Loadable::Failed(message) @@ -1539,52 +1103,15 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } // The virtual Likes playlist is stale now; refetch on next open. state.playlist_views.remove(&state::LIKES_PLAYLIST_ID); + state.playlists.list = None; state.status_message = Some(if liked { "♥ liked".to_string() } else { "like removed".to_string() }); } - AppEvent::DevicesPolled(result) => { - runtime.device_poll_in_flight = false; - match result { - Ok(response) => apply_devices_response(state, runtime, response, false), - Err(message) => { - tracing::warn!(%message, "device poll failed"); - state.devices.poll_error = Some(message); - } - } - } - AppEvent::DeviceActivated(result) => match result { - Ok(response) => apply_devices_response(state, runtime, response, true), - Err(message) => { - tracing::warn!(%message, "device activation failed"); - state.devices.switching_to = None; - state.devices.poll_error = Some(message.clone()); - state.status_message = Some(format!("device switch failed: {message}")); - } - }, AppEvent::EnqueueTracks { tracks, next } => { let count = tracks.len(); - if let Some(target) = state.devices.remote_target_id().map(str::to_string) { - let payload = serde_json::json!({ "tracks": tracks }); - send_device_command( - runtime, - target, - if next { - "queue_add_next" - } else { - "queue_add_end" - }, - payload, - ); - state.status_message = Some(if next { - format!("{count} tracks queued next on active device") - } else { - format!("{count} tracks queued on active device") - }); - return; - } update::enqueue_tracks(state, tracks, next); state.status_message = Some(if next { format!("{count} tracks queued next") @@ -1600,16 +1127,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent // The list is stale; refetch when next needed. state.playlists.list = None; if let Some(track) = add_track { - let Some(api) = runtime.api.clone() else { - return; - }; + let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); let (id, title) = (playlist.id, playlist.title.clone()); - tokio::spawn(async move { - let result = api + tokio::task::spawn_blocking(move || { + let result = library .add_tracks_to_playlist(id, &[track.id]) - .await - .map_err(|e| e.to_string()); + .map_err(|e| format!("{e:#}")); let _ = tx.send(AppEvent::PlaylistTracksAdded { playlist_id: id, playlist_title: title, @@ -1645,6 +1169,22 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } } } + AppEvent::LibraryChanged { message } => { + on_library_changed(state, runtime); + if let Some(message) = message { + state.status_message = Some(message); + } + } + AppEvent::ImportProgress { + done, + total, + current, + } => { + state.status_message = Some(format!("importing {done}/{total}: {current}")); + } + AppEvent::QueueTracksRefreshed { tracks } => { + apply_queue_refresh(state, runtime, tracks); + } AppEvent::Media(command) => { use crate::media::MediaCommand; tracing::debug!(?command, "media key"); @@ -1662,7 +1202,6 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent MediaCommand::Previous => action::Action::PrevTrack, MediaCommand::Stop => { runtime.player_start_pending = false; - runtime.player_paused_since = None; state.player.playing = false; state.player.paused = false; state.player.current = None; diff --git a/src/app/popup.rs b/src/app/popup.rs index 941e8bf..88681c3 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -1,13 +1,18 @@ -//! Modal dialog input: the add-to-playlist picker and new-playlist name -//! entry. The popup is taken out of the state, handled as an owned value -//! and put back unless the action closed it. +//! Modal dialog input: the add-to-playlist picker, new-playlist name entry, +//! metadata edit forms and delete confirmations. The popup is taken out of +//! the state, handled as an owned value and put back unless the action +//! closed it. + +use std::sync::Arc; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use crate::api::models::TrackItem; use crate::app::Runtime; use crate::app::event::AppEvent; -use crate::app::state::{AppState, Popup, addable_playlists}; +use crate::app::state::{ + AppState, DeleteTarget, EditField, EditTarget, FedInputField, Popup, addable_playlists, +}; +use crate::library::models::{ReleaseEdit, TrackEdit, TrackItem}; pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) { let Some(popup) = state.popup.take() else { @@ -22,7 +27,16 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) { input, busy, } => handle_name_entry(state, runtime, for_track, input, busy, key), - Popup::Devices { cursor } => handle_devices(state, runtime, cursor, key), + Popup::Edit { + target, + title, + fields, + focus, + error, + } => handle_edit(state, runtime, target, title, fields, focus, error, key), + Popup::ConfirmDelete { target, label } => { + handle_confirm_delete(state, runtime, target, label, key); + } Popup::TrackInfo { tracks, cursor, @@ -32,54 +46,269 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} _ => state.popup = Some(Popup::LogDetail(entry)), }, + Popup::FedInput { field, input } => handle_fed_input(state, runtime, field, input, key), + Popup::FedText { title, text } => match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} + _ => state.popup = Some(Popup::FedText { title, text }), + }, } } -/// Pasted text goes into the name field when it is open. -pub fn handle_paste(state: &mut AppState, pasted: &str) { - if let Some(Popup::NewPlaylist { input, busy, .. }) = &mut state.popup { - if !*busy { - input.extend(pasted.chars().filter(|c| !c.is_control())); - } - } -} - -fn handle_devices(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) { - let len = state.devices.devices.len(); +/// One-line text entry on the Federation tab (network id / peer ticket). +fn handle_fed_input( + state: &mut AppState, + runtime: &Runtime, + field: FedInputField, + mut input: String, + key: KeyEvent, +) { match key.code { - KeyCode::Esc | KeyCode::Char('q') => {} - KeyCode::Up | KeyCode::Char('k') => { - state.popup = Some(Popup::Devices { - cursor: cursor.saturating_sub(1), - }); - } - KeyCode::Down | KeyCode::Char('j') => { - state.popup = Some(Popup::Devices { - cursor: if len == 0 { - 0 - } else { - (cursor + 1).min(len - 1) - }, - }); - } + KeyCode::Esc => {} KeyCode::Enter => { - if let Some(device) = state.devices.devices.get(cursor.min(len.saturating_sub(1))) { - let target = device.id.clone(); - state.devices.switching_to = Some(target.clone()); - state.popup = Some(Popup::Devices { cursor }); - spawn_select_device(runtime, target); - } else { - state.popup = Some(Popup::Devices { cursor: 0 }); + let value = input.trim().to_string(); + match field { + FedInputField::NetworkId => { + state.federation.settings.network_id = value; + // An empty id turns federation off rather than leaving a + // node bound to an unnamed network; a freshly set id + // enables it right away. + state.federation.settings.enabled = + !state.federation.settings.network_id.is_empty(); + super::fed_apply_settings(state, runtime); + } + FedInputField::ConnectTicket => { + if value.is_empty() { + state.status_message = Some("ticket is empty".into()); + } else { + super::fed_connect(runtime, value); + } + } } } - _ => { - state.popup = Some(Popup::Devices { - cursor: cursor.min(len.saturating_sub(1)), - }) + KeyCode::Backspace => { + input.pop(); + state.popup = Some(Popup::FedInput { field, input }); } + KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { + input.push(c); + state.popup = Some(Popup::FedInput { field, input }); + } + _ => state.popup = Some(Popup::FedInput { field, input }), } } +/// Pasted text goes into the focused text field when one is open. +pub fn handle_paste(state: &mut AppState, pasted: &str) { + let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect(); + match &mut state.popup { + Some(Popup::NewPlaylist { input, busy, .. }) if !*busy => input.push_str(&cleaned), + Some(Popup::FedInput { input, .. }) => input.push_str(&cleaned), + Some(Popup::Edit { fields, focus, .. }) => { + if let Some(field) = fields.get_mut(*focus) { + field.value.push_str(&cleaned); + } + } + _ => {} + } +} + +// --------------------------------------------------------------------------- +// Edit form +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments, reason = "owned popup state passed back in")] +fn handle_edit( + state: &mut AppState, + runtime: &Runtime, + target: EditTarget, + title: String, + mut fields: Vec, + mut focus: usize, + error: Option, + key: KeyEvent, +) { + match key.code { + KeyCode::Esc => return, + KeyCode::Enter => { + match save_edit(runtime, target, &fields) { + Ok(message) => { + state.status_message = Some(message); + return; + } + Err(message) => { + state.popup = Some(Popup::Edit { + target, + title, + fields, + focus, + error: Some(message), + }); + return; + } + }; + } + KeyCode::Tab | KeyCode::Down => focus = (focus + 1) % fields.len().max(1), + KeyCode::BackTab | KeyCode::Up => { + let len = fields.len().max(1); + focus = (focus + len - 1) % len; + } + KeyCode::Backspace => { + if let Some(field) = fields.get_mut(focus) { + field.value.pop(); + } + } + KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { + if let Some(field) = fields.get_mut(focus) { + field.value.push(c); + } + } + _ => {} + } + state.popup = Some(Popup::Edit { + target, + title, + fields, + focus, + error, + }); +} + +/// Validate the form and write it to the library. Returns the status +/// message on success, the error text to show in the form otherwise. +fn save_edit( + runtime: &Runtime, + target: EditTarget, + fields: &[EditField], +) -> Result { + let value = |label: &str| { + fields + .iter() + .find(|field| field.label == label) + .map(|field| field.value.trim().to_string()) + .unwrap_or_default() + }; + let number = |label: &str| -> Result, String> { + let raw = value(label); + if raw.is_empty() { + return Ok(None); + } + raw.parse::() + .map(Some) + .map_err(|_| format!("{label} must be a number")) + }; + let names = |label: &str| -> Vec { + value(label) + .split(';') + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .collect() + }; + + let library = Arc::clone(&runtime.library); + let result = match target { + EditTarget::Track(id) => { + let title = value("Title"); + if title.is_empty() { + return Err("title is empty".to_string()); + } + let artists = names("Artists"); + if artists.is_empty() { + return Err("at least one artist is required".to_string()); + } + let edit = TrackEdit { + title, + artists, + featured_artists: names("Featured"), + track_number: number("Track #")?, + disc_number: number("Disc #")?, + }; + library.update_track(id, &edit) + } + EditTarget::Release(id) => { + let title = value("Title"); + if title.is_empty() { + return Err("title is empty".to_string()); + } + let release_type = value("Type").to_lowercase(); + let release_type = if release_type.is_empty() { + "album".to_string() + } else { + release_type + }; + let edit = ReleaseEdit { + title, + release_type, + year: number("Year")?, + artists: Vec::new(), + }; + library.update_release(id, &edit) + } + EditTarget::Artist(id) => { + let name = value("Name"); + if name.is_empty() { + return Err("name is empty".to_string()); + } + let image = value("Image path"); + let image = (!image.is_empty()).then_some(image); + library.update_artist(id, &name, image.as_deref()) + } + EditTarget::Playlist(id) => { + let title = value("Title"); + if title.is_empty() { + return Err("title is empty".to_string()); + } + library.update_playlist(id, &title, None) + } + }; + match result { + Ok(()) => { + let _ = runtime.event_tx.send(AppEvent::LibraryChanged { + message: Some("saved".to_string()), + }); + Ok("saved".to_string()) + } + Err(err) => Err(format!("{err:#}")), + } +} + +// --------------------------------------------------------------------------- +// Delete confirmation +// --------------------------------------------------------------------------- + +fn handle_confirm_delete( + state: &mut AppState, + runtime: &Runtime, + target: DeleteTarget, + label: String, + key: KeyEvent, +) { + match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {} + KeyCode::Enter | KeyCode::Char('y') => { + let library = Arc::clone(&runtime.library); + let result = match target { + DeleteTarget::Track(id) => library.delete_track(id), + DeleteTarget::Release(id) => library.delete_release(id), + DeleteTarget::Artist(id) => library.delete_artist(id), + DeleteTarget::Playlist(id) => library.delete_playlist(id), + }; + match result { + Ok(()) => { + let _ = runtime.event_tx.send(AppEvent::LibraryChanged { + message: Some(format!("deleted {label}")), + }); + } + Err(err) => state.status_message = Some(format!("delete failed: {err:#}")), + } + } + _ => state.popup = Some(Popup::ConfirmDelete { target, label }), + } +} + +// --------------------------------------------------------------------------- +// Track info +// --------------------------------------------------------------------------- + fn handle_track_info( state: &mut AppState, tracks: Vec, @@ -132,6 +361,10 @@ fn handle_track_info( } } +// --------------------------------------------------------------------------- +// Add-to-playlist picker & new playlist +// --------------------------------------------------------------------------- + fn handle_picker( state: &mut AppState, runtime: &Runtime, @@ -236,35 +469,13 @@ fn handle_name_entry( } } -fn spawn_select_device(runtime: &Runtime, target_device_id: String) { - let Some(api) = runtime.api.clone() else { - return; - }; - let current_device_id = runtime.device_id.clone(); - let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let event = match api - .select_device(&target_device_id, ¤t_device_id) - .await - { - Ok(response) => AppEvent::DeviceActivated(Ok(response)), - Err(crate::api::client::ApiError::SessionExpired) => AppEvent::SessionExpired, - Err(err) => AppEvent::DeviceActivated(Err(err.to_string())), - }; - let _ = tx.send(event); - }); -} - fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String, track: TrackItem) { - let Some(api) = runtime.api.clone() else { - return; - }; + let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let result = api + tokio::task::spawn_blocking(move || { + let result = library .add_tracks_to_playlist(playlist_id, &[track.id]) - .await - .map_err(|e| e.to_string()); + .map_err(|err| format!("{err:#}")); let _ = tx.send(AppEvent::PlaylistTracksAdded { playlist_id, playlist_title, @@ -274,12 +485,12 @@ fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String, } fn spawn_create_playlist(runtime: &Runtime, title: String, add_track: Option) { - let Some(api) = runtime.api.clone() else { - return; - }; + let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let result = api.create_playlist(&title).await.map_err(|e| e.to_string()); + tokio::task::spawn_blocking(move || { + let result = library + .create_playlist(&title) + .map_err(|err| format!("{err:#}")); let _ = tx.send(AppEvent::PlaylistCreated { result, add_track }); }); } diff --git a/src/app/sso.rs b/src/app/sso.rs deleted file mode 100644 index e11c152..0000000 --- a/src/app/sso.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::io; - -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::sync::mpsc::UnboundedSender; - -use crate::app::event::AppEvent; - -/// Loopback callback listener for browser SSO (RFC 8252 native-app flow). -/// The backend 303-redirects the browser to `http://127.0.0.1:{port}/callback` -/// with the exchange code; the listener delivers it as an AppEvent and exits. -pub struct SsoListener { - pub port: u16, - handle: tokio::task::JoinHandle<()>, -} - -impl SsoListener { - pub fn abort(&self) { - self.handle.abort(); - } -} - -pub fn start(tx: UnboundedSender) -> io::Result { - let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?; - listener.set_nonblocking(true)?; - let port = listener.local_addr()?.port(); - let handle = tokio::spawn(async move { - let result = match serve_one(listener).await { - Ok(result) => result, - Err(err) => Err(format!("callback listener failed: {err}")), - }; - let _ = tx.send(AppEvent::SsoCallback(result)); - }); - Ok(SsoListener { port, handle }) -} - -async fn serve_one(listener: std::net::TcpListener) -> io::Result> { - let listener = tokio::net::TcpListener::from_std(listener)?; - loop { - let (mut stream, _) = listener.accept().await?; - let request_line = read_request_line(&mut stream).await?; - let Some(result) = parse_request_line(&request_line) else { - // Stray request (favicon, prefetch) — keep waiting for the code. - let _ = stream.write_all(&response(404, "Not Found")).await; - continue; - }; - let page = match &result { - Ok(_) => "Sign-in complete. You can close this window and return to the terminal.", - Err(_) => "Sign-in failed. Return to the terminal to see the error.", - }; - let _ = stream.write_all(&response(200, page)).await; - let _ = stream.shutdown().await; - return Ok(result); - } -} - -async fn read_request_line(stream: &mut tokio::net::TcpStream) -> io::Result { - let mut buf = vec![0u8; 8192]; - let mut len = 0; - while len < buf.len() { - let n = stream.read(&mut buf[len..]).await?; - if n == 0 { - break; - } - len += n; - if buf[..len].windows(2).any(|w| w == b"\r\n") { - break; - } - } - let text = String::from_utf8_lossy(&buf[..len]); - Ok(text.lines().next().unwrap_or_default().to_string()) -} - -/// `GET /callback?code=furu_mx_... HTTP/1.1` → Ok(code) / Err(error). -/// Values are plain tokens (no percent-encoded characters expected). -fn parse_request_line(line: &str) -> Option> { - let path = line.split_whitespace().nth(1)?; - let query = path.split_once('?').map(|(_, q)| q).unwrap_or(""); - let mut code = None; - let mut error = None; - for pair in query.split('&') { - let (key, value) = pair.split_once('=').unwrap_or((pair, "")); - match key { - "code" if !value.is_empty() => code = Some(value.to_string()), - "error" if !value.is_empty() => error = Some(value.to_string()), - _ => {} - } - } - if let Some(error) = error { - return Some(Err(format!("SSO failed: {error}"))); - } - code.map(Ok) -} - -fn response(status: u16, body: &str) -> Vec { - let reason = if status == 200 { "OK" } else { "Not Found" }; - let body = format!( - "furumi\ -

{body}

" - ); - format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\n\ - Content-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - .into_bytes() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_code_from_request_line() { - assert_eq!( - parse_request_line("GET /callback?code=furu_mx_abc HTTP/1.1"), - Some(Ok("furu_mx_abc".to_string())) - ); - } - - #[test] - fn parses_error_from_request_line() { - assert_eq!( - parse_request_line("GET /callback?error=provider_denied HTTP/1.1"), - Some(Err("SSO failed: provider_denied".to_string())) - ); - } - - #[test] - fn ignores_unrelated_requests() { - assert_eq!(parse_request_line("GET /favicon.ico HTTP/1.1"), None); - assert_eq!(parse_request_line("GET /callback HTTP/1.1"), None); - } -} diff --git a/src/app/state.rs b/src/app/state.rs index e0acc40..ed4a093 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,12 +1,12 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::api::models::{ - ArtistCard, ArtistDetail, DeviceDto, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail, - SearchResults, TrackItem, User, -}; use crate::art::ArtImage; use crate::config::keymap::KeyContext; +use crate::library::models::{ + ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail, + SearchResults, TrackItem, +}; /// Remote data that a view renders: spinner, content, or error. #[derive(Debug)] @@ -83,9 +83,12 @@ pub struct GlobalTab { pub selected: usize, pub view: ViewMode, pub stack: Vec, - /// Page size, fixed at the first request — the server's offset is + /// Page size, fixed at the first request — the offset is /// `(page-1) * limit`, so it must not change between pages. pub page_limit: Option, + /// A full atomic reload is in flight (after a library change); incoming + /// pages of the old pagination are dropped until it lands. + pub reloading: bool, } impl Default for GlobalTab { @@ -101,6 +104,7 @@ impl Default for GlobalTab { view: ViewMode::default(), stack: Vec::new(), page_limit: None, + reloading: false, } } } @@ -161,8 +165,8 @@ pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec> rows } -/// The virtual server-side Likes playlist id (`kind == "likes"`). -pub const LIKES_PLAYLIST_ID: i64 = -1; +/// The virtual Likes playlist id (`kind == "likes"`). +pub use crate::library::LIKES_PLAYLIST_ID; #[derive(Debug, Clone, Copy)] pub struct OpenedPlaylist { @@ -282,42 +286,45 @@ impl Default for LogsTab { } } -#[derive(Debug, Default)] -pub struct DevicesState { - pub device_id: String, - pub active_device_id: Option, - pub devices: Vec, - pub poll_error: Option, - pub switching_to: Option, +/// What an open edit form writes to when saved. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditTarget { + Track(i64), + Release(i64), + Artist(i64), + Playlist(i64), } -impl DevicesState { - pub fn is_playback_device(&self) -> bool { - self.active_device_id - .as_deref() - .is_none_or(|active| active == self.device_id) - } +/// What a confirmed delete removes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteTarget { + Track(i64), + Release(i64), + Artist(i64), + Playlist(i64), +} - pub fn remote_target_id(&self) -> Option<&str> { - self.active_device_id - .as_deref() - .filter(|active| *active != self.device_id) - } +/// One text field of an edit form. +#[derive(Debug, Clone)] +pub struct EditField { + pub label: &'static str, + pub value: String, +} - pub fn active_device_name(&self) -> Option<&str> { - let active = self.active_device_id.as_deref()?; - self.devices - .iter() - .find(|device| device.id == active) - .map(|device| device.name.as_str()) +impl EditField { + pub fn new(label: &'static str, value: impl Into) -> Self { + Self { + label, + value: value.into(), + } } } /// Modal dialog over the main screen. #[derive(Debug)] pub enum Popup { - /// Pick one of the user's playlists (row 0 = "create new"); the track - /// is added on Enter. + /// Pick one of the playlists (row 0 = "create new"); the track is added + /// on Enter. AddToPlaylist { track: TrackItem, cursor: usize }, /// Name input for a new playlist; when `for_track` is set, the track is /// added to it right after creation. @@ -326,8 +333,16 @@ pub enum Popup { input: String, busy: bool, }, - /// Connected devices list; Enter transfers active playback to the row. - Devices { cursor: usize }, + /// Metadata edit form for a track, release, artist or playlist. + Edit { + target: EditTarget, + title: String, + fields: Vec, + focus: usize, + error: Option, + }, + /// Delete confirmation; Enter/y deletes, Esc/n cancels. + ConfirmDelete { target: DeleteTarget, label: String }, /// Track metadata viewer; left/right switch between selected tracks. TrackInfo { tracks: Vec, @@ -336,15 +351,67 @@ pub enum Popup { }, /// Full, wrapped view of one log entry (Enter on the Logs tab). LogDetail(crate::config::logging::LogEntry), + /// One-line text entry on the Federation tab (network id, peer ticket). + FedInput { + field: FedInputField, + input: String, + }, + /// Wrapped read-only text (this peer's connection ticket). + FedText { title: String, text: String }, } -/// User's own playlists eligible as add-targets (the virtual Likes playlist -/// is managed through likes, not direct adds). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FedInputField { + NetworkId, + ConnectTicket, +} + +impl FedInputField { + pub fn title(self) -> &'static str { + match self { + FedInputField::NetworkId => "Network ID", + FedInputField::ConnectTicket => "Connect to peer (paste ticket)", + } + } +} + +/// Rows of the Federation tab, in display order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FedRow { + Toggle, + NetworkId, + SaveOnListen, + SyncNow, + ShowTicket, + Connect, +} + +impl FedRow { + pub const ALL: [FedRow; 6] = [ + FedRow::Toggle, + FedRow::NetworkId, + FedRow::SaveOnListen, + FedRow::SyncNow, + FedRow::ShowTicket, + FedRow::Connect, + ]; +} + +/// The Federation tab: settings mirror + the latest status snapshot. +#[derive(Debug, Default)] +pub struct FederationTab { + pub cursor: usize, + pub settings: crate::federation::FedSettings, + pub status: Option, +} + +/// Playlists eligible as add-targets (the virtual Likes playlist is managed +/// through likes, not direct adds). pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> { match &state.playlists.list { Some(Loadable::Ready(list)) => list .iter() - .filter(|p| p.is_own && p.kind != "likes") + .filter(|p| p.kind != "likes") .map(|p| (p.id, p.title.clone())) .collect(), _ => Vec::new(), @@ -367,85 +434,10 @@ pub struct SearchState { pub query: String, pub loading: bool, pub results: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Screen { - #[default] - Main, - Login, -} - -/// SSO is the primary sign-in path, so it sits right under the server URL; -/// the password fields below are the rare fallback. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum LoginField { - #[default] - ServerUrl, - SsoButton, - Username, - Password, - SignInButton, -} - -impl LoginField { - const ORDER: [LoginField; 5] = [ - LoginField::ServerUrl, - LoginField::SsoButton, - LoginField::Username, - LoginField::Password, - LoginField::SignInButton, - ]; - - pub fn next(self) -> LoginField { - let i = Self::ORDER.iter().position(|f| *f == self).unwrap(); - Self::ORDER[(i + 1) % Self::ORDER.len()] - } - - pub fn prev(self) -> LoginField { - let i = Self::ORDER.iter().position(|f| *f == self).unwrap(); - Self::ORDER[(i + Self::ORDER.len() - 1) % Self::ORDER.len()] - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum LoginMode { - /// Server / username / password fields plus the SSO button. - #[default] - Form, - /// Browser SSO started; waiting for the pasted callback link or code. - SsoPending, -} - -#[derive(Debug)] -pub struct LoginForm { - pub server_url: String, - pub username: String, - pub password: String, - pub sso_paste: String, - pub sso_url: String, - pub sso_port: Option, - pub focus: LoginField, - pub mode: LoginMode, - pub busy: bool, - pub error: Option, -} - -impl Default for LoginForm { - fn default() -> Self { - Self { - server_url: "https://music.hexor.cy".to_string(), - username: String::new(), - password: String::new(), - sso_paste: String::new(), - sso_url: String::new(), - sso_port: None, - focus: LoginField::default(), - mode: LoginMode::default(), - busy: false, - error: None, - } - } + /// Tracks found on the federated network (empty while federation is + /// off); rendered as a separate, marked section. + pub fed_tracks: Vec, + pub fed_loading: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -454,17 +446,25 @@ pub enum Tab { Global, Playlists, Queue, + Federation, Logs, } impl Tab { - pub const ALL: [Tab; 4] = [Tab::Global, Tab::Playlists, Tab::Queue, Tab::Logs]; + pub const ALL: [Tab; 5] = [ + Tab::Global, + Tab::Playlists, + Tab::Queue, + Tab::Federation, + Tab::Logs, + ]; pub fn title(self) -> &'static str { match self { Tab::Global => "Global", Tab::Playlists => "Playlists", Tab::Queue => "Queue", + Tab::Federation => "Federation", Tab::Logs => "Logs", } } @@ -490,6 +490,7 @@ impl Tab { Tab::Global => KeyContext::Library, Tab::Playlists => KeyContext::Playlists, Tab::Queue => KeyContext::Queue, + Tab::Federation => KeyContext::Federation, Tab::Logs => KeyContext::Logs, } } @@ -567,7 +568,6 @@ impl Default for PlayerBar { /// event handlers in the main loop; views render from `&AppState`. #[derive(Debug, Default)] pub struct AppState { - pub screen: Screen, pub active_tab: Tab, pub should_quit: bool, /// Double-press quit confirmation: set by the first Quit press, expires @@ -577,8 +577,6 @@ pub struct AppState { pub pending_keys: Option, pub status_message: Option, pub player: PlayerBar, - pub login: LoginForm, - pub user: Option, pub global: GlobalTab, pub artist_views: HashMap>, pub release_views: HashMap>, @@ -588,8 +586,8 @@ pub struct AppState { pub likes: std::collections::HashSet, pub likes_loaded: bool, pub logs: LogsTab, - pub devices: DevicesState, pub queue_tab: QueueTab, + pub federation: FederationTab, pub track_selection: TrackSelection, /// Shift-J jump in flight: focus this (release, track) once the release /// view finishes loading. diff --git a/src/app/update.rs b/src/app/update.rs index e0c224d..1552460 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1,7 +1,7 @@ use std::time::{Duration, Instant}; use super::action::Action; -use crate::api::models::TrackItem; +use crate::library::models::TrackItem; use super::state::{ AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab, @@ -31,11 +31,24 @@ pub enum Effect { ToggleLikes { track_ids: Vec, }, + /// Remove tracks from a stored playlist in the library. + RemoveFromPlaylist { + playlist_id: i64, + track_ids: Vec, + }, RemoveQueueIndices { indices: Vec, restart_paused: Option, stop: bool, }, + /// Persist the Federation-tab settings and start/stop the node. + FedApplySettings, + /// Force an immediate library publish into the DHT. + FedSyncNow, + /// Fetch this peer's ticket and show it in a popup. + FedShowTicket, + /// Download (or resolve) a federated track and play it. + FedPlay(crate::federation::FedTrack), } pub fn update(state: &mut AppState, action: Action) -> Option { @@ -195,18 +208,6 @@ pub fn update(state: &mut AppState, action: Action) -> Option { } None } - Action::OpenDevices => { - let cursor = state - .devices - .devices - .iter() - .position(|device| { - device.id == state.devices.active_device_id.clone().unwrap_or_default() - }) - .unwrap_or(0); - state.popup = Some(super::state::Popup::Devices { cursor }); - None - } Action::Select => select_current(state), Action::Back if state.track_selection.is_active() => { state.track_selection.clear(); @@ -310,9 +311,243 @@ pub fn update(state: &mut AppState, action: Action) -> Option { None } } - // Needs the Runtime, so it is intercepted in app::handle_main_key - // before reaching update(). - Action::Logout => None, + Action::EditSelected => { + open_edit_popup(state); + None + } + Action::DeleteSelected => delete_selected(state), + } +} + +/// `e`: open the metadata edit form for whatever is under the cursor — +/// an artist tile, a release, a track or a playlist. +fn open_edit_popup(state: &mut AppState) { + use super::state::{EditField, EditTarget, Popup}; + + if state.active_tab == Tab::Playlists && state.playlists.opened.is_none() { + let card = match &state.playlists.list { + Some(Loadable::Ready(list)) => list.get(state.playlists.selected).cloned(), + _ => None, + }; + let Some(card) = card else { + return; + }; + if card.kind == "likes" { + state.status_message = Some("the Likes playlist cannot be edited".into()); + return; + } + state.popup = Some(Popup::Edit { + target: EditTarget::Playlist(card.id), + title: format!("Edit playlist — {}", card.title), + fields: vec![EditField::new("Title", card.title.clone())], + focus: 0, + error: None, + }); + return; + } + if state.active_tab == Tab::Global && state.global.stack.is_empty() { + let Some(artist) = state.global.artists.get(state.global.selected).cloned() else { + return; + }; + state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path)); + return; + } + if let Some(artist) = selected_search_artist(state) { + state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path)); + return; + } + if let Some(release) = selected_release_card(state) { + state.popup = Some(Popup::Edit { + target: EditTarget::Release(release.id), + title: format!("Edit release — {}", release.title), + fields: vec![ + EditField::new("Title", release.title.clone()), + EditField::new("Type", release.release_type.clone()), + EditField::new( + "Year", + release.year.map(|y| y.to_string()).unwrap_or_default(), + ), + ], + focus: 0, + error: None, + }); + return; + } + if let Some(track) = selected_track(state).or_else(|| state.player.current.clone()) { + state.popup = Some(track_edit_popup(&track)); + return; + } + state.status_message = Some("nothing to edit here".into()); +} + +fn artist_edit_popup( + id: i64, + name: &str, + image_path: Option, +) -> super::state::Popup { + use super::state::{EditField, EditTarget, Popup}; + Popup::Edit { + target: EditTarget::Artist(id), + title: format!("Edit artist — {name}"), + fields: vec![ + EditField::new("Name", name), + EditField::new("Image path", image_path.unwrap_or_default()), + ], + focus: 0, + error: None, + } +} + +fn track_edit_popup(track: &TrackItem) -> super::state::Popup { + use super::state::{EditField, EditTarget, Popup}; + let join = |artists: &[crate::library::models::ArtistRef]| { + artists + .iter() + .map(|a| a.name.clone()) + .collect::>() + .join("; ") + }; + Popup::Edit { + target: EditTarget::Track(track.id), + title: format!("Edit track — {}", track.title), + fields: vec![ + EditField::new("Title", track.title.clone()), + EditField::new("Artists", join(&track.artists)), + EditField::new("Featured", join(&track.featured_artists)), + EditField::new( + "Track #", + track.track_number.map(|n| n.to_string()).unwrap_or_default(), + ), + EditField::new( + "Disc #", + track.disc_number.map(|n| n.to_string()).unwrap_or_default(), + ), + ], + focus: 0, + error: None, + } +} + +/// shift-d: delete whatever is under the cursor. Library entities ask for +/// confirmation; playlist/queue rows are removed directly. +fn delete_selected(state: &mut AppState) -> Option { + use super::state::{DeleteTarget, Popup}; + + if state.active_tab == Tab::Queue { + return remove_selected_from_queue(state); + } + if state.active_tab == Tab::Playlists { + match state.playlists.opened { + None => { + let card = match &state.playlists.list { + Some(Loadable::Ready(list)) => list.get(state.playlists.selected).cloned(), + _ => None, + }; + let card = card?; + if card.kind == "likes" { + state.status_message = Some("the Likes playlist cannot be deleted".into()); + return None; + } + state.popup = Some(Popup::ConfirmDelete { + target: DeleteTarget::Playlist(card.id), + label: format!("playlist \"{}\"", card.title), + }); + return None; + } + Some(opened) => { + let tracks = selected_tracks(state); + if tracks.is_empty() { + state.status_message = Some("no track selected".into()); + return None; + } + let track_ids: Vec = tracks.iter().map(|track| track.id).collect(); + state.track_selection.clear(); + if opened.id == super::state::LIKES_PLAYLIST_ID { + let liked: Vec = track_ids + .into_iter() + .filter(|id| state.likes.contains(id)) + .collect(); + state.status_message = Some(format!("removing {} like(s)", liked.len())); + return Some(Effect::ToggleLikes { track_ids: liked }); + } + state.status_message = + Some(format!("removing {} track(s) from playlist", track_ids.len())); + return Some(Effect::RemoveFromPlaylist { + playlist_id: opened.id, + track_ids, + }); + } + } + } + if state.active_tab != Tab::Global { + return None; + } + if state.global.stack.is_empty() { + let artist = state.global.artists.get(state.global.selected).cloned()?; + state.popup = Some(Popup::ConfirmDelete { + target: DeleteTarget::Artist(artist.id), + label: format!("artist \"{}\" with all their releases and tracks", artist.name), + }); + return None; + } + if let Some(artist) = selected_search_artist(state) { + state.popup = Some(Popup::ConfirmDelete { + target: DeleteTarget::Artist(artist.id), + label: format!("artist \"{}\" with all their releases and tracks", artist.name), + }); + return None; + } + if let Some(release) = selected_release_card(state) { + state.popup = Some(Popup::ConfirmDelete { + target: DeleteTarget::Release(release.id), + label: format!("release \"{}\" with all its tracks", release.title), + }); + return None; + } + if let Some(track) = selected_track(state) { + state.popup = Some(Popup::ConfirmDelete { + target: DeleteTarget::Track(track.id), + label: format!("track \"{}\" (the audio file stays on disk)", track.title), + }); + } else { + state.status_message = Some("nothing to delete here".into()); + } + None +} + +/// The artist under the cursor in the search view, if any. +fn selected_search_artist(state: &AppState) -> Option { + if state.active_tab != Tab::Global { + return None; + } + let Some(GlobalView::Search { cursor }) = state.global.stack.last() else { + return None; + }; + state.search.results.as_ref()?.artists.get(*cursor).cloned() +} + +/// The release card under the cursor (artist view tiles/rows or search). +fn selected_release_card(state: &AppState) -> Option { + if state.active_tab != Tab::Global { + return None; + } + match state.global.stack.last()? { + GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { + Loadable::Ready(detail) => { + let position = cursor.checked_sub(detail.top_tracks.len())?; + let order = release_display_order(&detail.releases); + order + .get(position) + .map(|&index| detail.releases[index].clone()) + } + _ => None, + }, + GlobalView::Search { cursor } => { + let results = state.search.results.as_ref()?; + let offset = cursor.checked_sub(results.artists.len())?; + results.releases.get(offset).cloned() + } + GlobalView::Release { .. } => None, } } @@ -386,11 +621,10 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val } } TrackSelectionScope::Playlist(id) => { - if let Some(opened) = &mut state.playlists.opened { - if opened.id == *id { + if let Some(opened) = &mut state.playlists.opened + && opened.id == *id { opened.cursor = value; } - } } TrackSelectionScope::Queue => { state.queue_tab.cursor = value; @@ -438,7 +672,7 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, state.queue_tab.cursor, state.player.queue.len(), )), - Tab::Logs => None, + Tab::Federation | Tab::Logs => None, } } @@ -487,7 +721,7 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, & state.queue_tab.cursor, &state.player.queue, )), - Tab::Logs => None, + Tab::Federation | Tab::Logs => None, } } @@ -653,7 +887,7 @@ pub fn selected_track(state: &AppState) -> Option { .cloned() } Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(), - Tab::Logs => None, + Tab::Federation | Tab::Logs => None, } } @@ -769,11 +1003,10 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec, next: bool) for (offset, track) in tracks.into_iter().enumerate() { player.queue.insert(insert_at + offset, track); } - if let Some(prefetched) = &mut player.prefetched_pos { - if insert_at <= *prefetched { + if let Some(prefetched) = &mut player.prefetched_pos + && insert_at <= *prefetched { *prefetched += count; } - } if insert_at <= player.queue_pos && player.current.is_some() { player.queue_pos += count; } @@ -885,7 +1118,7 @@ pub fn restore_queue_order(player: &mut super::state::PlayerBar) { } let tail = player.queue.split_off(start); let mut used = vec![false; order.len()]; - let mut keyed: Vec<(usize, usize, crate::api::models::TrackItem)> = tail + let mut keyed: Vec<(usize, usize, crate::library::models::TrackItem)> = tail .into_iter() .enumerate() .map(|(position, track)| { @@ -954,6 +1187,14 @@ fn page_step(state: &AppState) -> isize { } fn move_selection(state: &mut AppState, dx: isize, dy: isize) { + if state.active_tab == Tab::Federation { + if dy != 0 { + let last = super::state::FedRow::ALL.len() as isize - 1; + state.federation.cursor = + (state.federation.cursor as isize + dy).clamp(0, last) as usize; + } + return; + } if state.active_tab == Tab::Logs { // The cursor anchors to an entry's seq, so freshly appended log // lines (including ones caused by this very keypress) don't shift @@ -1138,6 +1379,9 @@ fn current_view_len(state: &AppState) -> usize { if state.active_tab == Tab::Queue { return state.player.queue.len(); } + if state.active_tab == Tab::Federation { + return super::state::FedRow::ALL.len(); + } match state.global.stack.last() { None => state.global.artists.len(), Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) { @@ -1150,7 +1394,9 @@ fn current_view_len(state: &AppState) -> usize { Some(Loadable::Ready(d)) => d.tracks.len(), _ => 0, }, - Some(GlobalView::Search { .. }) => state.search.results.as_ref().map_or(0, |r| r.len()), + Some(GlobalView::Search { .. }) => { + state.search.results.as_ref().map_or(0, |r| r.len()) + state.search.fed_tracks.len() + } } } @@ -1173,15 +1419,19 @@ fn jump_selection(state: &mut AppState, first: bool) { } return; } + if state.active_tab == Tab::Federation { + let last = super::state::FedRow::ALL.len() - 1; + state.federation.cursor = if first { 0 } else { last }; + return; + } if state.active_tab == Tab::Logs { if first { let level = super::state::LOG_LEVELS[state.logs.level_index]; - if let Some(buffer) = crate::config::logging::buffer() { - if let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) { + if let Some(buffer) = crate::config::logging::buffer() + && let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) { state.logs.selected_seq = Some(seq); state.logs.follow = false; } - } } else { state.logs.follow = true; state.logs.selected_seq = None; @@ -1257,6 +1507,9 @@ fn select_current(state: &mut AppState) -> Option { } return None; } + if state.active_tab == Tab::Federation { + return federation_select(state); + } // Queue: jump playback to the track under the cursor. Earlier tracks // stay in the queue as "played"; picking one of them just moves the // playing position back. @@ -1274,9 +1527,10 @@ fn select_current(state: &mut AppState) -> Option { enum Outcome { Push(GlobalView), Play { - tracks: Vec, + tracks: Vec, start: usize, }, + PlayFed(crate::federation::FedTrack), Nothing, } let outcome = match state.global.stack.last().copied() { @@ -1347,10 +1601,17 @@ fn select_current(state: &mut AppState) -> Option { start: cursor - artists - releases, } } else { - Outcome::Nothing + let fed_index = cursor - artists - releases - results.tracks.len(); + match state.search.fed_tracks.get(fed_index) { + Some(fed) => Outcome::PlayFed(fed.clone()), + None => Outcome::Nothing, + } } } - None => Outcome::Nothing, + None => match state.search.fed_tracks.get(cursor) { + Some(fed) => Outcome::PlayFed(fed.clone()), + None => Outcome::Nothing, + }, }, }; match outcome { @@ -1364,13 +1625,57 @@ fn select_current(state: &mut AppState) -> Option { on_new_queue(state); Some(Effect::PlayCurrent) } + Outcome::PlayFed(fed) => { + state.status_message = Some(format!("federation: fetching \"{}\"…", fed.title)); + Some(Effect::FedPlay(fed)) + } Outcome::Nothing => None, } } +/// Enter on the Federation tab: toggle switches, open text inputs, run +/// one-shot operations. The heavy lifting happens in perform_effect(). +fn federation_select(state: &mut AppState) -> Option { + use super::state::{FedInputField, FedRow, Popup}; + match FedRow::ALL.get(state.federation.cursor)? { + FedRow::Toggle => { + let settings = &mut state.federation.settings; + if !settings.enabled && settings.network_id.trim().is_empty() { + state.popup = Some(Popup::FedInput { + field: FedInputField::NetworkId, + input: String::new(), + }); + return None; + } + settings.enabled = !settings.enabled; + Some(Effect::FedApplySettings) + } + FedRow::NetworkId => { + state.popup = Some(Popup::FedInput { + field: FedInputField::NetworkId, + input: state.federation.settings.network_id.clone(), + }); + None + } + FedRow::SaveOnListen => { + state.federation.settings.save_on_listen = !state.federation.settings.save_on_listen; + Some(Effect::FedApplySettings) + } + FedRow::SyncNow => Some(Effect::FedSyncNow), + FedRow::ShowTicket => Some(Effect::FedShowTicket), + FedRow::Connect => { + state.popup = Some(Popup::FedInput { + field: FedInputField::ConnectTicket, + input: String::new(), + }); + None + } + } +} + /// A freshly created play context: drop the stale pre-shuffle snapshot and, /// if shuffle is on, shuffle everything after the chosen track right away. -fn on_new_queue(state: &mut AppState) { +pub(super) fn on_new_queue(state: &mut AppState) { let player = &mut state.player; player.original_order = None; if player.shuffle && !player.queue.is_empty() { @@ -1409,19 +1714,17 @@ fn go_back(state: &mut AppState) { Tab::Global => { // Esc on a view opened by Shift-J from another tab goes back to // that tab, not down the Global stack. - if let Some((origin, depth)) = state.jump_origin { - if state.global.stack.len() == depth + 1 { + if let Some((origin, depth)) = state.jump_origin + && state.global.stack.len() == depth + 1 { state.global.stack.pop(); state.jump_origin = None; state.active_tab = origin; return; } - } - if let Some(popped) = state.global.stack.pop() { - if matches!(popped, GlobalView::Search { .. }) { + if let Some(popped) = state.global.stack.pop() + && matches!(popped, GlobalView::Search { .. }) { state.search = SearchState::default(); } - } } _ => {} } @@ -1450,6 +1753,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) { state.global.stack.clear(); } Tab::Playlists => state.playlists.opened = None, + Tab::Federation => state.federation.cursor = 0, Tab::Logs => { state.logs.follow = true; state.logs.selected_seq = None; @@ -1465,7 +1769,7 @@ fn not_yet(state: &mut AppState, what: &str) { #[cfg(test)] mod tests { use super::*; - use crate::api::models::{ArtistCard, ArtistDetail, TrackItem}; + use crate::library::models::{ArtistCard, ArtistDetail, TrackItem}; fn with_artists(n: usize) -> AppState { let mut state = AppState::default(); @@ -1473,7 +1777,7 @@ mod tests { .map(|i| ArtistCard { id: i as i64, name: format!("artist {i}"), - image_url: None, + image_path: None, release_count: 1, track_count: 2, }) @@ -1493,18 +1797,14 @@ mod tests { release_id: 1, release_title: "r".into(), release_year: None, - cover_url: None, - stream_url: format!("/s/{id}"), - uploader_name: String::new(), + cover_path: None, + file_path: format!("/s/{id}"), audio_format: None, audio_bitrate: None, audio_sample_rate: None, audio_bit_depth: None, file_size_bytes: None, - lastfm_listeners: None, - lastfm_playcount: None, - lastfm_rating: None, - lastfm_updated_at: None, + play_count: 0, } } @@ -1619,14 +1919,14 @@ mod tests { #[test] fn artist_tiles_move_by_visual_rows_across_groups() { - use crate::api::models::{ArtistDetail, ReleaseCard}; + use crate::library::models::{ArtistDetail, ReleaseCard}; let release = |id: i64, kind: &str| ReleaseCard { id, title: format!("r{id}"), release_type: kind.to_string(), year: None, - cover_url: None, + cover_path: None, track_count: 1, }; // columns = 3 in tests (no tty → 80 wide): albums rows [0,1,2],[3], @@ -1634,7 +1934,7 @@ mod tests { let detail = ArtistDetail { id: 1, name: "a".into(), - image_url: None, + image_path: None, total_track_count: 0, total_play_count: 0, top_tracks: vec![], @@ -1707,7 +2007,7 @@ mod tests { #[test] fn queue_advances_and_respects_repeat() { - use crate::api::models::TrackItem; + use crate::library::models::TrackItem; use crate::app::state::RepeatMode; let track = |id: i64| TrackItem { @@ -1721,18 +2021,14 @@ mod tests { release_id: 1, release_title: "r".into(), release_year: None, - cover_url: None, - stream_url: format!("/api/player/stream/{id}"), - uploader_name: String::new(), + cover_path: None, + file_path: format!("/api/player/stream/{id}"), audio_format: None, audio_bitrate: None, audio_sample_rate: None, audio_bit_depth: None, file_size_bytes: None, - lastfm_listeners: None, - lastfm_playcount: None, - lastfm_rating: None, - lastfm_updated_at: None, + play_count: 0, }; let mut state = AppState::default(); state.player.queue = vec![track(1), track(2)]; @@ -1776,7 +2072,7 @@ mod tests { #[test] fn queue_tab_select_and_clear() { - use crate::api::models::TrackItem; + use crate::library::models::TrackItem; let track = |id: i64| TrackItem { id, title: format!("t{id}"), @@ -1788,18 +2084,14 @@ mod tests { release_id: 1, release_title: "r".into(), release_year: None, - cover_url: None, - stream_url: format!("/s/{id}"), - uploader_name: String::new(), + cover_path: None, + file_path: format!("/s/{id}"), audio_format: None, audio_bitrate: None, audio_sample_rate: None, audio_bit_depth: None, file_size_bytes: None, - lastfm_listeners: None, - lastfm_playcount: None, - lastfm_rating: None, - lastfm_updated_at: None, + play_count: 0, }; let mut state = AppState { active_tab: Tab::Queue, @@ -1870,7 +2162,7 @@ mod tests { Loadable::Ready(ArtistDetail { id: 9, name: "artist".into(), - image_url: None, + image_path: None, total_track_count: 3, total_play_count: 0, top_tracks: (1..=3).map(test_track).collect(), @@ -1942,7 +2234,7 @@ mod tests { #[test] fn shuffle_reorders_tail_and_restores() { - use crate::api::models::TrackItem; + use crate::library::models::TrackItem; let track = |id: i64| TrackItem { id, title: format!("t{id}"), @@ -1954,18 +2246,14 @@ mod tests { release_id: 1, release_title: "r".into(), release_year: None, - cover_url: None, - stream_url: format!("/s/{id}"), - uploader_name: String::new(), + cover_path: None, + file_path: format!("/s/{id}"), audio_format: None, audio_bitrate: None, audio_sample_rate: None, audio_bit_depth: None, file_size_bytes: None, - lastfm_listeners: None, - lastfm_playcount: None, - lastfm_rating: None, - lastfm_updated_at: None, + play_count: 0, }; let mut state = AppState::default(); state.player.queue = (1..=8).map(track).collect(); @@ -1991,7 +2279,7 @@ mod tests { #[test] fn shift_j_opens_release_from_queue() { - use crate::api::models::{ReleaseDetail, TrackItem}; + use crate::library::models::{ReleaseDetail, TrackItem}; let track = |id: i64, release_id: i64| TrackItem { id, title: format!("t{id}"), @@ -2003,18 +2291,14 @@ mod tests { release_id, release_title: "r".into(), release_year: None, - cover_url: None, - stream_url: format!("/s/{id}"), - uploader_name: String::new(), + cover_path: None, + file_path: format!("/s/{id}"), audio_format: None, audio_bitrate: None, audio_sample_rate: None, audio_bit_depth: None, file_size_bytes: None, - lastfm_listeners: None, - lastfm_playcount: None, - lastfm_rating: None, - lastfm_updated_at: None, + play_count: 0, }; let mut state = AppState { active_tab: Tab::Queue, @@ -2048,10 +2332,9 @@ mod tests { title: "r".into(), release_type: "album".into(), year: None, - cover_url: None, + cover_path: None, artists: vec![], tracks: vec![track(1, 7), track(2, 7)], - uploaders: vec![], }), ); state.active_tab = Tab::Queue; diff --git a/src/config/default_keymap.toml b/src/config/default_keymap.toml index ac8de00..9d12384 100644 --- a/src/config/default_keymap.toml +++ b/src/config/default_keymap.toml @@ -9,7 +9,7 @@ # command: an Action name, optionally with parameters: # command = { SeekForward = { seconds = 30 } } # context: optional view filter — global (default), library, search, -# playlists, queue, devices. +# playlists, queue, logs. [[keymaps]] key_sequence = "q" @@ -47,6 +47,10 @@ command = { GoToTab = 2 } key_sequence = "4" command = { GoToTab = 3 } +[[keymaps]] +key_sequence = "5" +command = { GoToTab = 4 } + [[keymaps]] key_sequence = "a" command = "QueueAddNext" @@ -204,12 +208,12 @@ key_sequence = "i" command = "OpenTrackInfo" [[keymaps]] -key_sequence = "shift-l" -command = "Logout" +key_sequence = "e" +command = "EditSelected" [[keymaps]] key_sequence = "shift-d" -command = "OpenDevices" +command = "DeleteSelected" [[keymaps]] key_sequence = "v" diff --git a/src/config/keymap.rs b/src/config/keymap.rs index 0e200a7..dc23333 100644 --- a/src/config/keymap.rs +++ b/src/config/keymap.rs @@ -20,7 +20,7 @@ pub enum KeyContext { Search, Playlists, Queue, - Devices, + Federation, Logs, } @@ -32,7 +32,7 @@ impl KeyContext { KeyContext::Search => "search", KeyContext::Playlists => "playlists", KeyContext::Queue => "queue", - KeyContext::Devices => "devices", + KeyContext::Federation => "federation", KeyContext::Logs => "logs", } } @@ -81,8 +81,8 @@ impl Keymap { let mut bindings = parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse"); let mut warning = None; - if let Some(path) = user_keymap_path() { - if path.exists() { + if let Some(path) = user_keymap_path() + && path.exists() { match fs::read_to_string(&path) .map_err(anyhow::Error::from) .and_then(|text| parse_bindings(&text)) @@ -96,7 +96,6 @@ impl Keymap { } } } - } let keymap = Self { bindings, pending: Vec::new(), @@ -219,11 +218,10 @@ enum Lookup { /// Letters (of any alphabet) keep SHIFT (that is how "shift-g" works); /// symbols drop it so a "?" binding matches everywhere. fn normalize(key: KeyCombination) -> KeyCombination { - if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes { - if !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) { + if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes + && !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) { return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT); } - } key } @@ -303,8 +301,8 @@ fn parse_chord(chord: &str) -> Result { // crokey only parses single-byte characters; non-ASCII keys (Cyrillic // bindings) are built directly. let mut chars = chord.chars(); - if let (Some(c), None) = (chars.next(), chars.next()) { - if !c.is_ascii() { + if let (Some(c), None) = (chars.next(), chars.next()) + && !c.is_ascii() { let modifiers = if c.is_uppercase() { KeyModifiers::SHIFT } else { @@ -312,7 +310,6 @@ fn parse_chord(chord: &str) -> Result { }; return Ok(KeyCombination::new(KeyCode::Char(c), modifiers)); } - } KeyCombination::from_str(chord) .map_err(|e| anyhow::anyhow!("{e}")) .map(normalize) diff --git a/src/config/mod.rs b/src/config/mod.rs index 895dc72..2f6631e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,54 +2,7 @@ pub mod keymap; pub mod logging; use directories::ProjectDirs; -use std::fs; -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; pub fn project_dirs() -> Option { ProjectDirs::from("", "", "furumi") } - -pub fn device_id_path() -> Option { - project_dirs().map(|dirs| dirs.config_dir().join("device_id")) -} - -pub fn load_or_create_device_id() -> String { - if let Some(path) = device_id_path() { - if let Ok(raw) = fs::read_to_string(&path) { - let id = raw.trim(); - if valid_device_id(id) { - return id.to_string(); - } - } - - let id = generate_device_id(); - if let Some(parent) = path.parent() { - if let Err(err) = fs::create_dir_all(parent) { - tracing::warn!(path = %parent.display(), %err, "failed to create config directory"); - return id; - } - } - if let Err(err) = fs::write(&path, &id) { - tracing::warn!(path = %path.display(), %err, "failed to persist device id"); - } - return id; - } - generate_device_id() -} - -fn valid_device_id(id: &str) -> bool { - !id.is_empty() - && id.len() <= 128 - && id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') -} - -fn generate_device_id() -> String { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - format!("tui-{nanos:x}-{:x}", std::process::id()) -} diff --git a/src/federation/audio.rs b/src/federation/audio.rs new file mode 100644 index 0000000..f7421e5 --- /dev/null +++ b/src/federation/audio.rs @@ -0,0 +1,311 @@ +//! The peer-to-peer audio protocol, wire compatible with furumi-fd. +//! +//! One byte stream per request: the requester sends one JSON line +//! ([`AudioRequest`]) and receives one JSON line ([`AudioResponseHeader`]) +//! followed by the raw file bytes from the requested offset. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; + +use crate::library::Library; + +/// ALPN of the audio streaming protocol (shared with furumi-fd). +pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1"; + +/// Maximum size of a JSON protocol line (request or response header). +const MAX_PROTOCOL_LINE: usize = 4096; + +#[derive(Debug, Serialize, Deserialize)] +struct AudioRequest { + /// Hex-encoded [`ItemId`] of the track. + item_id: String, + /// Byte offset to start streaming from. + offset: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AudioResponseHeader { + ok: bool, + #[serde(default)] + error: Option, + #[serde(default)] + mime_type: String, + #[serde(default)] + total_size: u64, + #[serde(default)] + offset: u64, +} + +pub fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +pub fn hex_decode_item_id(value: &str) -> Option { + if value.len() != 64 { + return None; + } + let mut bytes = [0u8; 32]; + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16).ok()?; + } + Some(ItemId::from_bytes(bytes)) +} + +fn guess_mime(path: &Path) -> &'static str { + match path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "mp3" => "audio/mpeg", + "flac" => "audio/flac", + "ogg" | "oga" => "audio/ogg", + "opus" => "audio/opus", + "wav" => "audio/wav", + "m4a" | "mp4" | "alac" => "audio/mp4", + "aac" => "audio/aac", + "aiff" | "aif" => "audio/aiff", + _ => "application/octet-stream", + } +} + +/// Extension for a downloaded file, from the mime type the peer reported. +fn extension_for_mime(mime: &str) -> &'static str { + match mime { + "audio/mpeg" => "mp3", + "audio/flac" | "audio/x-flac" => "flac", + "audio/ogg" => "ogg", + "audio/opus" => "opus", + "audio/wav" | "audio/x-wav" => "wav", + "audio/mp4" | "audio/x-m4a" => "m4a", + "audio/aac" => "aac", + "audio/aiff" => "aiff", + _ => "bin", + } +} + +/// Reads one `\n`-terminated line, bounded by [`MAX_PROTOCOL_LINE`]. +async fn read_line(reader: &mut R) -> Result> { + let mut line = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = reader.read(&mut byte).await?; + if n == 0 { + anyhow::bail!("stream ended before the protocol line was complete"); + } + if byte[0] == b'\n' { + return Ok(line); + } + line.push(byte[0]); + if line.len() > MAX_PROTOCOL_LINE { + anyhow::bail!("protocol line exceeds {MAX_PROTOCOL_LINE} bytes"); + } + } +} + +async fn write_line(writer: &mut W, value: &impl Serialize) -> Result<()> { + let mut line = serde_json::to_vec(value)?; + line.push(b'\n'); + writer.write_all(&line).await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Requesting side: download a track from its owner +// --------------------------------------------------------------------------- + +/// Downloads a whole track from `owner` into `dir/.`; returns +/// the file path and the mime type the peer reported. An already complete +/// cached file is reused. +pub async fn download_track( + service: &MusicDhtService, + owner: EndpointId, + item_id_hex: &str, + dir: &Path, + stem: &str, +) -> Result<(PathBuf, String)> { + let mut stream = service + .open_stream(owner, AUDIO_ALPN) + .await + .map_err(|err| anyhow::anyhow!("cannot reach the owner peer: {err}"))?; + write_line( + &mut stream.send, + &AudioRequest { + item_id: item_id_hex.to_string(), + offset: 0, + }, + ) + .await?; + stream.send.finish()?; + let header: AudioResponseHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?) + .context("malformed response header")?; + if !header.ok { + anyhow::bail!( + "peer refused the stream: {}", + header.error.unwrap_or_else(|| "unknown error".to_string()) + ); + } + + let extension = extension_for_mime(&header.mime_type); + let path = dir.join(format!("{stem}.{extension}")); + if let Ok(metadata) = tokio::fs::metadata(&path).await + && metadata.len() == header.total_size + && header.total_size > 0 + { + // Already fully downloaded earlier; no need to fetch again. + return Ok((path, header.mime_type)); + } + + let temp_path = dir.join(format!(".{stem}.{extension}.part")); + let mut file = tokio::fs::File::create(&temp_path).await?; + let mut received: u64 = 0; + let mut chunk = vec![0u8; 64 * 1024]; + // quinn's inherent read returns None when the peer finished the stream. + while let Some(n) = stream.recv.read(&mut chunk).await? { + file.write_all(&chunk[..n]).await?; + received += n as u64; + } + file.flush().await?; + drop(file); + if header.total_size > 0 && received != header.total_size { + let _ = tokio::fs::remove_file(&temp_path).await; + anyhow::bail!( + "download incomplete: got {received} of {} bytes", + header.total_size + ); + } + tokio::fs::rename(&temp_path, &path).await?; + Ok((path, header.mime_type)) +} + +// --------------------------------------------------------------------------- +// Serving side: answer audio requests from other peers +// --------------------------------------------------------------------------- + +/// Finds the local track whose derived DHT item id matches `item_id`. +pub fn resolve_local_track_id( + library: &Library, + own: EndpointId, + item_id: ItemId, +) -> Result> { + let export = library.federation_export()?; + for track in export.tracks { + let derived = ItemId::derive(&own, ItemKind::Track, &format!("track:{}", track.id)); + if derived == item_id { + return Ok(Some(track.id)); + } + } + Ok(None) +} + +fn resolve_local_file( + library: &Library, + own: EndpointId, + item_id: ItemId, +) -> Result> { + let export = library.federation_export()?; + for track in export.tracks { + let derived = ItemId::derive(&own, ItemKind::Track, &format!("track:{}", track.id)); + if derived == item_id { + return Ok(Some(track.file_path)); + } + } + Ok(None) +} + +/// Runs the accept loop of the audio protocol until the acceptor closes. +/// Every track of the local library is downloadable by every peer of the +/// network — the libraries of all participants are equal. +pub async fn serve_peers(mut acceptor: StreamAcceptor, library: Arc, own: EndpointId) { + while let Some(stream) = acceptor.accept().await { + let library = Arc::clone(&library); + tokio::spawn(async move { + let peer = stream.peer_id; + if let Err(err) = serve_one(stream, library, own).await { + tracing::warn!(peer = %peer, "audio stream failed: {err:#}"); + } + }); + } +} + +async fn serve_one(mut stream: ByteStream, library: Arc, own: EndpointId) -> Result<()> { + let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?; + tracing::info!( + peer = %stream.peer_id, + item = %request.item_id, + offset = request.offset, + "peer requested audio" + ); + + let resolved = match hex_decode_item_id(&request.item_id) { + Some(item_id) => { + let library = Arc::clone(&library); + match tokio::task::spawn_blocking(move || resolve_local_file(&library, own, item_id)) + .await + { + Ok(Ok(Some(path))) => Ok(path), + Ok(Ok(None)) => Err("track not found in the library".to_string()), + Ok(Err(err)) => Err(format!("library lookup failed: {err:#}")), + Err(err) => Err(format!("lookup task failed: {err}")), + } + } + None => Err("malformed item_id".to_string()), + }; + let file_path = match resolved { + Ok(path) => path, + Err(message) => return refuse(stream, message).await, + }; + + let path = PathBuf::from(&file_path); + let mut file = match tokio::fs::File::open(&path).await { + Ok(file) => file, + Err(err) => return refuse(stream, format!("audio file is not readable: {err}")).await, + }; + let total_size = file.metadata().await?.len(); + let offset = request.offset.min(total_size); + if offset > 0 { + file.seek(std::io::SeekFrom::Start(offset)).await?; + } + write_line( + &mut stream.send, + &AudioResponseHeader { + ok: true, + error: None, + mime_type: guess_mime(&path).to_string(), + total_size, + offset, + }, + ) + .await?; + tokio::io::copy(&mut file, &mut stream.send).await?; + stream.send.finish()?; + // Wait until the peer read everything (or gave up) before dropping the + // stream, otherwise the tail of the file is lost. + let _ = stream.send.stopped().await; + Ok(()) +} + +/// Sends a refusal header and waits until the peer read it. +async fn refuse(mut stream: ByteStream, message: String) -> Result<()> { + write_line( + &mut stream.send, + &AudioResponseHeader { + ok: false, + error: Some(message.clone()), + mime_type: String::new(), + total_size: 0, + offset: 0, + }, + ) + .await?; + stream.send.finish()?; + let _ = stream.send.stopped().await; + anyhow::bail!("refused audio request: {message}"); +} diff --git a/src/federation/mod.rs b/src/federation/mod.rs new file mode 100644 index 0000000..5cf8d6f --- /dev/null +++ b/src/federation/mod.rs @@ -0,0 +1,630 @@ +//! P2P federation for the TUI player. +//! +//! The player stays fully local, but can join a federated network of +//! furumi instances (TUI or furumi-fd): it publishes its library index +//! (names and small metadata, never files) into a shared DHT, searches the +//! other participants' libraries and streams their audio over the same +//! `furumi-fd/audio/1` protocol furumi-fd speaks — the two are wire +//! compatible. +//! +//! Federated tracks are downloaded before playback: into a cache file, or — +//! with "save on listen" enabled — straight into the local library (the +//! file is imported like any local file, so this peer then serves it to +//! the network too). + +mod audio; + +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use music_dht::{ + EndpointId, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, + RendezvousConfig, +}; +use serde::{Deserialize, Serialize}; + +use crate::library::Library; +use crate::library::models::{ArtistRef, TrackItem}; + +pub use audio::AUDIO_ALPN; + +/// How often the published library is re-synchronized with the local index. +const SYNC_INTERVAL: Duration = Duration::from_secs(60); + +/// Ephemeral (not-in-library) tracks get negative ids so the rest of the +/// app can tell them apart from library rows (history, likes and release +/// navigation skip them). +static NEXT_EPHEMERAL_ID: AtomicI64 = AtomicI64::new(-1); + +// --------------------------------------------------------------------------- +// Settings (persisted in /federation.toml) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct FedSettings { + pub enabled: bool, + #[serde(default)] + pub network_id: String, + /// Downloaded-for-playback federated tracks are imported into the local + /// library (replication) instead of a throwaway cache. + #[serde(default)] + pub save_on_listen: bool, +} + +fn settings_path() -> Option { + crate::config::project_dirs().map(|dirs| dirs.config_dir().join("federation.toml")) +} + +pub fn load_settings() -> FedSettings { + let Some(path) = settings_path() else { + return FedSettings::default(); + }; + match std::fs::read_to_string(&path) { + Ok(text) => toml::from_str(&text).unwrap_or_else(|err| { + tracing::warn!(%err, "federation.toml is malformed; using defaults"); + FedSettings::default() + }), + Err(_) => FedSettings::default(), + } +} + +fn save_settings(settings: &FedSettings) -> Result<()> { + let path = settings_path().context("cannot determine the config directory")?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, toml::to_string_pretty(settings)?)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Data shapes for the UI +// --------------------------------------------------------------------------- + +/// A track found through federated search. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FedTrack { + /// Hex item id in the DHT (the key audio is requested by). + pub item_id: String, + /// Hex endpoint id of the owning peer. + pub owner: String, + /// The item is published by this very instance. + pub own: bool, + pub title: String, + pub artist_names: Vec, + pub year: Option, + pub duration_seconds: Option, +} + +impl FedTrack { + pub fn artist_line(&self) -> String { + self.artist_names.join(", ") + } + + pub fn owner_short(&self) -> String { + self.owner.chars().take(10).collect() + } + + pub fn duration_label(&self) -> String { + match self.duration_seconds { + Some(total) => format!("{}:{:02}", total / 60, total % 60), + None => String::new(), + } + } +} + +/// Live status snapshot rendered on the Federation tab. +#[derive(Debug, Clone, Default)] +pub struct FedStatus { + pub running: bool, + pub network: String, + pub endpoint_id: String, + pub connected_peers: Vec, + pub known_contacts: usize, + pub published_items: usize, + pub last_sync: Option, + pub last_error: Option, +} + +/// Outcome of preparing a federated track for playback. +#[derive(Debug)] +pub struct FedPlayable { + pub track: TrackItem, + /// The file was imported into the local library (save-on-listen). + pub imported: bool, +} + +// --------------------------------------------------------------------------- +// The federation manager (lives in Runtime, not AppState) +// --------------------------------------------------------------------------- + +struct Running { + service: Arc, + network_name: String, + tasks: Vec>, +} + +pub struct Federation { + library: Arc, + data_dir: PathBuf, + cache_dir: PathBuf, + media_dir: PathBuf, + settings: std::sync::Mutex, + running: tokio::sync::Mutex>, + last_sync: std::sync::Mutex>, + last_error: std::sync::Mutex>, +} + +fn lock(mutex: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn now_label() -> String { + // Seconds since start of the day are enough for a status line without + // pulling in a date-time crate. + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + format!( + "{:02}:{:02}:{:02} UTC", + secs / 3600 % 24, + secs / 60 % 60, + secs % 60 + ) +} + +impl Federation { + pub fn new(library: Arc) -> Arc { + let dirs = crate::config::project_dirs(); + let data_dir = dirs + .as_ref() + .map(|d| d.data_dir().join("federation")) + .unwrap_or_else(|| PathBuf::from("federation")); + let cache_dir = dirs + .as_ref() + .map(|d| d.cache_dir().join("fedcache")) + .unwrap_or_else(|| PathBuf::from("fedcache")); + let media_dir = dirs + .as_ref() + .map(|d| d.data_dir().join("federation-media")) + .unwrap_or_else(|| PathBuf::from("federation-media")); + Arc::new(Self { + library, + data_dir, + cache_dir, + media_dir, + settings: std::sync::Mutex::new(load_settings()), + running: tokio::sync::Mutex::new(None), + last_sync: std::sync::Mutex::new(None), + last_error: std::sync::Mutex::new(None), + }) + } + + pub fn settings(&self) -> FedSettings { + lock(&self.settings).clone() + } + + fn set_error(&self, message: Option) { + *lock(&self.last_error) = message; + } + + /// Persists new settings and starts/stops/restarts the node to match. + pub async fn apply_settings(self: &Arc, settings: FedSettings) -> Result<()> { + anyhow::ensure!( + !settings.enabled || !settings.network_id.trim().is_empty(), + "set a network id before enabling federation" + ); + if let Err(err) = save_settings(&settings) { + tracing::warn!(%err, "saving federation settings failed"); + } + *lock(&self.settings) = settings.clone(); + if settings.enabled { + self.start(settings.network_id.trim().to_string()).await?; + self.spawn_sync_soon().await; + } else { + self.stop().await; + } + Ok(()) + } + + pub async fn start_if_enabled(self: &Arc) { + let settings = self.settings(); + if settings.enabled && !settings.network_id.trim().is_empty() { + if let Err(err) = self.start(settings.network_id.trim().to_string()).await { + tracing::error!("federation autostart failed: {err:#}"); + self.set_error(Some(format!("autostart failed: {err}"))); + } else { + self.spawn_sync_soon().await; + } + } + } + + /// Starts the DHT node. Idempotent per network name. + async fn start(self: &Arc, network_name: String) -> Result<()> { + let mut guard = self.running.lock().await; + if let Some(running) = guard.as_ref() { + if running.network_name == network_name { + return Ok(()); + } + stop_running(guard.take()).await; + } + + let config = MusicDhtConfig::builder() + .data_dir(&self.data_dir) + .network_id(NetworkId::from_name(&network_name)) + // Peers of the network find each other knowing only its name. + .rendezvous(RendezvousConfig::default()) + // Peers stream each other's audio over this protocol. + .stream_protocol(AUDIO_ALPN) + .build() + .map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?; + let (service, mut events) = MusicDhtService::start(config) + .await + .map_err(|err| anyhow::anyhow!("failed to start the DHT node: {err}"))?; + let service = Arc::new(service); + tracing::info!( + endpoint_id = %service.endpoint_id(), + network = %network_name, + "federation started" + ); + + // Drain DHT events into the log; the channel is bounded. + let event_task = tokio::spawn(async move { + while let Some(event) = events.recv().await { + tracing::debug!("federation event: {event:?}"); + } + }); + // Keep the published library in sync with the local index. + let sync_self = Arc::clone(self); + let sync_service = Arc::clone(&service); + let sync_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(SYNC_INTERVAL); + loop { + interval.tick().await; + sync_self.sync_once(&sync_service).await; + } + }); + // Serve audio requests from other peers of the network. + let audio_acceptor = service + .stream_acceptor(AUDIO_ALPN) + .map_err(|err| anyhow::anyhow!("failed to take the audio acceptor: {err}"))?; + let audio_task = tokio::spawn(audio::serve_peers( + audio_acceptor, + Arc::clone(&self.library), + service.endpoint_id(), + )); + + *guard = Some(Running { + service, + network_name, + tasks: vec![event_task, sync_task, audio_task], + }); + self.set_error(None); + Ok(()) + } + + async fn stop(&self) { + let mut guard = self.running.lock().await; + stop_running(guard.take()).await; + } + + pub async fn shutdown(&self) { + self.stop().await; + } + + async fn service(&self) -> Result> { + self.running + .lock() + .await + .as_ref() + .map(|running| Arc::clone(&running.service)) + .context("federation is not running") + } + + /// Publishes the library immediately (used right after start/settings). + async fn spawn_sync_soon(self: &Arc) { + if let Ok(service) = self.service().await { + let fed = Arc::clone(self); + tokio::spawn(async move { fed.sync_once(&service).await }); + } + } + + pub async fn sync_now(self: &Arc) -> Result<()> { + let service = self.service().await?; + self.sync_once(&service).await; + Ok(()) + } + + async fn sync_once(&self, service: &MusicDhtService) { + let library = Arc::clone(&self.library); + let specs = tokio::task::spawn_blocking(move || collect_specs(&library)).await; + let specs = match specs { + Ok(Ok(specs)) => specs, + Ok(Err(err)) => { + tracing::warn!("federation sync: library read failed: {err:#}"); + self.set_error(Some(format!("library read failed: {err}"))); + return; + } + Err(err) => { + tracing::warn!("federation sync task failed: {err}"); + return; + } + }; + match service.sync_library(specs).await { + Ok(stats) => { + *lock(&self.last_sync) = Some(format!( + "{} (+{} ~{} −{}, unchanged {})", + now_label(), + stats.added, + stats.updated, + stats.removed, + stats.unchanged + )); + self.set_error(None); + } + Err(err) => { + tracing::warn!("federation sync failed: {err}"); + self.set_error(Some(format!("sync failed: {err}"))); + } + } + } + + pub async fn status(&self) -> FedStatus { + let settings = self.settings(); + let guard = self.running.lock().await; + let mut status = FedStatus { + network: settings.network_id, + last_sync: lock(&self.last_sync).clone(), + last_error: lock(&self.last_error).clone(), + ..FedStatus::default() + }; + if let Some(running) = guard.as_ref() { + let service = &running.service; + status.running = true; + status.network = running.network_name.clone(); + status.endpoint_id = service.endpoint_id().to_string(); + status.connected_peers = service + .connected_peers() + .iter() + .map(|p| p.to_string()) + .collect(); + status.known_contacts = service.known_peers().len(); + status.published_items = service + .list_local_items() + .await + .map(|items| items.len()) + .unwrap_or(0); + } + status + } + + /// Searches the federated network for tracks matching `query`. + pub async fn search(&self, query: &str) -> Result> { + let service = self.service().await?; + let outcome = service + .search_network(query) + .await + .map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?; + let own = service.endpoint_id(); + Ok(outcome + .network_results + .iter() + .filter(|item| item.kind == ItemKind::Track) + .map(|item| FedTrack { + item_id: audio::hex_encode(item.id.as_bytes()), + owner: item.owner.to_string(), + own: item.owner == own, + title: item.name.clone(), + artist_names: item.artist_names.clone(), + year: item.year, + duration_seconds: item.duration_seconds.map(|d| d.round() as i64), + }) + .collect()) + } + + pub async fn ticket(&self) -> Result { + let service = self.service().await?; + let ticket = service + .ticket() + .await + .map_err(|err| anyhow::anyhow!("cannot create a ticket: {err}"))?; + Ok(ticket.to_string()) + } + + pub async fn connect(&self, ticket: &str) -> Result { + let service = self.service().await?; + let ticket: PeerTicket = ticket + .trim() + .parse() + .map_err(|err| anyhow::anyhow!("malformed ticket: {err}"))?; + let peer = service + .connect(ticket) + .await + .map_err(|err| anyhow::anyhow!("connect failed: {err}"))?; + Ok(peer.to_string()) + } + + /// Prepares a federated track for playback: local tracks resolve + /// straight to the library; remote tracks are downloaded — into the + /// library when save-on-listen is enabled, into the cache otherwise. + pub async fn prepare_playback(self: &Arc, fed: &FedTrack) -> Result { + let service = self.service().await?; + let item_id = + audio::hex_decode_item_id(&fed.item_id).context("malformed item id in the result")?; + + if fed.own { + let library = Arc::clone(&self.library); + let own_id = service.endpoint_id(); + let track = tokio::task::spawn_blocking(move || -> Result> { + let Some(track_id) = audio::resolve_local_track_id(&library, own_id, item_id)? + else { + return Ok(None); + }; + Ok(library.tracks_by_ids(&[track_id])?.into_iter().next()) + }) + .await?? + .context("this track is no longer in the local library")?; + return Ok(FedPlayable { + track, + imported: false, + }); + } + + let owner = EndpointId::from_str(&fed.owner) + .map_err(|_| anyhow::anyhow!("malformed owner id '{}'", fed.owner))?; + let save = self.settings().save_on_listen; + let dir = if save { &self.media_dir } else { &self.cache_dir }; + tokio::fs::create_dir_all(dir).await?; + + let (path, mime) = + audio::download_track(&service, owner, &fed.item_id, dir, &download_stem(fed)).await?; + tracing::info!(path = %path.display(), %mime, "federated track downloaded"); + + if save { + let library = Arc::clone(&self.library); + let import_path = path.clone(); + let imported = tokio::task::spawn_blocking(move || -> Result> { + let import = crate::library::import::read_file(&import_path)?; + let (track_id, _) = crate::library::import::upsert_track(&library, &import)?; + Ok(library.tracks_by_ids(&[track_id])?.into_iter().next()) + }) + .await?; + match imported { + Ok(Some(track)) => { + return Ok(FedPlayable { + track, + imported: true, + }); + } + Ok(None) => {} + Err(err) => { + tracing::warn!("importing the downloaded track failed: {err:#}; playing from the file"); + } + } + } + + Ok(FedPlayable { + track: ephemeral_track(fed, &path), + imported: false, + }) + } +} + +async fn stop_running(running: Option) { + let Some(running) = running else { return }; + for task in &running.tasks { + task.abort(); + } + if let Err(err) = running.service.shutdown().await { + tracing::warn!("federation node shutdown reported an error: {err}"); + } + tracing::info!("federation stopped"); +} + +/// Reads the local library and converts it into DHT item specs. Only names +/// and small metadata are shared — never file paths or the files themselves. +fn collect_specs(library: &Library) -> Result> { + let export = library.federation_export()?; + let mut specs = Vec::new(); + for (id, name) in export.artists { + specs.push(ItemSpec { + local_key: format!("artist:{id}"), + kind: ItemKind::Artist, + name, + artist_names: Vec::new(), + year: None, + release_type: None, + duration_seconds: None, + }); + } + for release in export.releases { + specs.push(ItemSpec { + local_key: format!("release:{}", release.id), + kind: ItemKind::Release, + name: release.title, + artist_names: release.artist_names, + year: release.year, + release_type: Some(release.release_type), + duration_seconds: None, + }); + } + for track in export.tracks { + specs.push(ItemSpec { + local_key: format!("track:{}", track.id), + kind: ItemKind::Track, + name: track.title, + artist_names: track.artist_names, + year: track.year, + release_type: None, + duration_seconds: (track.duration_seconds > 0.0).then_some(track.duration_seconds), + }); + } + Ok(specs) +} + +fn sanitize_file_stem(value: &str) -> String { + let cleaned: String = value + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect(); + let trimmed = cleaned.trim().trim_matches('.'); + let mut stem: String = trimmed.chars().take(120).collect(); + if stem.is_empty() { + stem.push_str("track"); + } + stem +} + +fn download_stem(fed: &FedTrack) -> String { + let artists = fed.artist_line(); + if artists.is_empty() { + sanitize_file_stem(&fed.title) + } else { + sanitize_file_stem(&format!("{artists} - {}", fed.title)) + } +} + +/// A playable TrackItem for a downloaded-but-not-imported federated track. +fn ephemeral_track(fed: &FedTrack, path: &std::path::Path) -> TrackItem { + let id = NEXT_EPHEMERAL_ID.fetch_sub(1, Ordering::Relaxed); + let file_size = std::fs::metadata(path).map(|m| m.len() as i64).ok(); + TrackItem { + id, + title: fed.title.clone(), + track_number: None, + disc_number: None, + duration_seconds: fed.duration_seconds.unwrap_or(0) as f64, + artists: fed + .artist_names + .iter() + .map(|name| ArtistRef { + id: -1, + name: name.clone(), + }) + .collect(), + featured_artists: Vec::new(), + release_id: -1, + release_title: format!("federation · {}", fed.owner_short()), + release_year: fed.year, + file_path: path.to_string_lossy().into_owned(), + cover_path: None, + audio_format: path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_string()), + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: file_size, + play_count: 0, + } +} diff --git a/src/library/import.rs b/src/library/import.rs new file mode 100644 index 0000000..b798f2c --- /dev/null +++ b/src/library/import.rs @@ -0,0 +1,549 @@ +//! Importing audio files into the library: directory scanning, tag reading +//! (via lofty) and cover extraction. Importing the same file again updates +//! its metadata instead of duplicating it. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result}; +use lofty::file::{AudioFile as _, TaggedFileExt as _}; +use lofty::picture::MimeType; +use lofty::tag::{Accessor as _, ItemKey}; +use rusqlite::{OptionalExtension as _, params}; + +use super::{Library, find_or_create_artist}; + +/// Extensions the playback engine can decode (rodio/symphonia feature set). +const AUDIO_EXTENSIONS: [&str; 8] = ["mp3", "flac", "ogg", "oga", "wav", "m4a", "mp4", "aac"]; + +/// Everything known about one audio file, ready to be written to the DB. +#[derive(Debug)] +pub struct TrackImport { + pub file_path: String, + pub title: String, + pub artists: Vec, + pub featured_artists: Vec, + pub album_artists: Vec, + pub release_title: String, + pub year: Option, + pub track_number: Option, + pub disc_number: Option, + pub duration_seconds: f64, + pub audio_format: Option, + pub audio_bitrate: Option, + pub audio_sample_rate: Option, + pub audio_bit_depth: Option, + pub file_size_bytes: Option, + /// Embedded cover art (bytes, file extension), if any. + pub cover: Option<(Vec, &'static str)>, +} + +#[derive(Debug, Default)] +pub struct ImportOutcome { + pub added: usize, + pub updated: usize, + pub failed: Vec<(PathBuf, String)>, +} + +impl ImportOutcome { + pub fn summary(&self) -> String { + let mut message = format!("imported {} track(s)", self.added); + if self.updated > 0 { + message.push_str(&format!(", updated {}", self.updated)); + } + if !self.failed.is_empty() { + message.push_str(&format!(", {} failed", self.failed.len())); + } + message + } +} + +/// Import a file or a directory (recursively). `progress(done, total, name)` +/// is called after every file. +pub fn import_path( + library: &Library, + path: &Path, + mut progress: impl FnMut(usize, usize, &str), +) -> Result { + let path = path + .canonicalize() + .with_context(|| format!("{} does not exist", path.display()))?; + let mut files = Vec::new(); + collect_audio_files(&path, &mut files); + anyhow::ensure!( + !files.is_empty(), + "no audio files found at {} (supported: {})", + path.display(), + AUDIO_EXTENSIONS.join(", ") + ); + files.sort(); + + let total = files.len(); + let mut outcome = ImportOutcome::default(); + for (index, file) in files.iter().enumerate() { + match read_file(file).and_then(|import| upsert_track(library, &import)) { + Ok((_, created)) => { + if created { + outcome.added += 1; + } else { + outcome.updated += 1; + } + } + Err(err) => { + tracing::warn!(file = %file.display(), %err, "import failed"); + outcome.failed.push((file.clone(), format!("{err:#}"))); + } + } + let name = file + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + progress(index + 1, total, &name); + } + Ok(outcome) +} + +fn collect_audio_files(path: &Path, files: &mut Vec) { + if path.is_dir() { + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + collect_audio_files(&entry.path(), files); + } + return; + } + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()); + if extension.is_some_and(|ext| AUDIO_EXTENSIONS.contains(&ext.as_str())) { + files.push(path.to_path_buf()); + } +} + +/// Read tags and audio properties from one file. +pub fn read_file(path: &Path) -> Result { + let tagged = lofty::read_from_path(path).context("cannot read tags")?; + let properties = tagged.properties(); + let tag = tagged.primary_tag().or_else(|| tagged.first_tag()); + + let fallback_title = path + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Unknown".to_string()); + let (mut title, artist_raw, album, year, track_number, disc_number, album_artist_raw, cover) = + match tag { + Some(tag) => ( + tag.title() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback_title), + tag.artist().map(|value| value.into_owned()), + tag.album() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + tag.year().and_then(|value| i32::try_from(value).ok()), + tag.track().and_then(|value| i32::try_from(value).ok()), + tag.disk().and_then(|value| i32::try_from(value).ok()), + tag.get_string(&ItemKey::AlbumArtist) + .map(|value| value.to_string()), + tag.pictures().first().map(|picture| { + let extension = match picture.mime_type() { + Some(MimeType::Png) => "png", + Some(MimeType::Gif) => "gif", + Some(MimeType::Bmp) => "bmp", + _ => "jpg", + }; + (picture.data().to_vec(), extension) + }), + ), + None => (fallback_title, None, None, None, None, None, None, None), + }; + + let (mut artists, mut featured) = split_artist_tag(artist_raw.as_deref().unwrap_or("")); + // "Song (feat. X)" in the title moves X into the featured list. + if let Some((clean_title, feat)) = extract_title_feat(&title) { + title = clean_title; + for name in feat { + if !featured.iter().any(|f| f.eq_ignore_ascii_case(&name)) { + featured.push(name); + } + } + } + if artists.is_empty() { + artists.push("Unknown Artist".to_string()); + } + let album_artists = match album_artist_raw.as_deref().map(split_artist_tag) { + Some((main, _)) if !main.is_empty() => main, + _ => artists.clone(), + }; + + let metadata = std::fs::metadata(path).ok(); + Ok(TrackImport { + file_path: path.to_string_lossy().into_owned(), + title, + artists, + featured_artists: featured, + album_artists, + release_title: album.unwrap_or_else(|| "Unknown Album".to_string()), + year, + track_number, + disc_number, + duration_seconds: properties.duration().as_secs_f64(), + audio_format: path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()), + audio_bitrate: properties + .audio_bitrate() + .and_then(|value| i32::try_from(value).ok()), + audio_sample_rate: properties + .sample_rate() + .and_then(|value| i32::try_from(value).ok()), + audio_bit_depth: properties.bit_depth().map(i32::from), + file_size_bytes: metadata.map(|meta| meta.len() as i64), + cover, + }) +} + +/// Insert or update one track (matching by file path). Returns the track id +/// and whether a new row was created. +pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, bool)> { + let mut conn = library.lock(); + let tx = conn.transaction()?; + + // Release, keyed by (title, first album artist). + let album_artist_id = find_or_create_artist( + &tx, + import + .album_artists + .first() + .map(String::as_str) + .unwrap_or("Unknown Artist"), + )?; + let release_id: Option = tx + .query_row( + "SELECT r.id FROM releases r + JOIN release_artists ra ON ra.release_id = r.id + WHERE r.title = ?1 COLLATE NOCASE AND ra.artist_id = ?2", + params![import.release_title, album_artist_id], + |row| row.get(0), + ) + .optional()?; + let release_id = match release_id { + Some(id) => { + // Fill in the year if this file is the first one to know it. + if import.year.is_some() { + tx.execute( + "UPDATE releases SET year = COALESCE(year, ?2) WHERE id = ?1", + params![id, import.year], + )?; + } + id + } + None => { + tx.execute( + "INSERT INTO releases (title, release_type, year) VALUES (?1, 'album', ?2)", + params![import.release_title, import.year], + )?; + let id = tx.last_insert_rowid(); + for (position, name) in import.album_artists.iter().enumerate() { + let artist_id = find_or_create_artist(&tx, name)?; + tx.execute( + "INSERT OR IGNORE INTO release_artists (release_id, artist_id, position) + VALUES (?1, ?2, ?3)", + params![id, artist_id, position as i64], + )?; + } + id + } + }; + + let existing: Option = tx + .query_row( + "SELECT id FROM tracks WHERE file_path = ?1", + [&import.file_path], + |row| row.get(0), + ) + .optional()?; + let (track_id, created) = match existing { + Some(id) => { + tx.execute( + "UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4, + duration_seconds = ?5, release_id = ?6, audio_format = ?7, + audio_bitrate = ?8, audio_sample_rate = ?9, audio_bit_depth = ?10, + file_size_bytes = ?11 + WHERE id = ?1", + params![ + id, + import.title, + import.track_number, + import.disc_number, + import.duration_seconds, + release_id, + import.audio_format, + import.audio_bitrate, + import.audio_sample_rate, + import.audio_bit_depth, + import.file_size_bytes, + ], + )?; + tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?; + (id, false) + } + None => { + tx.execute( + "INSERT INTO tracks (title, track_number, disc_number, duration_seconds, + release_id, file_path, audio_format, audio_bitrate, audio_sample_rate, + audio_bit_depth, file_size_bytes) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + import.title, + import.track_number, + import.disc_number, + import.duration_seconds, + release_id, + import.file_path, + import.audio_format, + import.audio_bitrate, + import.audio_sample_rate, + import.audio_bit_depth, + import.file_size_bytes, + ], + )?; + (tx.last_insert_rowid(), true) + } + }; + for (position, name) in import.artists.iter().enumerate() { + let artist_id = find_or_create_artist(&tx, name)?; + tx.execute( + "INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position) + VALUES (?1, ?2, 'main', ?3)", + params![track_id, artist_id, position as i64], + )?; + } + for (position, name) in import.featured_artists.iter().enumerate() { + let artist_id = find_or_create_artist(&tx, name)?; + tx.execute( + "INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position) + VALUES (?1, ?2, 'featured', ?3)", + params![track_id, artist_id, position as i64], + )?; + } + + // Cover: a release keeps the first cover found — an image file next to + // the audio, or the embedded picture saved into the covers directory. + let has_cover: bool = tx + .query_row( + "SELECT cover_path IS NOT NULL FROM releases WHERE id = ?1", + [release_id], + |row| row.get(0), + ) + .unwrap_or(false); + if !has_cover + && let Some(cover_path) = resolve_cover(library, release_id, import) + { + tx.execute( + "UPDATE releases SET cover_path = ?2 WHERE id = ?1", + params![release_id, cover_path], + )?; + } + + tx.commit()?; + Ok((track_id, created)) +} + +/// Find a cover image for the release: a cover/folder/front image in the +/// audio file's directory, or the embedded picture written to disk. +fn resolve_cover(library: &Library, release_id: i64, import: &TrackImport) -> Option { + let directory = Path::new(&import.file_path).parent()?; + if let Ok(entries) = std::fs::read_dir(directory) { + for entry in entries.flatten() { + let path = entry.path(); + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| stem.to_ascii_lowercase()); + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()); + let is_image = matches!( + extension.as_deref(), + Some("jpg" | "jpeg" | "png" | "webp" | "bmp" | "gif") + ); + if is_image + && matches!(stem.as_deref(), Some("cover" | "folder" | "front" | "album")) + { + return Some(path.to_string_lossy().into_owned()); + } + } + } + let (data, extension) = import.cover.as_ref()?; + let covers_dir = library.covers_dir(); + if let Err(err) = std::fs::create_dir_all(covers_dir) { + tracing::warn!(%err, "cannot create covers directory"); + return None; + } + let path = covers_dir.join(format!("release_{release_id}.{extension}")); + match std::fs::write(&path, data) { + Ok(()) => Some(path.to_string_lossy().into_owned()), + Err(err) => { + tracing::warn!(%err, path = %path.display(), "cannot save embedded cover"); + None + } + } +} + +/// Split an artist tag into (main artists, featured artists). +/// Separators: ";" and "/" between main artists; "feat."/"ft."/"featuring" +/// starts the featured list. +pub fn split_artist_tag(raw: &str) -> (Vec, Vec) { + let raw = raw.trim(); + if raw.is_empty() { + return (Vec::new(), Vec::new()); + } + let (main_part, feat_part) = match find_feat_marker(raw) { + Some((at, marker_len)) => { + let main = raw[..at].trim_end_matches(['(', '[', ' ', ',', '-']); + let feat = raw[at + marker_len..].trim_end_matches([')', ']']); + (main, feat) + } + None => (raw, ""), + }; + (split_names(main_part), split_names(feat_part)) +} + +/// The earliest "feat."/"ft."/"featuring" marker that stands as its own +/// word — preceded by a separator and followed by a space — so artist names +/// like "Daft Punk" are not split on the "ft" inside them. +fn find_feat_marker(raw: &str) -> Option<(usize, usize)> { + let lowered = raw.to_lowercase(); + let mut best: Option<(usize, usize)> = None; + for marker in ["featuring", "feat.", "feat", "ft.", "ft"] { + for (at, _) in lowered.match_indices(marker) { + let before_ok = raw[..at] + .chars() + .next_back() + .is_some_and(|c| matches!(c, ' ' | '(' | '[' | ',' | '-')); + let after_ok = raw[at + marker.len()..].starts_with(' '); + if before_ok && after_ok && best.is_none_or(|(current, _)| at < current) { + best = Some((at, marker.len())); + } + } + } + best +} + +fn split_names(raw: &str) -> Vec { + raw.split([';', '/']) + .flat_map(|part| part.split(" & ")) + .map(|name| name.trim().trim_matches(',').trim().to_string()) + .filter(|name| !name.is_empty()) + .collect() +} + +/// Extract "(feat. X)" / "[ft. Y]" from a track title. +fn extract_title_feat(title: &str) -> Option<(String, Vec)> { + let lowered = title.to_lowercase(); + for marker in ["(feat.", "(feat ", "(ft.", "[feat.", "[ft."] { + if let Some(start) = lowered.find(marker) { + let closer = if marker.starts_with('(') { ')' } else { ']' }; + let rest = &title[start + marker.len()..]; + let end = rest.find(closer)?; + let names = split_names(&rest[..end]); + if names.is_empty() { + return None; + } + let mut clean = title[..start].trim_end().to_string(); + clean.push_str(rest[end + 1..].trim_end()); + return Some((clean.trim().to_string(), names)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal valid WAV file: 0.1s of silence at 8kHz mono 16-bit. + fn write_test_wav(path: &Path) { + let samples: u32 = 800; + let data_len = samples * 2; + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + data_len).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); // PCM + bytes.extend_from_slice(&1u16.to_le_bytes()); // mono + bytes.extend_from_slice(&8000u32.to_le_bytes()); // sample rate + bytes.extend_from_slice(&16000u32.to_le_bytes()); // byte rate + bytes.extend_from_slice(&2u16.to_le_bytes()); // block align + bytes.extend_from_slice(&16u16.to_le_bytes()); // bits per sample + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_len.to_le_bytes()); + bytes.resize(bytes.len() + data_len as usize, 0); + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn imports_a_real_audio_file_end_to_end() { + let dir = std::env::temp_dir().join(format!("furumi-import-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let wav = dir.join("My Song.wav"); + write_test_wav(&wav); + + let db = dir.join("library.db"); + let library = Library::open(&db).unwrap(); + let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap(); + assert_eq!(outcome.added, 1); + assert!(outcome.failed.is_empty()); + + // Untagged files fall back to the file name and placeholder names. + let results = library.search("My Song", 10).unwrap(); + assert_eq!(results.tracks.len(), 1); + let track = &results.tracks[0]; + assert_eq!(track.title, "My Song"); + assert_eq!(track.artists[0].name, "Unknown Artist"); + assert_eq!(track.release_title, "Unknown Album"); + assert!(track.duration_seconds > 0.05); + assert_eq!(track.audio_sample_rate, Some(8000)); + assert!(std::fs::File::open(&track.file_path).is_ok()); + + // Re-importing the same directory only updates. + let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap(); + assert_eq!((outcome.added, outcome.updated), (0, 1)); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn splits_plain_artist() { + let (main, feat) = split_artist_tag("Daft Punk"); + assert_eq!(main, vec!["Daft Punk"]); + assert!(feat.is_empty()); + } + + #[test] + fn splits_multiple_and_featured() { + let (main, feat) = split_artist_tag("A; B feat. C & D"); + assert_eq!(main, vec!["A", "B"]); + assert_eq!(feat, vec!["C", "D"]); + } + + #[test] + fn keeps_commas_inside_names() { + let (main, _) = split_artist_tag("Tyler, The Creator"); + assert_eq!(main, vec!["Tyler, The Creator"]); + } + + #[test] + fn extracts_feat_from_title() { + let (title, names) = extract_title_feat("Song (feat. X & Y)").unwrap(); + assert_eq!(title, "Song"); + assert_eq!(names, vec!["X", "Y"]); + assert!(extract_title_feat("Plain Song").is_none()); + } +} diff --git a/src/library/mod.rs b/src/library/mod.rs new file mode 100644 index 0000000..9d36ae6 --- /dev/null +++ b/src/library/mod.rs @@ -0,0 +1,1018 @@ +//! The local music library: a SQLite database holding artists, releases, +//! tracks (with paths to local audio files), playlists, likes and play +//! history. This module is the only place that talks SQL; it returns the +//! same data shapes the furumusic API used to serve, so the views did not +//! have to change their model of the world. +//! +//! All methods take `&self` and lock the connection internally, so an +//! `Arc` can be shared across blocking tasks. + +pub mod import; +pub mod models; + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use anyhow::{Context as _, Result}; +use rusqlite::{Connection, OptionalExtension as _, params}; + +use models::{ + ArtistCard, ArtistDetail, ArtistRef, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseCard, + ReleaseDetail, ReleaseEdit, SearchResults, TrackEdit, TrackItem, +}; + +/// The virtual "Liked tracks" playlist id, kept from the server API. +pub const LIKES_PLAYLIST_ID: i64 = -1; + +const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS artists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + image_path TEXT +); +CREATE TABLE IF NOT EXISTS releases ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + release_type TEXT NOT NULL DEFAULT 'album', + year INTEGER, + cover_path TEXT +); +CREATE TABLE IF NOT EXISTS release_artists ( + release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, + artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (release_id, artist_id) +); +CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + track_number INTEGER, + disc_number INTEGER, + duration_seconds REAL NOT NULL DEFAULT 0, + release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, + file_path TEXT NOT NULL UNIQUE, + audio_format TEXT, + audio_bitrate INTEGER, + audio_sample_rate INTEGER, + audio_bit_depth INTEGER, + file_size_bytes INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS track_artists ( + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'main', + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (track_id, artist_id, role) +); +CREATE TABLE IF NOT EXISTS playlists ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS playlist_tracks ( + playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + PRIMARY KEY (playlist_id, track_id) +); +CREATE TABLE IF NOT EXISTS likes ( + track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + liked_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY, + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + started_at INTEGER, + listened_seconds INTEGER NOT NULL DEFAULT 0, + completed INTEGER NOT NULL DEFAULT 0, + played_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_tracks_release ON tracks(release_id); +CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id); +CREATE INDEX IF NOT EXISTS idx_release_artists_artist ON release_artists(artist_id); +CREATE INDEX IF NOT EXISTS idx_history_track ON history(track_id); +CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id); +"; + +/// The SELECT column list every TrackItem row is built from; artist lists +/// are attached in a second pass. +const TRACK_COLUMNS: &str = " + t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, + t.release_id, r.title, r.year, r.cover_path, + t.file_path, t.audio_format, t.audio_bitrate, t.audio_sample_rate, + t.audio_bit_depth, t.file_size_bytes, + (SELECT COUNT(*) FROM history h WHERE h.track_id = t.id AND h.completed = 1) +"; + +/// Plain rows handed to the federation for publishing (see +/// `federation_export`). +#[derive(Debug)] +pub struct FederationExport { + pub artists: Vec<(i64, String)>, + pub releases: Vec, + pub tracks: Vec, +} + +#[derive(Debug)] +pub struct ExportRelease { + pub id: i64, + pub title: String, + pub year: Option, + pub release_type: String, + pub artist_names: Vec, +} + +#[derive(Debug)] +pub struct ExportTrack { + pub id: i64, + pub title: String, + pub year: Option, + pub duration_seconds: f64, + pub file_path: String, + pub artist_names: Vec, +} + +pub struct Library { + conn: Mutex, + /// Directory where extracted embedded covers are stored. + covers_dir: PathBuf, +} + +/// Default database location: `/furumi/library.db`. +pub fn default_db_path() -> Result { + let dirs = crate::config::project_dirs().context("cannot determine the data directory")?; + Ok(dirs.data_dir().join("library.db")) +} + +impl Library { + pub fn open(db_path: &Path) -> Result { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + let conn = Connection::open(db_path) + .with_context(|| format!("opening database {}", db_path.display()))?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + conn.execute_batch(SCHEMA).context("applying schema")?; + let covers_dir = db_path + .parent() + .map(|dir| dir.join("covers")) + .unwrap_or_else(|| PathBuf::from("covers")); + Ok(Self { + conn: Mutex::new(conn), + covers_dir, + }) + } + + pub fn covers_dir(&self) -> &Path { + &self.covers_dir + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Connection> { + self.conn.lock().unwrap_or_else(|poisoned| { + // A panic mid-query leaves the connection usable; keep going. + poisoned.into_inner() + }) + } + + // ----------------------------------------------------------------- + // Reads (same shapes the API used to return) + // ----------------------------------------------------------------- + + pub fn artists(&self, page: i64, limit: i64) -> Result { + let conn = self.lock(); + let total: i64 = conn.query_row("SELECT COUNT(*) FROM artists", [], |row| row.get(0))?; + let offset = (page.max(1) - 1) * limit; + let mut statement = conn.prepare( + "SELECT a.id, a.name, a.image_path, + (SELECT COUNT(*) FROM release_artists ra WHERE ra.artist_id = a.id), + (SELECT COUNT(*) FROM track_artists ta WHERE ta.artist_id = a.id) + FROM artists a + ORDER BY a.name COLLATE NOCASE + LIMIT ?1 OFFSET ?2", + )?; + let items = statement + .query_map(params![limit, offset], |row| { + Ok(ArtistCard { + id: row.get(0)?, + name: row.get(1)?, + image_path: row.get(2)?, + release_count: row.get(3)?, + track_count: row.get(4)?, + }) + })? + .collect::>>()?; + let has_more = offset + (items.len() as i64) < total; + Ok(ArtistsPage { + has_more, + items, + total, + page: page.max(1), + }) + } + + pub fn artist(&self, id: i64) -> Result { + let conn = self.lock(); + let (name, image_path): (String, Option) = conn + .query_row( + "SELECT name, image_path FROM artists WHERE id = ?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .context("artist not found")?; + let total_track_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM track_artists WHERE artist_id = ?1 AND role = 'main'", + [id], + |row| row.get(0), + )?; + let total_play_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM history h + WHERE h.completed = 1 AND h.track_id IN + (SELECT track_id FROM track_artists WHERE artist_id = ?1)", + [id], + |row| row.get(0), + )?; + let top_tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + JOIN track_artists ta ON ta.track_id = t.id + WHERE ta.artist_id = ?1 AND ta.role = 'main' + ORDER BY 16 DESC, t.title COLLATE NOCASE + LIMIT 10" + ), + params![id], + )?; + let featured_tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + JOIN track_artists ta ON ta.track_id = t.id + WHERE ta.artist_id = ?1 AND ta.role = 'featured' + ORDER BY t.title COLLATE NOCASE" + ), + params![id], + )?; + let mut statement = conn.prepare( + "SELECT r.id, r.title, r.release_type, r.year, r.cover_path, + (SELECT COUNT(*) FROM tracks t WHERE t.release_id = r.id) + FROM releases r + JOIN release_artists ra ON ra.release_id = r.id + WHERE ra.artist_id = ?1 + ORDER BY r.year IS NULL, r.year DESC, r.title COLLATE NOCASE", + )?; + let releases = statement + .query_map([id], release_card_from_row)? + .collect::>>()?; + Ok(ArtistDetail { + id, + name, + image_path, + total_track_count, + total_play_count, + top_tracks, + releases, + featured_tracks, + }) + } + + pub fn release(&self, id: i64) -> Result { + let conn = self.lock(); + let (title, release_type, year, cover_path) = conn + .query_row( + "SELECT title, release_type, year, cover_path FROM releases WHERE id = ?1", + [id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }, + ) + .context("release not found")?; + let mut statement = conn.prepare( + "SELECT a.id, a.name FROM artists a + JOIN release_artists ra ON ra.artist_id = a.id + WHERE ra.release_id = ?1 ORDER BY ra.position", + )?; + let artists = statement + .query_map([id], |row| { + Ok(ArtistRef { + id: row.get(0)?, + name: row.get(1)?, + }) + })? + .collect::>>()?; + let tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + WHERE t.release_id = ?1 + ORDER BY t.disc_number IS NULL, t.disc_number, + t.track_number IS NULL, t.track_number, + t.title COLLATE NOCASE" + ), + params![id], + )?; + Ok(ReleaseDetail { + id, + title, + release_type, + year, + cover_path, + artists, + tracks, + }) + } + + pub fn search(&self, query: &str, limit: i64) -> Result { + let query = query.trim(); + if query.is_empty() { + return Ok(SearchResults::default()); + } + let pattern = format!("%{}%", like_escape(query)); + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT a.id, a.name, a.image_path, + (SELECT COUNT(*) FROM release_artists ra WHERE ra.artist_id = a.id), + (SELECT COUNT(*) FROM track_artists ta WHERE ta.artist_id = a.id) + FROM artists a WHERE a.name LIKE ?1 ESCAPE '\\' + ORDER BY a.name COLLATE NOCASE LIMIT ?2", + )?; + let artists = statement + .query_map(params![pattern, limit], |row| { + Ok(ArtistCard { + id: row.get(0)?, + name: row.get(1)?, + image_path: row.get(2)?, + release_count: row.get(3)?, + track_count: row.get(4)?, + }) + })? + .collect::>>()?; + let mut statement = conn.prepare( + "SELECT r.id, r.title, r.release_type, r.year, r.cover_path, + (SELECT COUNT(*) FROM tracks t WHERE t.release_id = r.id) + FROM releases r WHERE r.title LIKE ?1 ESCAPE '\\' + ORDER BY r.title COLLATE NOCASE LIMIT ?2", + )?; + let releases = statement + .query_map(params![pattern, limit], release_card_from_row)? + .collect::>>()?; + let tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + WHERE t.title LIKE ?1 ESCAPE '\\' + ORDER BY t.title COLLATE NOCASE LIMIT ?2" + ), + params![pattern, limit], + )?; + Ok(SearchResults { + artists, + releases, + tracks, + }) + } + + /// Everything the federation publishes into the DHT: plain rows, so the + /// federation module needs no SQL of its own. + pub fn federation_export(&self) -> Result { + let conn = self.lock(); + let mut statement = conn.prepare("SELECT id, name FROM artists")?; + let artists = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>>()?; + + let mut statement = conn.prepare( + "SELECT ra.release_id, a.name FROM release_artists ra + JOIN artists a ON a.id = ra.artist_id + ORDER BY ra.release_id, ra.position", + )?; + let mut release_artists: std::collections::HashMap> = Default::default(); + for row in statement.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))? { + let (id, name) = row?; + release_artists.entry(id).or_default().push(name); + } + let mut statement = + conn.prepare("SELECT id, title, year, release_type FROM releases")?; + let releases = statement + .query_map([], |row| { + Ok(ExportRelease { + id: row.get(0)?, + title: row.get(1)?, + year: row.get(2)?, + release_type: row.get(3)?, + artist_names: Vec::new(), + }) + })? + .collect::>>()? + .into_iter() + .map(|mut release| { + release.artist_names = release_artists.remove(&release.id).unwrap_or_default(); + release + }) + .collect(); + + let mut statement = conn.prepare( + "SELECT ta.track_id, a.name FROM track_artists ta + JOIN artists a ON a.id = ta.artist_id + ORDER BY ta.track_id, ta.position", + )?; + let mut track_artists: std::collections::HashMap> = Default::default(); + for row in statement.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))? { + let (id, name) = row?; + track_artists.entry(id).or_default().push(name); + } + let mut statement = conn.prepare( + "SELECT t.id, t.title, r.year, t.duration_seconds, t.file_path + FROM tracks t JOIN releases r ON r.id = t.release_id", + )?; + let tracks = statement + .query_map([], |row| { + Ok(ExportTrack { + id: row.get(0)?, + title: row.get(1)?, + year: row.get(2)?, + duration_seconds: row.get(3)?, + file_path: row.get(4)?, + artist_names: Vec::new(), + }) + })? + .collect::>>()? + .into_iter() + .map(|mut track| { + track.artist_names = track_artists.remove(&track.id).unwrap_or_default(); + track + }) + .collect(); + + Ok(FederationExport { + artists, + releases, + tracks, + }) + } + + pub fn tracks_by_ids(&self, ids: &[i64]) -> Result> { + let conn = self.lock(); + let mut tracks = Vec::with_capacity(ids.len()); + for &id in ids { + let mut found = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + WHERE t.id = ?1" + ), + params![id], + )?; + tracks.append(&mut found); + } + Ok(tracks) + } + + // ----------------------------------------------------------------- + // Playlists & likes + // ----------------------------------------------------------------- + + pub fn playlists(&self) -> Result> { + let conn = self.lock(); + let liked: i64 = conn.query_row("SELECT COUNT(*) FROM likes", [], |row| row.get(0))?; + let mut list = vec![PlaylistCard { + id: LIKES_PLAYLIST_ID, + title: "Liked tracks".to_string(), + track_count: liked, + kind: "likes".to_string(), + }]; + let mut statement = conn.prepare( + "SELECT p.id, p.title, + (SELECT COUNT(*) FROM playlist_tracks pt WHERE pt.playlist_id = p.id) + FROM playlists p ORDER BY p.title COLLATE NOCASE", + )?; + let rows = statement + .query_map([], |row| { + Ok(PlaylistCard { + id: row.get(0)?, + title: row.get(1)?, + track_count: row.get(2)?, + kind: "normal".to_string(), + }) + })? + .collect::>>()?; + list.extend(rows); + Ok(list) + } + + pub fn playlist(&self, id: i64) -> Result { + let conn = self.lock(); + if id == LIKES_PLAYLIST_ID { + let tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + JOIN likes k ON k.track_id = t.id + ORDER BY k.liked_at DESC" + ), + params![], + )?; + return Ok(PlaylistDetail { + id, + title: "Liked tracks".to_string(), + description: None, + tracks, + }); + } + let (title, description): (String, Option) = conn + .query_row( + "SELECT title, description FROM playlists WHERE id = ?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .context("playlist not found")?; + let tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + JOIN playlist_tracks pt ON pt.track_id = t.id + WHERE pt.playlist_id = ?1 + ORDER BY pt.position" + ), + params![id], + )?; + Ok(PlaylistDetail { + id, + title, + description, + tracks, + }) + } + + pub fn create_playlist(&self, title: &str) -> Result { + let conn = self.lock(); + conn.execute("INSERT INTO playlists (title) VALUES (?1)", [title])?; + Ok(PlaylistCard { + id: conn.last_insert_rowid(), + title: title.to_string(), + track_count: 0, + kind: "normal".to_string(), + }) + } + + pub fn update_playlist(&self, id: i64, title: &str, description: Option<&str>) -> Result<()> { + let conn = self.lock(); + conn.execute( + "UPDATE playlists SET title = ?2, description = ?3 WHERE id = ?1", + params![id, title, description], + )?; + Ok(()) + } + + pub fn delete_playlist(&self, id: i64) -> Result<()> { + let conn = self.lock(); + conn.execute("DELETE FROM playlists WHERE id = ?1", [id])?; + Ok(()) + } + + pub fn add_tracks_to_playlist(&self, playlist_id: i64, track_ids: &[i64]) -> Result<()> { + let mut conn = self.lock(); + let tx = conn.transaction()?; + let mut next: i64 = tx.query_row( + "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1", + [playlist_id], + |row| row.get(0), + )?; + for &track_id in track_ids { + let inserted = tx.execute( + "INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) + VALUES (?1, ?2, ?3)", + params![playlist_id, track_id, next], + )?; + if inserted > 0 { + next += 1; + } + } + tx.commit()?; + Ok(()) + } + + pub fn remove_tracks_from_playlist(&self, playlist_id: i64, track_ids: &[i64]) -> Result<()> { + let conn = self.lock(); + for &track_id in track_ids { + conn.execute( + "DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND track_id = ?2", + params![playlist_id, track_id], + )?; + } + Ok(()) + } + + pub fn likes(&self) -> Result> { + let conn = self.lock(); + let mut statement = conn.prepare("SELECT track_id FROM likes")?; + let ids = statement + .query_map([], |row| row.get(0))? + .collect::>>()?; + Ok(ids) + } + + /// Returns the new liked state. + pub fn toggle_like(&self, track_id: i64) -> Result { + let conn = self.lock(); + let removed = conn.execute("DELETE FROM likes WHERE track_id = ?1", [track_id])?; + if removed > 0 { + return Ok(false); + } + conn.execute("INSERT INTO likes (track_id) VALUES (?1)", [track_id])?; + Ok(true) + } + + pub fn add_history( + &self, + track_id: i64, + started_at: Option, + listened_seconds: i32, + completed: bool, + ) -> Result<()> { + let conn = self.lock(); + conn.execute( + "INSERT INTO history (track_id, started_at, listened_seconds, completed) + VALUES (?1, ?2, ?3, ?4)", + params![track_id, started_at, listened_seconds, completed], + )?; + Ok(()) + } + + // ----------------------------------------------------------------- + // Editing & deleting + // ----------------------------------------------------------------- + + pub fn update_track(&self, id: i64, edit: &TrackEdit) -> Result<()> { + let mut conn = self.lock(); + let tx = conn.transaction()?; + tx.execute( + "UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4 WHERE id = ?1", + params![id, edit.title, edit.track_number, edit.disc_number], + )?; + tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?; + link_track_artists(&tx, id, &edit.artists, "main")?; + link_track_artists(&tx, id, &edit.featured_artists, "featured")?; + tx.commit()?; + Ok(()) + } + + pub fn update_release(&self, id: i64, edit: &ReleaseEdit) -> Result<()> { + let mut conn = self.lock(); + let tx = conn.transaction()?; + tx.execute( + "UPDATE releases SET title = ?2, release_type = ?3, year = ?4 WHERE id = ?1", + params![id, edit.title, edit.release_type, edit.year], + )?; + // An empty artist list means "keep the current artists". + if !edit.artists.is_empty() { + tx.execute("DELETE FROM release_artists WHERE release_id = ?1", [id])?; + for (position, name) in edit.artists.iter().enumerate() { + let artist_id = find_or_create_artist(&tx, name)?; + tx.execute( + "INSERT OR IGNORE INTO release_artists (release_id, artist_id, position) + VALUES (?1, ?2, ?3)", + params![id, artist_id, position as i64], + )?; + } + } + tx.commit()?; + Ok(()) + } + + pub fn update_artist(&self, id: i64, name: &str, image_path: Option<&str>) -> Result<()> { + let conn = self.lock(); + let taken: Option = conn + .query_row( + "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE AND id != ?2", + params![name, id], + |row| row.get(0), + ) + .optional()?; + if taken.is_some() { + anyhow::bail!("an artist named \"{name}\" already exists"); + } + conn.execute( + "UPDATE artists SET name = ?2, image_path = ?3 WHERE id = ?1", + params![id, name, image_path], + )?; + Ok(()) + } + + pub fn delete_track(&self, id: i64) -> Result<()> { + let mut conn = self.lock(); + let tx = conn.transaction()?; + tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?; + cleanup_empty_releases(&tx)?; + tx.commit()?; + Ok(()) + } + + pub fn delete_release(&self, id: i64) -> Result<()> { + let conn = self.lock(); + conn.execute("DELETE FROM releases WHERE id = ?1", [id])?; + Ok(()) + } + + /// Delete an artist together with everything that only existed because + /// of them: tracks left without a main artist and releases left without + /// tracks. Collaborations with other artists survive. + pub fn delete_artist(&self, id: i64) -> Result<()> { + let mut conn = self.lock(); + let tx = conn.transaction()?; + tx.execute("DELETE FROM artists WHERE id = ?1", [id])?; + tx.execute( + "DELETE FROM tracks WHERE id NOT IN + (SELECT track_id FROM track_artists WHERE role = 'main')", + [], + )?; + cleanup_empty_releases(&tx)?; + tx.commit()?; + Ok(()) + } +} + +fn cleanup_empty_releases(tx: &rusqlite::Transaction) -> rusqlite::Result<()> { + tx.execute( + "DELETE FROM releases WHERE id NOT IN (SELECT release_id FROM tracks)", + [], + )?; + Ok(()) +} + +fn link_track_artists( + tx: &rusqlite::Transaction, + track_id: i64, + names: &[String], + role: &str, +) -> Result<()> { + for (position, name) in names.iter().enumerate() { + let artist_id = find_or_create_artist(tx, name)?; + tx.execute( + "INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position) + VALUES (?1, ?2, ?3, ?4)", + params![track_id, artist_id, role, position as i64], + )?; + } + Ok(()) +} + +pub(crate) fn find_or_create_artist(conn: &Connection, name: &str) -> Result { + let name = name.trim(); + anyhow::ensure!(!name.is_empty(), "artist name is empty"); + if let Some(id) = conn + .query_row( + "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE", + [name], + |row| row.get(0), + ) + .optional()? + { + return Ok(id); + } + conn.execute("INSERT INTO artists (name) VALUES (?1)", [name])?; + Ok(conn.last_insert_rowid()) +} + +fn release_card_from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(ReleaseCard { + id: row.get(0)?, + title: row.get(1)?, + release_type: row.get(2)?, + year: row.get(3)?, + cover_path: row.get(4)?, + track_count: row.get(5)?, + }) +} + +/// Run a track query built on TRACK_COLUMNS and attach artist lists. +fn query_tracks( + conn: &Connection, + sql: &str, + params: &[&dyn rusqlite::types::ToSql], +) -> Result> { + let mut statement = conn.prepare(sql)?; + let mut tracks = statement + .query_map(params, |row| { + Ok(TrackItem { + id: row.get(0)?, + title: row.get(1)?, + track_number: row.get(2)?, + disc_number: row.get(3)?, + duration_seconds: row.get(4)?, + release_id: row.get(5)?, + release_title: row.get(6)?, + release_year: row.get(7)?, + cover_path: row.get(8)?, + file_path: row.get(9)?, + audio_format: row.get(10)?, + audio_bitrate: row.get(11)?, + audio_sample_rate: row.get(12)?, + audio_bit_depth: row.get(13)?, + file_size_bytes: row.get(14)?, + play_count: row.get(15)?, + artists: Vec::new(), + featured_artists: Vec::new(), + }) + })? + .collect::>>()?; + let mut artist_statement = conn.prepare( + "SELECT a.id, a.name, ta.role FROM artists a + JOIN track_artists ta ON ta.artist_id = a.id + WHERE ta.track_id = ?1 + ORDER BY ta.position", + )?; + for track in &mut tracks { + let rows = artist_statement.query_map([track.id], |row| { + Ok(( + ArtistRef { + id: row.get(0)?, + name: row.get(1)?, + }, + row.get::<_, String>(2)?, + )) + })?; + for row in rows { + let (artist, role) = row?; + if role == "featured" { + track.featured_artists.push(artist); + } else { + track.artists.push(artist); + } + } + } + Ok(tracks) +} + +/// Escape LIKE wildcards in user input; queries use `ESCAPE '\'`. +fn like_escape(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_library() -> Library { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + Library { + conn: Mutex::new(conn), + covers_dir: std::env::temp_dir(), + } + } + + fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 { + let import = import::TrackImport { + file_path: format!("/music/{artist}/{album}/{title}.mp3"), + title: title.to_string(), + artists: vec![artist.to_string()], + featured_artists: Vec::new(), + album_artists: vec![artist.to_string()], + release_title: album.to_string(), + year: Some(2020), + track_number: None, + disc_number: None, + duration_seconds: 60.0, + audio_format: Some("mp3".into()), + audio_bitrate: Some(320), + audio_sample_rate: Some(44100), + audio_bit_depth: None, + file_size_bytes: Some(1), + cover: None, + }; + import::upsert_track(lib, &import).unwrap().0 + } + + #[test] + fn import_creates_artist_release_track() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + let page = lib.artists(1, 10).unwrap(); + assert_eq!(page.total, 1); + assert_eq!(page.items[0].name, "Artist"); + assert_eq!(page.items[0].track_count, 1); + + let detail = lib.artist(page.items[0].id).unwrap(); + assert_eq!(detail.releases.len(), 1); + assert_eq!(detail.top_tracks.len(), 1); + + let release = lib.release(detail.releases[0].id).unwrap(); + assert_eq!(release.tracks.len(), 1); + assert_eq!(release.tracks[0].id, track_id); + assert_eq!(release.tracks[0].artists[0].name, "Artist"); + } + + #[test] + fn reimport_updates_instead_of_duplicating() { + let lib = test_library(); + let first = add_track(&lib, "Song", "Artist", "Album"); + let second = add_track(&lib, "Song", "Artist", "Album"); + assert_eq!(first, second); + let page = lib.artists(1, 10).unwrap(); + assert_eq!(page.items[0].track_count, 1); + } + + #[test] + fn search_finds_all_kinds() { + let lib = test_library(); + add_track(&lib, "Neon Lights", "Neon Artist", "Neon Album"); + let results = lib.search("neon", 10).unwrap(); + assert_eq!(results.artists.len(), 1); + assert_eq!(results.releases.len(), 1); + assert_eq!(results.tracks.len(), 1); + // LIKE wildcards in the query must not match everything. + assert_eq!(lib.search("%", 10).unwrap().len(), 0); + } + + #[test] + fn playlists_and_likes_round_trip() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + let playlist = lib.create_playlist("Mix").unwrap(); + lib.add_tracks_to_playlist(playlist.id, &[track_id]).unwrap(); + assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1); + + assert!(lib.toggle_like(track_id).unwrap()); + assert_eq!(lib.likes().unwrap(), vec![track_id]); + assert_eq!(lib.playlist(LIKES_PLAYLIST_ID).unwrap().tracks.len(), 1); + assert!(!lib.toggle_like(track_id).unwrap()); + + lib.remove_tracks_from_playlist(playlist.id, &[track_id]) + .unwrap(); + assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0); + lib.delete_playlist(playlist.id).unwrap(); + // Only the virtual Likes playlist remains. + assert_eq!(lib.playlists().unwrap().len(), 1); + } + + #[test] + fn track_edit_relinks_artists() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + lib.update_track( + track_id, + &TrackEdit { + title: "Renamed".into(), + artists: vec!["Other".into()], + featured_artists: vec!["Guest".into()], + track_number: Some(2), + disc_number: None, + }, + ) + .unwrap(); + let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0); + assert_eq!(track.title, "Renamed"); + assert_eq!(track.artists[0].name, "Other"); + assert_eq!(track.featured_artists[0].name, "Guest"); + assert_eq!(track.track_number, Some(2)); + } + + #[test] + fn deleting_artist_cleans_up_own_content() { + let lib = test_library(); + add_track(&lib, "Song", "Solo", "Solo Album"); + let page = lib.artists(1, 10).unwrap(); + lib.delete_artist(page.items[0].id).unwrap(); + assert_eq!(lib.artists(1, 10).unwrap().total, 0); + assert_eq!(lib.search("Song", 10).unwrap().len(), 0); + } + + #[test] + fn delete_track_drops_empty_release() { + let lib = test_library(); + let track_id = add_track(&lib, "Only", "Artist", "Album"); + lib.delete_track(track_id).unwrap(); + let detail = lib.artist(lib.artists(1, 10).unwrap().items[0].id).unwrap(); + assert!(detail.releases.is_empty()); + } + + #[test] + fn history_counts_completed_plays() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + lib.add_history(track_id, None, 60, true).unwrap(); + lib.add_history(track_id, None, 10, false).unwrap(); + let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0); + assert_eq!(track.play_count, 1); + } +} diff --git a/src/library/models.rs b/src/library/models.rs new file mode 100644 index 0000000..b74554e --- /dev/null +++ b/src/library/models.rs @@ -0,0 +1,173 @@ +//! Data shapes the views render. They mirror what the furumusic API used to +//! return, but every field is now filled from the local SQLite library. + +#[derive(Debug, Clone)] +pub struct ArtistCard { + pub id: i64, + pub name: String, + /// Path to a local image file, if one is set for the artist. + pub image_path: Option, + pub release_count: i64, + pub track_count: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtistRef { + pub id: i64, + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct TrackItem { + pub id: i64, + pub title: String, + pub track_number: Option, + pub disc_number: Option, + pub duration_seconds: f64, + pub artists: Vec, + pub featured_artists: Vec, + pub release_id: i64, + pub release_title: String, + pub release_year: Option, + /// Absolute path to the local audio file. + pub file_path: String, + /// Path to a local cover image (the release cover). + pub cover_path: Option, + pub audio_format: Option, + pub audio_bitrate: Option, + pub audio_sample_rate: Option, + pub audio_bit_depth: Option, + pub file_size_bytes: Option, + /// Completed local plays, from the history table. + pub play_count: i64, +} + +impl TrackItem { + pub fn artist_line(&self) -> String { + let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect(); + if !self.featured_artists.is_empty() { + names.push("feat."); + names.extend(self.featured_artists.iter().map(|a| a.name.as_str())); + } + names.join(", ") + } + + pub fn duration_label(&self) -> String { + let total = self.duration_seconds.round() as i64; + format!("{}:{:02}", total / 60, total % 60) + } + + /// Full tech line for the status bar, including the sample rate. + pub fn tech_label_full(&self) -> String { + let mut parts = Vec::new(); + if let Some(format) = &self.audio_format { + parts.push(format.to_uppercase()); + } + if let Some(bitrate) = self.audio_bitrate { + parts.push(format!("{bitrate}kbps")); + } + if let Some(rate) = self.audio_sample_rate { + parts.push(format!("{:.1}kHz", f64::from(rate) / 1000.0)); + } + if let Some(bytes) = self.file_size_bytes { + parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0)); + } + parts.join(" · ") + } +} + +#[derive(Debug, Clone)] +pub struct ReleaseCard { + pub id: i64, + pub title: String, + pub release_type: String, + pub year: Option, + pub cover_path: Option, + pub track_count: i64, +} + +#[derive(Debug)] +pub struct ArtistDetail { + #[allow(dead_code, reason = "cache key is held by the caller")] + pub id: i64, + pub name: String, + pub image_path: Option, + pub total_track_count: i64, + pub total_play_count: i64, + pub top_tracks: Vec, + pub releases: Vec, + /// Tracks where this artist is featured (the only content for artists + /// without own releases). + pub featured_tracks: Vec, +} + +#[derive(Debug)] +pub struct ReleaseDetail { + #[allow(dead_code, reason = "cache key is held by the caller")] + pub id: i64, + pub title: String, + pub release_type: String, + pub year: Option, + pub cover_path: Option, + pub artists: Vec, + pub tracks: Vec, +} + +#[derive(Debug, Clone)] +pub struct PlaylistCard { + pub id: i64, + pub title: String, + pub track_count: i64, + /// "normal" for user playlists, "likes" for the virtual Likes playlist. + pub kind: String, +} + +#[derive(Debug)] +pub struct PlaylistDetail { + #[allow(dead_code, reason = "cache key is held by the caller")] + pub id: i64, + pub title: String, + #[allow(dead_code, reason = "shown in a detail header later")] + pub description: Option, + pub tracks: Vec, +} + +#[derive(Debug, Default)] +pub struct SearchResults { + pub artists: Vec, + pub releases: Vec, + pub tracks: Vec, +} + +impl SearchResults { + pub fn len(&self) -> usize { + self.artists.len() + self.releases.len() + self.tracks.len() + } +} + +#[derive(Debug)] +pub struct ArtistsPage { + pub items: Vec, + pub total: i64, + pub page: i64, + pub has_more: bool, +} + +/// Edited values submitted from the track edit form. `None` numbers clear +/// the column. +#[derive(Debug, Clone)] +pub struct TrackEdit { + pub title: String, + pub artists: Vec, + pub featured_artists: Vec, + pub track_number: Option, + pub disc_number: Option, +} + +#[derive(Debug, Clone)] +pub struct ReleaseEdit { + pub title: String, + pub release_type: String, + pub year: Option, + pub artists: Vec, +} diff --git a/src/main.rs b/src/main.rs index c232e72..44dd194 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,8 @@ -mod api; mod app; mod art; mod config; +mod federation; +mod library; mod media; mod player; mod ui; diff --git a/src/player/mod.rs b/src/player/mod.rs index ea3963b..939c94f 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -10,10 +10,9 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; use std::time::Duration; use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink}; -use stream_download::StreamDownload; -use stream_download::storage::temp::TempStorageProvider; -pub type TrackReader = StreamDownload; +/// Local audio files are read straight from disk. +pub type TrackReader = std::io::BufReader; /// Perceptual volume: cubic mapping from percent to linear amplitude, so /// equal percent steps sound like equal loudness steps and low percentages @@ -258,11 +257,10 @@ fn handle( *track_loaded = false; } Command::Seek(position) => { - if let Some(out) = output { - if let Err(err) = out.player.try_seek(position) { + if let Some(out) = output + && let Err(err) = out.player.try_seek(position) { tracing::warn!(%err, "seek failed"); } - } } Command::SetVolume(volume) => { if let Some(out) = output { diff --git a/src/ui/federation.rs b/src/ui/federation.rs new file mode 100644 index 0000000..70a7ea1 --- /dev/null +++ b/src/ui/federation.rs @@ -0,0 +1,138 @@ +//! The Federation tab: settings rows on top, a live status block below. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +use super::theme; +use crate::app::state::{AppState, FedRow}; + +pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { + let block = Block::bordered() + .title(" Federation ") + .title_style(theme::header()) + .border_style(theme::dim()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows_height = FedRow::ALL.len() as u16; + let [rows_area, _, status_area] = Layout::vertical([ + Constraint::Length(rows_height), + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas(inner); + + let settings = &state.federation.settings; + let on_off = |on: bool| if on { "on" } else { "off" }; + for (index, row) in FedRow::ALL.iter().enumerate() { + let (label, value) = match row { + FedRow::Toggle => ("Federation", on_off(settings.enabled).to_string()), + FedRow::NetworkId => ( + "Network ID (shared secret)", + if settings.network_id.is_empty() { + "(not set — press enter)".to_string() + } else { + settings.network_id.clone() + }, + ), + FedRow::SaveOnListen => ( + "Save federated tracks to the library on listen", + on_off(settings.save_on_listen).to_string(), + ), + FedRow::SyncNow => ("Publish the library now", "↵".to_string()), + FedRow::ShowTicket => ("Show my connection ticket", "↵".to_string()), + FedRow::Connect => ("Connect to a peer by ticket…", "↵".to_string()), + }; + let selected = index == state.federation.cursor; + let rect = Rect { + x: rows_area.x, + y: rows_area.y + index as u16, + width: rows_area.width, + height: 1, + }; + if rect.y >= rows_area.y + rows_area.height { + break; + } + let marker = if selected { "▶ " } else { " " }; + let label_width = 48usize; + let line = Line::from(vec![ + Span::styled(marker, theme::accent()), + Span::styled(format!("{label: Line<'static> { + Line::from(vec![ + Span::styled(format!("{label:<22}"), theme::dim()), + Span::raw(value), + ]) +} + +fn short_id(id: &str) -> String { + id.chars().take(12).collect::() + "…" +} + +fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { + let mut lines: Vec = vec![Line::styled("Status", theme::header())]; + match &state.federation.status { + None => lines.push(Line::styled("loading…", theme::dim())), + Some(status) if !status.running => { + lines.push(status_line("Node", "stopped".to_string())); + if let Some(error) = &status.last_error { + lines.push(status_line("Error", error.clone())); + } + lines.push(Line::default()); + lines.push(Line::styled( + "Enable federation and set a network id — every instance using the", + theme::dim(), + )); + lines.push(Line::styled( + "same id (furumi TUI or furumi-fd) finds the others automatically.", + theme::dim(), + )); + } + Some(status) => { + lines.push(status_line("Node", "running".to_string())); + lines.push(status_line("Network", status.network.clone())); + lines.push(status_line("Endpoint ID", status.endpoint_id.clone())); + let peers = if status.connected_peers.is_empty() { + "none yet".to_string() + } else { + let names: Vec = + status.connected_peers.iter().map(|p| short_id(p)).collect(); + format!("{} — {}", status.connected_peers.len(), names.join(", ")) + }; + lines.push(status_line("Connected peers", peers)); + lines.push(status_line( + "Known contacts", + status.known_contacts.to_string(), + )); + lines.push(status_line( + "Published items", + status.published_items.to_string(), + )); + lines.push(status_line( + "Last sync", + status + .last_sync + .clone() + .unwrap_or_else(|| "not yet".to_string()), + )); + if let Some(error) = &status.last_error { + lines.push(status_line("Error", error.clone())); + } + } + } + frame.render_widget(Paragraph::new(lines), area); +} diff --git a/src/ui/global.rs b/src/ui/global.rs index 3dcc106..1625a96 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Row, Table}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::{art, theme}; -use crate::api::models::{ArtistCard, ReleaseCard}; +use crate::library::models::{ArtistCard, ReleaseCard, SearchResults}; use crate::app::state::{ ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState, GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, release_groups, @@ -315,7 +315,7 @@ fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) { draw_tile( frame, tile, - tile_art(state, artist.image_url.as_ref()), + tile_art(state, artist.image_path.as_ref()), &artist.name, &artist_tile_meta(artist), index == global.selected, @@ -395,7 +395,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area }, - header_art(state, detail.image_url.as_ref()), + header_art(state, detail.image_path.as_ref()), ); let mut about = format!("{} releases", detail.releases.len()); if !detail.featured_tracks.is_empty() { @@ -536,7 +536,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: draw_tile( frame, tile, - tile_art(state, release.cover_url.as_ref()), + tile_art(state, release.cover_path.as_ref()), &release.title, &release_tile_meta(release), cursor == tracks + position, @@ -648,13 +648,12 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area }, - header_art(state, detail.cover_url.as_ref()), + header_art(state, detail.cover_path.as_ref()), ); let artists: Vec<&str> = detail.artists.iter().map(|a| a.name.as_str()).collect(); let year = detail.year.map(|y| format!(" · {y}")).unwrap_or_default(); - let uploaders: Vec<&str> = detail.uploaders.iter().map(|u| u.name.as_str()).collect(); - let mut info = vec![ + let info = vec![ Line::default(), Line::styled(detail.title.clone(), theme::header()), Line::raw(artists.join(", ")), @@ -668,12 +667,6 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor theme::dim(), ), ]; - if !uploaders.is_empty() { - info.push(Line::styled( - format!("uploaded by {}", uploaders.join(", ")), - theme::dim(), - )); - } frame.render_widget(Paragraph::new(info), info_area); // Track list with centered scrolling. @@ -719,15 +712,20 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { } let inner = bordered(frame, area, title); - let Some(results) = &search.results else { - let hint = if search.query.is_empty() { - "type to search artists, releases and tracks" - } else { - "searching…" - }; - return centered_line(frame, inner, Line::styled(hint, theme::dim())); + let empty_results = SearchResults::default(); + let results = match &search.results { + Some(results) => results, + None if !state.search.fed_tracks.is_empty() || state.search.fed_loading => &empty_results, + None => { + let hint = if search.query.is_empty() { + "type to search artists, releases and tracks" + } else { + "searching…" + }; + return centered_line(frame, inner, Line::styled(hint, theme::dim())); + } }; - if results.len() == 0 { + if results.len() == 0 && state.search.fed_tracks.is_empty() && !state.search.fed_loading { return centered_line(frame, inner, Line::styled("nothing found", theme::dim())); } @@ -784,6 +782,45 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { index += 1; } } + // Tracks found on the federated network, marked with the owning peer. + if !state.search.fed_tracks.is_empty() || state.search.fed_loading { + if !rows.is_empty() { + rows.push((Line::default(), None, None)); + } + let header = if state.search.fed_loading { + "Federation · searching…" + } else { + "Federation" + }; + rows.push((Line::styled(header, theme::header()), None, None)); + for fed in &state.search.fed_tracks { + let origin = if fed.own { + "your library".to_string() + } else { + format!("peer {}…", fed.owner_short()) + }; + let mut meta = fed.duration_label(); + if let Some(year) = fed.year { + if !meta.is_empty() { + meta.push_str(" · "); + } + meta.push_str(&year.to_string()); + } + rows.push(( + Line::from(vec![ + Span::styled("⇅ ", theme::accent()), + Span::raw(fed.title.clone()), + Span::styled( + format!(" {} · {}", fed.artist_line(), origin), + theme::dim(), + ), + ]), + Some(meta), + Some(index), + )); + index += 1; + } + } let cursor_row = rows .iter() diff --git a/src/ui/login.rs b/src/ui/login.rs deleted file mode 100644 index 779229d..0000000 --- a/src/ui/login.rs +++ /dev/null @@ -1,230 +0,0 @@ -use ratatui::Frame; -use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; -use ratatui::style::{Color, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Paragraph, Wrap}; - -use super::theme; -use crate::app::state::{LoginField, LoginForm, LoginMode}; - -pub fn draw(frame: &mut Frame, form: &LoginForm) { - match form.mode { - LoginMode::Form => draw_form(frame, form), - LoginMode::SsoPending => draw_sso_pending(frame, form), - } -} - -fn draw_form(frame: &mut Frame, form: &LoginForm) { - let area = centered(frame.area(), 52, 19); - let block = Block::bordered() - .title(" Sign in to furumi ") - .title_style(theme::header()) - .border_style(theme::accent()); - let inner = block.inner(area); - frame.render_widget(block, area); - - // SSO is the primary path: server URL + SSO button up top, the rarely - // used password fallback below a separator. - let [ - server, - sso_button, - separator, - username, - password, - signin_button, - message, - hint, - ] = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(1), - Constraint::Length(2), - Constraint::Length(1), - ]) - .areas(inner); - - draw_field( - frame, - server, - "Server URL", - &form.server_url, - false, - form.focus == LoginField::ServerUrl, - ); - draw_button( - frame, - sso_button, - "[ Continue with SSO ]", - form.focus == LoginField::SsoButton, - ); - frame.render_widget( - Paragraph::new(Line::styled("── or sign in with password ──", theme::dim())) - .alignment(Alignment::Center), - separator, - ); - draw_field( - frame, - username, - "Username", - &form.username, - false, - form.focus == LoginField::Username, - ); - draw_field( - frame, - password, - "Password", - &form.password, - true, - form.focus == LoginField::Password, - ); - draw_button( - frame, - signin_button, - "[ Sign in ]", - form.focus == LoginField::SignInButton, - ); - - draw_message(frame, message, form); - frame.render_widget( - Paragraph::new(Line::styled( - "tab/↑↓ move · enter submit · ctrl-c quit", - theme::dim(), - )) - .alignment(Alignment::Center), - hint, - ); -} - -fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) { - // The URL stays on ONE line (wrapping breaks copy-paste); the dialog is - // as wide as the terminal allows and ctrl-l copies the full link. - let width = - (form.sso_url.len() as u16 + 4).clamp(48, frame.area().width.saturating_sub(2).max(40)); - let area = centered(frame.area(), width, 14.min(frame.area().height)); - - let block = Block::bordered() - .title(" Continue with SSO ") - .title_style(theme::header()) - .border_style(theme::accent()); - let inner = block.inner(area); - frame.render_widget(block, area); - - let [steps, url_label, url, paste, message, hint] = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(3), - Constraint::Length(2), - Constraint::Length(1), - ]) - .areas(inner); - - let lines = if let Some(port) = form.sso_port { - vec![ - Line::raw("1. Finish signing in, in the browser window."), - Line::from(vec![ - Span::raw("2. Sign-in completes here automatically "), - Span::styled(format!("(waiting on 127.0.0.1:{port})"), theme::dim()), - ]), - Line::raw("3. If it doesn't, paste the code from the page below."), - ] - } else { - vec![ - Line::raw("1. Finish signing in, in the browser window."), - Line::raw("2. Copy the code shown on the final page."), - Line::raw("3. Paste it below and press Enter."), - ] - }; - frame.render_widget(Paragraph::new(lines), steps); - - frame.render_widget( - Paragraph::new(Line::styled( - "If the browser didn't open — ctrl-l copies this link, ctrl-o retries:", - theme::dim(), - )), - url_label, - ); - // One line, never wrapped: a wrapped URL copies with a line break and - // stops working. If it doesn't fit, ctrl-l still copies it whole. - frame.render_widget( - Paragraph::new(Line::styled(form.sso_url.clone(), theme::accent())), - url, - ); - - draw_field(frame, paste, "Link or code", &form.sso_paste, false, true); - draw_message(frame, message, form); - frame.render_widget( - Paragraph::new(Line::styled( - "enter submit · ctrl-l copy link · esc back · ctrl-c quit", - theme::dim(), - )) - .alignment(Alignment::Center), - hint, - ); -} - -fn draw_field(frame: &mut Frame, area: Rect, label: &str, value: &str, mask: bool, focused: bool) { - let border = if focused { - theme::accent() - } else { - theme::dim() - }; - let block = Block::bordered().title(label).border_style(border); - let shown = if mask { - "•".repeat(value.chars().count()) - } else { - value.to_string() - }; - // Keep the tail visible when the value overflows the field. - let width = block.inner(area).width.saturating_sub(1) as usize; - let mut text: String = shown - .chars() - .skip(shown.chars().count().saturating_sub(width)) - .collect(); - if focused { - text.push('█'); - } - frame.render_widget(Paragraph::new(text).block(block), area); -} - -fn draw_button(frame: &mut Frame, area: Rect, label: &str, focused: bool) { - let style = if focused { - theme::tab_active() - } else { - theme::dim() - }; - frame.render_widget( - Paragraph::new(Line::styled(label, style)).alignment(Alignment::Center), - area, - ); -} - -fn draw_message(frame: &mut Frame, area: Rect, form: &LoginForm) { - let line = if form.busy { - Line::styled("signing in…", theme::accent()) - } else if let Some(error) = &form.error { - Line::styled(error.clone(), Style::new().fg(Color::Red)) - } else { - Line::default() - }; - frame.render_widget( - Paragraph::new(line) - .wrap(Wrap { trim: true }) - .alignment(Alignment::Center), - area, - ); -} - -fn centered(area: Rect, width: u16, height: u16) -> Rect { - let [rect] = Layout::horizontal([Constraint::Length(width.min(area.width))]) - .flex(Flex::Center) - .areas(area); - let [rect] = Layout::vertical([Constraint::Length(height.min(area.height))]) - .flex(Flex::Center) - .areas(rect); - rect -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e4d9a12..4ff4b02 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,6 @@ pub mod art; +mod federation; mod global; -mod login; mod logs; mod playlists; mod popup; @@ -12,14 +12,10 @@ use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Paragraph, Tabs}; -use crate::app::state::{AppState, Screen, Tab, TrackSelectionScope}; +use crate::app::state::{AppState, Tab, TrackSelectionScope}; use crate::config::keymap::Keymap; pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) { - if state.screen == Screen::Login { - login::draw(frame, &state.login); - return; - } let [tabs_area, main_area, status_area] = Layout::vertical([ Constraint::Length(1), Constraint::Min(0), @@ -32,6 +28,7 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) { Tab::Global => global::draw(frame, main_area, state), Tab::Playlists => playlists::draw(frame, main_area, state), Tab::Queue => draw_queue(frame, main_area, state), + Tab::Federation => federation::draw(frame, main_area, state), Tab::Logs => logs::draw(frame, main_area, state), } draw_status(frame, status_area, state); @@ -60,7 +57,7 @@ pub(crate) fn track_row( frame: &mut Frame, area: Rect, state: &AppState, - track: &crate::api::models::TrackItem, + track: &crate::library::models::TrackItem, index_label: String, selected: bool, visual_selected: bool, @@ -92,7 +89,7 @@ pub(crate) fn track_row( } pub(crate) fn track_meta_suffix( - track: &crate::api::models::TrackItem, + track: &crate::library::models::TrackItem, include_tech: bool, ) -> String { let has_tech = track.audio_format.is_some() @@ -201,8 +198,8 @@ fn format_secs(secs: f64) -> String { /// Wider consoles get a longer bar and full flags; narrow ones drop pieces. fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> { let mut spans: Vec> = Vec::new(); - if let Some(track) = &player.current { - if player.playing { + if let Some(track) = &player.current + && player.playing { let bar_width: usize = match width { 0..=59 => 0, 60..=79 => 8, @@ -227,7 +224,6 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line< )); } } - } if width >= 80 { let volume_cells = usize::from(player.volume / 10); spans.extend([ @@ -260,65 +256,21 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line< Line::from(spans) } -fn truncate_chars(value: &str, max: usize) -> String { - let mut out: String = value.chars().take(max).collect(); - if value.chars().count() > max { - out.push('…'); - } - out -} - -fn device_status_line(state: &AppState) -> Line<'static> { - if state.devices.is_playback_device() { - return Line::from(vec![Span::styled("playing here", theme::accent())]); - } - let name = state - .devices - .active_device_name() - .map(|name| truncate_chars(name, 26)) - .unwrap_or_else(|| "remote device".to_string()); - Line::from(vec![ - Span::styled("controlling ", theme::dim()), - Span::styled(name, theme::accent()), - ]) -} - fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { let [player_row, message_row] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); let player = &state.player; - // Layout: track title left, time/progress/flags centered, user right. - // The center block is built first and gets a fixed width; the title + // Layout: track title left, time/progress/flags on the right. The + // right block is built first and gets a fixed width; the title // truncates into whatever is left. let center = player_right_line(player, area.width); let center_width = (center.width() as u16).min(area.width); - let device_line = device_status_line(state); - let device_width = (device_line.width() as u16).min(32); - let user_line = state.user.as_ref().map(|user| { - Line::from(vec![ - Span::styled("◉ ", theme::accent()), - Span::raw(user.name.clone()), - ]) - }); - let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16); - let [title_area, right_area, device_area, user_area] = Layout::horizontal([ + let [title_area, right_area] = Layout::horizontal([ Constraint::Min(8), Constraint::Length(center_width), - Constraint::Length(device_width.saturating_add(2)), - Constraint::Length(user_width), ]) .areas(player_row); - frame.render_widget( - Paragraph::new(device_line).alignment(Alignment::Right), - device_area, - ); - if let Some(user_line) = user_line { - frame.render_widget( - Paragraph::new(user_line).alignment(Alignment::Right), - user_area, - ); - } let mut spans = Vec::new(); match &player.current { diff --git a/src/ui/playlists.rs b/src/ui/playlists.rs index 25da663..da3e1ce 100644 --- a/src/ui/playlists.rs +++ b/src/ui/playlists.rs @@ -78,26 +78,8 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) { } else { Span::raw(" ") }; - let mut flags = Vec::new(); - if !playlist.is_own { - if let Some(owner) = &playlist.owner_name { - flags.push(format!("by {owner}")); - } - } - if playlist.is_public { - flags.push("public".to_string()); - } - let suffix = if flags.is_empty() { - String::new() - } else { - format!(" {}", flags.join(" · ")) - }; frame.render_widget( - Paragraph::new(Line::from(vec![ - marker, - Span::raw(playlist.title.clone()), - Span::styled(suffix, theme::dim()), - ])), + Paragraph::new(Line::from(vec![marker, Span::raw(playlist.title.clone())])), row, ); frame.render_widget( diff --git a/src/ui/popup.rs b/src/ui/popup.rs index b34614f..411d744 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -4,8 +4,8 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Paragraph, Wrap}; use super::theme; -use crate::api::models::{ArtistRef, TrackItem}; -use crate::app::state::{AppState, Loadable, Popup, addable_playlists}; +use crate::app::state::{AppState, EditField, Loadable, Popup, addable_playlists}; +use crate::library::models::{ArtistRef, TrackItem}; pub fn draw(frame: &mut Frame, state: &AppState) { match state.popup.as_ref() { @@ -13,102 +13,156 @@ pub fn draw(frame: &mut Frame, state: &AppState) { draw_picker(frame, state, &track.title, *cursor) } Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy), - Some(Popup::Devices { cursor }) => draw_devices(frame, state, *cursor), + Some(Popup::Edit { + title, + fields, + focus, + error, + .. + }) => draw_edit(frame, title, fields, *focus, error.as_deref()), + Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, label), Some(Popup::TrackInfo { tracks, cursor, scroll, }) => draw_track_info(frame, tracks, *cursor, *scroll), Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry), + Some(Popup::FedInput { field, input }) => draw_fed_input(frame, field.title(), input), + Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text), None => {} } } -fn draw_devices(frame: &mut Frame, state: &AppState, cursor: usize) { - let rows = state.devices.devices.len().max(1); - let height = (rows as u16 + 4) - .min(frame.area().height.saturating_sub(2)) - .max(7); - let area = centered(frame.area(), 64, height); +/// One-line text entry on the Federation tab (network id / peer ticket). +fn draw_fed_input(frame: &mut Frame, title: &str, input: &str) { + let area = centered(frame.area(), 64, 5); let block = Block::bordered() - .title(" Connected devices ") + .title(format!(" {title} ")) + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + let [entry_area, hint_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); + // Keep the tail visible when the value (a ticket) exceeds the width. + let visible: String = { + let width = usize::from(entry_area.width.saturating_sub(2)); + let chars: Vec = input.chars().collect(); + let skip = chars.len().saturating_sub(width); + chars[skip..].iter().collect() + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(visible), + Span::styled("█", theme::accent()), + ])), + entry_area, + ); + frame.render_widget( + Paragraph::new(Line::styled("enter: apply · esc: cancel", theme::dim())) + .alignment(Alignment::Center), + hint_area, + ); +} + +/// Read-only wrapped text (this peer's federation ticket). +fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) { + let width = frame.area().width.saturating_sub(8).clamp(24, 90); + let text_width = usize::from(width.saturating_sub(2)); + let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16; + let area = centered(frame.area(), width, lines_needed.clamp(5, frame.area().height)); + let block = Block::bordered() + .title(format!(" {title} ")) + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + frame.render_widget( + Paragraph::new(text.to_string()).wrap(Wrap { trim: false }), + inner, + ); +} + +/// Metadata edit form: one bordered input per field, the focused field gets +/// the accent border and a cursor block. +fn draw_edit( + frame: &mut Frame, + title: &str, + fields: &[EditField], + focus: usize, + error: Option<&str>, +) { + let height = (fields.len() as u16 * 3 + 4).min(frame.area().height.saturating_sub(2)); + let area = centered(frame.area(), 60, height); + let block = Block::bordered() + .title(format!(" {title} ")) .title_style(theme::header()) .border_style(theme::accent()); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); - let [list_area, _, footer] = Layout::vertical([ - Constraint::Min(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas(inner); + let mut constraints: Vec = fields.iter().map(|_| Constraint::Length(3)).collect(); + constraints.push(Constraint::Min(0)); + constraints.push(Constraint::Length(1)); + let areas = Layout::vertical(constraints).split(inner); - if state.devices.devices.is_empty() { - frame.render_widget( - Paragraph::new(Line::styled("waiting for device poll…", theme::dim())) - .alignment(Alignment::Center), - list_area, - ); - } else { - let visible = usize::from(list_area.height.max(1)); - let cursor = cursor.min(state.devices.devices.len() - 1); - let first = cursor - .saturating_sub(visible / 2) - .min(state.devices.devices.len().saturating_sub(visible)); - for (index, device) in state - .devices - .devices - .iter() - .enumerate() - .skip(first) - .take(visible) - { - let row = Rect { - x: list_area.x, - y: list_area.y + (index - first) as u16, - width: list_area.width, - height: 1, - }; - let marker = if device.is_active { - Span::styled("▶ ", theme::accent()) - } else { - Span::styled(" ", theme::dim()) - }; - let current = if device.is_current { - " · this TUI" - } else { - "" - }; - let switching = if state.devices.switching_to.as_deref() == Some(device.id.as_str()) { - " · switching" - } else { - "" - }; - let line = Line::from(vec![ - marker, - Span::raw(device.name.clone()), - Span::styled( - format!(" · {}{current}{switching}", device.kind), - theme::dim(), - ), - ]); - frame.render_widget(Paragraph::new(line), row); - if index == cursor { - frame.buffer_mut().set_style(row, theme::tab_active()); - } + for (index, field) in fields.iter().enumerate() { + let focused = index == focus; + let field_block = Block::bordered().title(field.label).border_style(if focused { + theme::accent() + } else { + theme::dim() + }); + let field_inner = field_block.inner(areas[index]); + frame.render_widget(field_block, areas[index]); + let width = usize::from(field_inner.width.saturating_sub(1)); + let mut shown: String = field + .value + .chars() + .skip(field.value.chars().count().saturating_sub(width)) + .collect(); + if focused { + shown.push('█'); } + frame.render_widget(Paragraph::new(shown), field_inner); } - let hint = if let Some(error) = &state.devices.poll_error { - Line::styled(format!("sync error: {error}"), theme::dim()) - } else { - Line::styled("enter make active · esc close", theme::dim()) + let footer = areas[areas.len() - 1]; + let hint = match error { + Some(error) => Line::styled(error.to_string(), theme::accent()), + None => Line::styled("tab/↑↓ field · enter save · esc cancel", theme::dim()), }; frame.render_widget(Paragraph::new(hint).alignment(Alignment::Center), footer); } +fn draw_confirm_delete(frame: &mut Frame, label: &str) { + let width = 64.min(frame.area().width.saturating_sub(4)).max(30); + let area = centered(frame.area(), width, 7); + let block = Block::bordered() + .title(" Delete? ") + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + + let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner); + frame.render_widget( + Paragraph::new(format!("Delete {label}?")) + .wrap(Wrap { trim: false }) + .alignment(Alignment::Center), + body, + ); + frame.render_widget( + Paragraph::new(Line::styled("enter/y delete · esc/n cancel", theme::dim())) + .alignment(Alignment::Center), + footer, + ); +} + fn draw_log_detail(frame: &mut Frame, entry: &crate::config::logging::LogEntry) { let width = 90.min(frame.area().width.saturating_sub(4)).max(40); let height = 18.min(frame.area().height.saturating_sub(2)).max(7); @@ -203,7 +257,6 @@ fn track_info_lines(track: &TrackItem) -> Vec> { track.duration_seconds ), ), - field("Uploader", empty_dash(&track.uploader_name)), field("Audio format", opt_string(track.audio_format.clone())), field( "Bitrate", @@ -220,18 +273,9 @@ fn track_info_lines(track: &TrackItem) -> Vec> { opt_map(track.audio_bit_depth, |v| format!("{v} bit")), ), field("File size", file_size(track.file_size_bytes)), - field("Last.fm listeners", opt_display(track.lastfm_listeners)), - field("Last.fm plays", opt_display(track.lastfm_playcount)), - field( - "Last.fm rating", - opt_map(track.lastfm_rating, |v| format!("{v:.3}")), - ), - field( - "Last.fm updated", - opt_string(track.lastfm_updated_at.clone()), - ), - field("Stream URL", empty_dash(&track.stream_url)), - field("Cover URL", opt_string(track.cover_url.clone())), + field("Plays", track.play_count.to_string()), + field("File path", empty_dash(&track.file_path)), + field("Cover path", opt_string(track.cover_path.clone())), ] }