From 0d7c9b9f5e4cae22a4cfe3f73fe589a04baa5428 Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Fri, 14 Aug 2026 02:14:46 +0100 Subject: [PATCH] Fixed UI bugs --- ARCHITECTURE.md | 54 +- Cargo.lock | 622 ++++++++++- Cargo.toml | 8 +- crates/application/src/lib.rs | 216 +++- crates/backend-api/src/lib.rs | 91 +- crates/backend/Cargo.toml | 6 + crates/backend/src/actor_devices.rs | 1 + crates/backend/src/devices.rs | 257 ++++- crates/backend/src/devices/store.rs | 26 +- crates/backend/src/federation.rs | 76 +- crates/backend/src/federation_similarity.rs | 378 +++++++ crates/backend/src/lib.rs | 433 +++++++- crates/backend/src/settings/mod.rs | 144 ++- crates/backend/src/similarity.rs | 1096 +++++++++++++++++++ crates/backend/src/support.rs | 82 +- crates/backend/src/tests.rs | 65 ++ crates/domain/src/lib.rs | 71 ++ crates/ui/assets/history.svg | 5 + crates/ui/src/lib.rs | 165 ++- crates/ui/src/render.rs | 238 +++- crates/ui/ui/app.slint | 411 ++++++- crates/ui/ui/components.slint | 56 +- crates/ui/ui/models.slint | 3 + shell.nix | 5 + 24 files changed, 4351 insertions(+), 158 deletions(-) create mode 100644 crates/backend/src/federation_similarity.rs create mode 100644 crates/backend/src/similarity.rs create mode 100644 crates/ui/assets/history.svg diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8122c6e..3e43b25 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -65,10 +65,50 @@ the common placeholder and never performs filesystem or network I/O. ## Settings persistence -The backend stores settings in `furumi-desktop.sqlite3` under the platform -application-data directory selected by `directories::ProjectDirs`. Schema -changes are ordered migrations recorded in `schema_migrations`; each migration -runs in a transaction. The settings writer owns its SQLite connection on a -dedicated thread and coalesces bursts of edits before writing the latest full -snapshot. The configured device name is also written to the connected-device -identity and published through the device-profile operation log. +The backend stores settings in `furumi-desktop.sqlite3` under the dedicated +`furumi-desktop` platform application-data directory selected by +`directories::ProjectDirs`. Device identity, federation state, and caches use +that same desktop-specific namespace and are never shared with other Furumi +clients. Only the music library path returned by `furumi-library` is shared. +Schema changes are ordered migrations recorded in `schema_migrations`; each +migration runs in a transaction. The settings writer owns its SQLite connection +on a dedicated thread and coalesces bursts of edits before writing the latest +full snapshot. The configured device name is also written to the connected- +device identity and published through the device-profile operation log. + +## Device pairing and sync groups + +A pairing request includes the requester's sync-group ID, active-device count, +and known device profiles. A requester that is the only active device can move +to the inviter's group through the normal accept flow. If it already belongs to +a different group with multiple active devices, the inviter must explicitly +choose either to join that existing group or keep the local group and move only +the requesting device into it. Joining imports the requester's group profiles; +keeping the local group may leave the requester's former peers unable to sync +with that device. The backend never resolves this conflict without a user +choice. + +The default music directory is derived from the parent directory of the shared +`furumi-library` database and named `federation-media`. This keeps the default +platform-correct (`$XDG_DATA_HOME/furumi/federation-media` on Linux) and shared +with other Furumi clients. The desktop-specific federation image and temporary +stream caches remain under the desktop cache directory. A user-selected Library +Path replaces only the permanent music directory and is never overwritten by +default-path migrations. + +## Listening history and similarity + +Qualified listening sessions are append-only `ListenRecorded` operations in +the trusted-device sync log. Desktop records its own finished, skipped, +stopped, and replaced sessions using the same wire contract as TUI, then +projects `furumi-library`'s materialized history into a dedicated screen. +Unknown remote content is resolved asynchronously by content ID so metadata +and artwork can be enriched without blocking the UI. + +Similarity is disabled by default. When enabled, the backend downloads the +selected versioned ONNX model, stores normalized embeddings in the shared +music database, and keeps an exact in-memory index for local queries. Network +queries additionally require explicit privacy consent. Compatible peers are +discovered through the signed similarity-routing DHT with connected/known +peers as fallback; only anonymous numeric embeddings with an exact profile +fingerprint are exchanged. diff --git a/Cargo.lock b/Cargo.lock index 774b138..f7bb842 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,6 +184,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "anymap3" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" + [[package]] name = "arbitrary" version = "1.4.2" @@ -526,7 +532,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey", + "pastey 0.1.1", "rayon", "thiserror 2.0.20", "v_frame", @@ -615,6 +621,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -1435,6 +1459,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive-new" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1580,6 +1615,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dpi" version = "0.1.2" @@ -1638,6 +1679,18 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dyn-eq" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388" + +[[package]] +name = "dyn-hash" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fdab65db9274e0168143841eb8f864a0a21f8b1b8d2ba6812bbe6024346e99e" + [[package]] name = "ed25519" version = "3.0.0" @@ -1659,7 +1712,7 @@ dependencies = [ "ed25519", "rand_core 0.10.1", "serde", - "sha2", + "sha2 0.11.0", "signature", "subtle", "zeroize", @@ -1756,6 +1809,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -1983,6 +2047,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + [[package]] name = "flume" version = "0.12.0" @@ -2122,17 +2192,23 @@ name = "furumi-backend" version = "0.1.1" dependencies = [ "anyhow", + "blake3", "directories", "furumi-backend-api", "furumi-domain", "furumi-library", + "futures-util", "music-dht", + "reqwest 0.12.28", "rodio", "rusqlite", + "rustfft", "serde", "serde_json", + "sha2 0.10.9", "tokio", "tokio-util", + "tract-onnx", ] [[package]] @@ -2593,6 +2669,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -2635,6 +2712,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash", + "serde", + "serde_core", ] [[package]] @@ -2867,6 +2946,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -3534,6 +3614,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "io-lifetimes" version = "1.0.11" @@ -3602,7 +3691,7 @@ dependencies = [ "portable-atomic", "portmapper", "rand 0.10.2", - "reqwest", + "reqwest 0.13.4", "rustc-hash 2.1.3", "rustls", "rustls-pki-types", @@ -3717,7 +3806,7 @@ dependencies = [ "pin-project", "postcard", "rand 0.10.2", - "reqwest", + "reqwest 0.13.4", "rustls", "rustls-pki-types", "serde", @@ -4200,6 +4289,12 @@ dependencies = [ "libc", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "matchers" version = "0.2.0" @@ -4209,6 +4304,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -4234,6 +4339,12 @@ dependencies = [ "libc", ] +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "memoffset" version = "0.7.1" @@ -4252,6 +4363,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minijinja" +version = "2.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d74234349a775546a83af0f0c0c0e3a73227dee4950a542cda81a47240b3e6" +dependencies = [ + "memo-map", + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4406,6 +4527,21 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -4607,6 +4743,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom-language" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29" +dependencies = [ + "nom 8.0.0", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -5407,6 +5552,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -5608,6 +5759,15 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.19.1" @@ -5709,6 +5869,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -5756,6 +5925,29 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -5828,6 +6020,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -5925,6 +6173,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_distr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" +dependencies = [ + "num-traits", + "rand 0.10.2", +] + [[package]] name = "rand_pcg" version = "0.10.2" @@ -6011,6 +6269,12 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.12.0" @@ -6125,6 +6389,47 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -6158,7 +6463,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -6314,6 +6619,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "0.37.28" @@ -6459,6 +6778,19 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safetensors" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846" +dependencies = [ + "hashbrown 0.16.1", + "libc", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6468,6 +6800,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scan_fmt" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b53b0a5db882a8e2fdaae0a43f7b39e7e9082389e978398bdf223a55b581248" +dependencies = [ + "regex", +] + [[package]] name = "schannel" version = "0.1.29" @@ -6636,6 +6977,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serdect" version = "0.4.3" @@ -6663,6 +7016,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -7120,6 +7484,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strict-num" version = "0.1.1" @@ -7129,6 +7499,16 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string-interner" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad3df9b59e2eded8d825c7c4363ad339a20fb6bc0b9a4778560f518f59910b15" +dependencies = [ + "hashbrown 0.16.1", + "serde", +] + [[package]] name = "strum" version = "0.28.0" @@ -7916,6 +8296,217 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tract-core" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e176a669d5da02cccc92bbfe5ee4e57686ed8841022608a9eba014d3b7886" +dependencies = [ + "anyhow", + "anymap3", + "bit-set", + "derive-new", + "downcast-rs 2.0.2", + "dyn-clone", + "dyn-eq", + "erased-serde", + "inventory", + "lazy_static", + "log", + "maplit", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pastey 0.2.3", + "rustfft", + "serde", + "smallvec", + "tract-data", + "tract-linalg", +] + +[[package]] +name = "tract-data" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "870236dd45aaeb1381023cb709a67ff14ece608ee0b37f99aa166d166db9b0d0" +dependencies = [ + "anyhow", + "downcast-rs 2.0.2", + "dyn-clone", + "dyn-eq", + "dyn-hash", + "half", + "inventory", + "itertools 0.14.0", + "lazy_static", + "libm", + "maplit", + "ndarray", + "nom 8.0.0", + "nom-language", + "num-integer", + "num-traits", + "parking_lot", + "scan_fmt", + "smallvec", + "string-interner", +] + +[[package]] +name = "tract-extra" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "effcae1ebfce133e8bf6c79ad31298cbee0a9eceb3e4642fe1d14cb54cca78e6" +dependencies = [ + "tract-nnef", + "tract-pulse", +] + +[[package]] +name = "tract-hir" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28783b2bb583177685f65016a866b01eb3d383bce4fa7ae1b9692b325b64449d" +dependencies = [ + "derive-new", + "log", + "tract-core", +] + +[[package]] +name = "tract-linalg" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e01491f7360806ef061af4c016a2d0a801d586768896394a0b8a7d6872c2b0" +dependencies = [ + "byteorder", + "cc", + "derive-new", + "downcast-rs 2.0.2", + "dyn-clone", + "dyn-eq", + "dyn-hash", + "half", + "lazy_static", + "log", + "minijinja", + "num-traits", + "pastey 0.2.3", + "scan_fmt", + "tract-data", + "walkdir", +] + +[[package]] +name = "tract-nnef" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00417fabf01aeea7bc56107367862e5bb8da18c9ad850c9061d3823700479ecc" +dependencies = [ + "byteorder", + "erased-serde", + "flate2", + "log", + "minijinja", + "nom 8.0.0", + "nom-language", + "safetensors", + "serde", + "serde_json", + "simd-adler32", + "tar", + "tract-core", + "walkdir", +] + +[[package]] +name = "tract-onnx" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3215dd27bddd2a041a20fee750013b400135186d3485501c9274c755b19ceb0" +dependencies = [ + "bytes", + "derive-new", + "dyn-eq", + "log", + "memmap2", + "num-integer", + "prost", + "smallvec", + "tract-extra", + "tract-hir", + "tract-nnef", + "tract-onnx-opl", + "tract-transformers", +] + +[[package]] +name = "tract-onnx-opl" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f549ad3f245c1c00ce710c66cc0e086be0b637f35934d37b655ff175e1ab3e" +dependencies = [ + "dyn-eq", + "getrandom 0.4.3", + "log", + "rand 0.10.2", + "rand_distr", + "rustfft", + "tract-extra", + "tract-nnef", +] + +[[package]] +name = "tract-pulse" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a164e22e96ab9b5c90458fa270700570d87963e3dec9ccaac1ab18d9fa763ce2" +dependencies = [ + "downcast-rs 2.0.2", + "dyn-eq", + "erased-serde", + "lazy_static", + "log", + "serde", + "tract-pulse-opl", + "tract-transformers", +] + +[[package]] +name = "tract-pulse-opl" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65418f9e93e0af0d567f4f2f1bf2309b53930fa2543c635a69fda10c87a04987" +dependencies = [ + "downcast-rs 2.0.2", + "dyn-eq", + "lazy_static", + "tract-nnef", +] + +[[package]] +name = "tract-transformers" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8471ebf7f52d226552283d9130539595540c28ed41b8e032df3830507d3ccdd5" +dependencies = [ + "float-ord", + "rayon", + "tract-nnef", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -7941,6 +8532,12 @@ dependencies = [ "serde", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" @@ -8285,6 +8882,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -8305,7 +8915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ "cc", - "downcast-rs", + "downcast-rs 1.2.1", "rustix 1.1.4", "scoped-tls", "smallvec", diff --git a/Cargo.toml b/Cargo.toml index da830f1..bc2af67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.1" +version = "0.1.2" edition = "2024" rust-version = "1.97" license = "WTFPL" @@ -21,6 +21,8 @@ readme = "README.md" [workspace.dependencies] anyhow = "1.0.102" +blake3 = "1" +futures-util = "0.3.32" slint = { version = "=1.17.1", default-features = false, features = [ "backend-winit", "renderer-femtovg", @@ -47,6 +49,10 @@ rodio = { version = "0.22.2", default-features = false, features = [ "symphonia-isomp4", "symphonia-alac", ] } +reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream"] } +rustfft = "6.4.1" +sha2 = "0.10.9" +tract-onnx = "0.23.4" souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] } core-foundation = "0.10.1" objc2 = "0.6.4" diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 987287e..27819ff 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -3,7 +3,7 @@ use furumi_backend_api::{ BackendCommand, BackendSnapshot, PlaybackRepeat, PlaybackStatus, RequestId, }; -use furumi_domain::{ArtistKey, QueueItemId, ReleaseKey, TrackKey}; +use furumi_domain::{ArtistKey, QueueItemId, Release, ReleaseKey, Track, TrackKey}; use std::time::Duration; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -19,7 +19,7 @@ pub struct Strings { pub search: &'static str, pub library: &'static str, pub queue: &'static str, - pub recently_played: &'static str, + pub listening_history: &'static str, pub made_for_listening: &'static str, pub empty_queue: &'static str, pub search_placeholder: &'static str, @@ -31,7 +31,7 @@ pub const EN: Strings = Strings { search: "Search", library: "Your library", queue: "Queue", - recently_played: "Recently played", + listening_history: "Listening history", made_for_listening: "Made for listening", empty_queue: "Your queue is empty", search_placeholder: "Artists, albums or tracks", @@ -52,6 +52,8 @@ pub enum Screen { Home, Search, Library, + History, + Similarity, Artist(ArtistKey), Release(ReleaseKey, Option), Playlist(i64), @@ -117,6 +119,13 @@ pub enum UiAction { }, PlayTrack(TrackKey), PlayQueueItem(QueueItemId), + MoveQueueItem { + item_id: QueueItemId, + target_index: usize, + }, + RemoveQueueItem(QueueItemId), + SearchSimilar(TrackKey), + ClearSimilarity, PlayContext { tracks: Vec, selected: TrackKey, @@ -145,6 +154,13 @@ pub enum UiAction { LibraryPathChanged(String), FederationChanged(bool), SaveFederatedOnListenChanged(bool), + SimilarityEnabledChanged(bool), + SimilarityModelChanged(String), + SimilarityProfileChanged(String), + SimilarityWorkersChanged(usize), + SimilarityMinimumScoreChanged(f32), + SimilarityMaxTracksPerArtistChanged(usize), + SimilarityFederationConsentChanged(bool), LanguageChanged(String), ShowTrackInfo(TrackKey), CloseTrackInfo, @@ -180,13 +196,15 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec { state.next_request_id = state.next_request_id.saturating_add(1); let request_id = RequestId::new(state.next_request_id); match screen { - Screen::Artist(key) => find_artist(state, &key).map_or_else(Vec::new, |artist| { - send(BackendCommand::LoadArtist { - request_id, - key, - name: artist.name.clone(), + Screen::Artist(key) => { + find_artist_name(state, &key).map_or_else(Vec::new, |name| { + send(BackendCommand::LoadArtist { + request_id, + key, + name: name.to_owned(), + }) }) - }), + } Screen::Release(key, preferred) => { find_release(state, &key).map_or_else(Vec::new, |release| { let selected_artist = preferred @@ -198,8 +216,8 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec { .find(|artist| &artist.key == preferred) .map(|artist| (artist.key.clone(), artist.name.clone())) .or_else(|| { - find_artist(state, preferred) - .map(|artist| (artist.key.clone(), artist.name.clone())) + find_artist_name(state, preferred) + .map(|name| (preferred.clone(), name.to_owned())) }) }) .or_else(|| { @@ -271,6 +289,26 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec { } UiAction::PlayTrack(track) => send(BackendCommand::PlayTrack { track }), UiAction::PlayQueueItem(item_id) => send(BackendCommand::PlayQueueItem { item_id }), + UiAction::MoveQueueItem { + item_id, + target_index, + } => send(BackendCommand::MoveQueueItem { + item_id, + target_index, + }), + UiAction::RemoveQueueItem(item_id) => send(BackendCommand::RemoveQueueItem { item_id }), + UiAction::SearchSimilar(track) => { + if state.frontend.screen != Screen::Similarity { + state + .frontend + .navigation_history + .push(state.frontend.screen.clone()); + state.frontend.navigation_forward.clear(); + } + state.frontend.screen = Screen::Similarity; + send(BackendCommand::SearchSimilar { track }) + } + UiAction::ClearSimilarity => send(BackendCommand::ClearSimilarity), UiAction::PlayContext { tracks, selected } => { send(BackendCommand::PlayContext { tracks, selected }) } @@ -362,6 +400,48 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec { state.backend.settings.clone(), )) } + UiAction::SimilarityEnabledChanged(enabled) => { + state.backend.settings.similarity.enabled = enabled; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::SimilarityModelChanged(model) => { + state.backend.settings.similarity.model = model; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::SimilarityProfileChanged(profile) => { + state.backend.settings.similarity.profile = profile; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::SimilarityWorkersChanged(workers) => { + state.backend.settings.similarity.workers = workers; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::SimilarityMinimumScoreChanged(minimum_score) => { + state.backend.settings.similarity.minimum_score = minimum_score; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::SimilarityMaxTracksPerArtistChanged(max_tracks_per_artist) => { + state.backend.settings.similarity.max_tracks_per_artist = max_tracks_per_artist; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::SimilarityFederationConsentChanged(federation_consent) => { + state.backend.settings.similarity.federation_consent = federation_consent; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } UiAction::LanguageChanged(language) => { state.frontend.locale = Locale::En; state.backend.settings.language = language; @@ -384,15 +464,64 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec { } } -fn find_artist<'a>(state: &'a AppState, key: &ArtistKey) -> Option<&'a furumi_domain::Artist> { - match &state.backend.library { +fn find_artist_name<'a>(state: &'a AppState, key: &ArtistKey) -> Option<&'a str> { + let library = match &state.backend.library { furumi_backend_api::RemoteData::Ready(l) => Some(l), _ => None, + }; + if let Some(artist) = library + .into_iter() + .flat_map(|library| library.artists.iter()) + .chain(state.backend.search.results.artists.iter()) + .find(|artist| &artist.key == key) + { + return Some(artist.name.as_str()); } - .into_iter() - .flat_map(|l| l.artists.iter()) - .chain(state.backend.search.results.artists.iter()) - .find(|a| &a.key == key) + if let Some(name) = library + .into_iter() + .flat_map(|library| library.featured_releases.iter()) + .chain(state.backend.search.results.releases.iter()) + .find_map(|release| artist_name_in_release(release, key)) + { + return Some(name); + } + library + .into_iter() + .flat_map(|library| { + library.recently_played.iter().chain( + library + .playlists + .iter() + .flat_map(|playlist| playlist.tracks.iter()), + ) + }) + .chain(state.backend.search.results.tracks.iter()) + .chain(state.backend.queue.items().iter().map(|item| &item.track)) + .find_map(|track| artist_name_in_track(track, key)) +} + +fn artist_name_in_release<'a>(release: &'a Release, key: &ArtistKey) -> Option<&'a str> { + release + .artists + .iter() + .chain(release.featured_artists.iter()) + .find(|artist| &artist.key == key) + .map(|artist| artist.name.as_str()) + .or_else(|| { + release + .tracks + .iter() + .find_map(|track| artist_name_in_track(track, key)) + }) +} + +fn artist_name_in_track<'a>(track: &'a Track, key: &ArtistKey) -> Option<&'a str> { + track + .artists + .iter() + .chain(track.featured_artists.iter()) + .find(|artist| &artist.key == key) + .map(|artist| artist.name.as_str()) } fn find_release<'a>(state: &'a AppState, key: &ReleaseKey) -> Option<&'a furumi_domain::Release> { @@ -516,6 +645,21 @@ mod tests { )); } + #[test] + fn similarity_action_opens_results_and_starts_the_backend_search() { + let mut state = AppState::default(); + let key = TrackKey::local(furumi_domain::LocalTrackId::new(42)); + + let effects = reduce_action(&mut state, UiAction::SearchSimilar(key.clone())); + + assert_eq!(state.frontend.screen, Screen::Similarity); + assert_eq!(state.frontend.navigation_history, vec![Screen::Home]); + assert_eq!( + effects, + vec![Effect::Send(BackendCommand::SearchSimilar { track: key })] + ); + } + #[test] fn stale_backend_snapshots_are_ignored() { let mut state = AppState::default(); @@ -563,6 +707,44 @@ mod tests { assert_eq!(state.frontend.navigation_history, vec![Screen::Home]); } + #[test] + fn featured_artist_reference_opens_the_standard_artist_detail() { + let mut state = AppState::default(); + let artist_key = ArtistKey::Federation { + peer_id: "peer-a".into(), + id: "guest-artist".into(), + }; + state.backend.search.results.releases = vec![Release { + key: ReleaseKey::Local(ReleaseId::new(7)), + source: CatalogSource::Local, + title: "Release".into(), + artists: Vec::new(), + featured_artists: vec![ArtistRef { + key: artist_key.clone(), + name: "Guest Artist".into(), + }], + release_type: "album".into(), + year: None, + artwork: Artwork::default(), + tracks: Vec::new(), + }]; + + let effects = reduce_action( + &mut state, + UiAction::Navigate(Screen::Artist(artist_key.clone())), + ); + + assert_eq!(state.frontend.screen, Screen::Artist(artist_key.clone())); + assert_eq!( + effects, + vec![Effect::Send(BackendCommand::LoadArtist { + request_id: RequestId::new(1), + key: artist_key, + name: "Guest Artist".into(), + })] + ); + } + #[test] fn release_lookup_keeps_the_preferred_artist_name_and_key_together() { let mut state = AppState::default(); diff --git a/crates/backend-api/src/lib.rs b/crates/backend-api/src/lib.rs index e7b6645..756a33e 100644 --- a/crates/backend-api/src/lib.rs +++ b/crates/backend-api/src/lib.rs @@ -222,7 +222,7 @@ pub struct BuildInfoSnapshot { pub protocols: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct SettingsSnapshot { pub network_id: String, pub device_name: String, @@ -230,6 +230,54 @@ pub struct SettingsSnapshot { pub federation_enabled: bool, pub save_federated_on_listen: bool, pub language: String, + pub similarity: SimilaritySettingsSnapshot, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SimilaritySettingsSnapshot { + pub enabled: bool, + pub model: String, + pub profile: String, + pub workers: usize, + pub minimum_score: f32, + pub max_tracks_per_artist: usize, + pub federation_consent: bool, + pub active_profile: Option, +} + +impl Default for SimilaritySettingsSnapshot { + fn default() -> Self { + Self { + enabled: false, + model: "discogs-effnet-bsdynamic-1".into(), + profile: "furumi-full-track-v1".into(), + workers: std::thread::available_parallelism() + .map_or(1, |count| (count.get() / 2).clamp(1, 4)), + minimum_score: 0.70, + max_tracks_per_artist: 5, + federation_consent: false, + active_profile: None, + } + } +} + +impl SimilaritySettingsSnapshot { + #[must_use] + pub fn normalized(mut self) -> Self { + if self.model.trim().is_empty() { + self.model = "discogs-effnet-bsdynamic-1".into(); + } + if self.profile.trim().is_empty() { + self.profile = "furumi-full-track-v1".into(); + } + self.workers = self.workers.clamp(1, 16); + if !self.minimum_score.is_finite() { + self.minimum_score = 0.70; + } + self.minimum_score = self.minimum_score.clamp(0.0, 1.0); + self.max_tracks_per_artist = self.max_tracks_per_artist.clamp(1, 50); + self + } } impl Default for SettingsSnapshot { @@ -237,14 +285,40 @@ impl Default for SettingsSnapshot { Self { network_id: "furumi".into(), device_name: String::new(), - library_path: "~/Music/Furumi".into(), + // The backend resolves this from the platform's shared Furumi data + // directory before publishing its first authoritative snapshot. + library_path: String::new(), federation_enabled: true, save_federated_on_listen: true, language: "English".into(), + similarity: SimilaritySettingsSnapshot::default(), } } } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SimilarityStatusSnapshot { + pub phase: String, + pub active_profile: Option, + pub target_profile: Option, + pub model: String, + pub total_tracks: usize, + pub completed_tracks: usize, + pub failed_tracks: usize, + pub stored_vectors: usize, + pub stored_bytes: u64, + pub current_track: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SimilaritySearchSnapshot { + pub source_title: String, + pub results: Vec, + pub pending: bool, + pub error: Option, +} + #[derive(Debug, Clone, Default, PartialEq)] pub struct BackendSnapshot { pub revision: u64, @@ -257,6 +331,8 @@ pub struct BackendSnapshot { pub build_info: BuildInfoSnapshot, pub connected_devices: ConnectedDevicesSnapshot, pub settings: SettingsSnapshot, + pub similarity_status: SimilarityStatusSnapshot, + pub similarity_search: SimilaritySearchSnapshot, pub playback_error: Option, pub settings_error: Option, } @@ -286,6 +362,17 @@ pub enum BackendCommand { PlayQueueItem { item_id: QueueItemId, }, + SearchSimilar { + track: TrackKey, + }, + ClearSimilarity, + MoveQueueItem { + item_id: QueueItemId, + target_index: usize, + }, + RemoveQueueItem { + item_id: QueueItemId, + }, PlayContext { tracks: Vec, selected: TrackKey, diff --git a/crates/backend/Cargo.toml b/crates/backend/Cargo.toml index 62de2b6..d95b21c 100644 --- a/crates/backend/Cargo.toml +++ b/crates/backend/Cargo.toml @@ -7,6 +7,8 @@ license.workspace = true [dependencies] anyhow.workspace = true +blake3.workspace = true +futures-util.workspace = true furumi-backend-api.workspace = true furumi-domain.workspace = true furumi-library.workspace = true @@ -14,6 +16,10 @@ music-dht.workspace = true directories.workspace = true rusqlite.workspace = true rodio.workspace = true +reqwest.workspace = true +rustfft.workspace = true +sha2.workspace = true +tract-onnx.workspace = true tokio.workspace = true tokio-util.workspace = true serde_json.workspace = true diff --git a/crates/backend/src/actor_devices.rs b/crates/backend/src/actor_devices.rs index b96351b..d65e684 100644 --- a/crates/backend/src/actor_devices.rs +++ b/crates/backend/src/actor_devices.rs @@ -216,6 +216,7 @@ impl Actor { } urgent_targets.push(previous); } + self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped); self.audio.stop(); self.device_role = DevicePlaybackRole::Control; self.active_device_id.clone_from(&target.id); diff --git a/crates/backend/src/devices.rs b/crates/backend/src/devices.rs index 092c606..07c28e9 100644 --- a/crates/backend/src/devices.rs +++ b/crates/backend/src/devices.rs @@ -12,10 +12,11 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use music_dht::device_sync::{ DEFAULT_INVITE_TTL_MS, DEVICE_SYNC_PROTOCOL_VERSION, DeviceProfileWire, InviteWire, - PlaybackCommand, PlaybackSnapshot, SnapshotLike, SnapshotLikeTombstone, SnapshotPlaylist, - SnapshotPlaylistItem, SnapshotPlaylistItemTombstone, SnapshotPlaylistTombstone, SyncOpPayload, - SyncOpWire, SyncSnapshot, SyncedFedTrack, WireMessage, encode_invite, finish_response, - finish_send, hash_secret, parse_invite, random_hex, read_msg, ticket_endpoint_id, write_msg, + ListenEndReason, ListenEvent, ListenTrackMetadata, PlaybackCommand, PlaybackSnapshot, + SnapshotLike, SnapshotLikeTombstone, SnapshotPlaylist, SnapshotPlaylistItem, + SnapshotPlaylistItemTombstone, SnapshotPlaylistTombstone, SyncOpPayload, SyncOpWire, + SyncSnapshot, SyncedFedTrack, WireMessage, encode_invite, finish_response, finish_send, + hash_secret, parse_invite, random_hex, read_msg, ticket_endpoint_id, write_msg, }; use music_dht::{ByteStream, MusicDhtService, PeerTicket, StreamAcceptor}; use rusqlite::{Connection, OptionalExtension as _, params}; @@ -120,6 +121,10 @@ impl DeviceSync { Ok((identity.device_id, identity.name)) } + pub fn new_listen_id() -> String { + format!("{}-{}", now_ms(), random_hex(12)) + } + pub fn set_device_name(&self, name: &str, endpoint_ticket: Option<&str>) -> Result<()> { let name = if name.trim().is_empty() { "furumi".to_string() @@ -367,6 +372,56 @@ impl DeviceSync { }) } + pub fn record_listen( + &self, + listen_id: String, + track: &furumi_domain::Track, + started_at_ms: i64, + listened_ms: i64, + ended_reason: ListenEndReason, + ) -> Result<()> { + let Some(content_id) = track + .key + .content_id() + .and_then(|id| music_dht::normalize_content_id(id.as_str())) + else { + return Ok(()); + }; + let mut artist_names = track + .artists + .iter() + .map(|artist| artist.name.clone()) + .filter(|artist| !artist.trim().is_empty()) + .collect::>(); + if artist_names.is_empty() && !track.artist.trim().is_empty() { + artist_names.push(track.artist.clone()); + } + let event = ListenEvent { + listen_id, + content_id, + started_at_ms, + listened_ms: listened_ms.max(0), + track_duration_ms: (track.duration_seconds > 0.0).then_some( + crate::support::seconds_to_milliseconds(track.duration_seconds), + ), + ended_reason, + track: ListenTrackMetadata { + title: track.title.clone(), + artist_names, + featured_artist_names: track + .featured_artists + .iter() + .map(|artist| artist.name.clone()) + .collect(), + release_title: (!track.release.trim().is_empty()).then(|| track.release.clone()), + }, + }; + if event.should_record() { + self.record_op(SyncOpPayload::ListenRecorded { event })?; + } + Ok(()) + } + pub fn record_playlist_created(&self, id: i64, title: &str) -> Result<()> { let playlist_id = self.library.ensure_playlist_sync_id(id)?; self.record_op(SyncOpPayload::PlaylistCreated { @@ -698,6 +753,8 @@ async fn handle_pair_request( finish_response(&mut stream, RESPONSE_DRAIN).await?; return Ok(()); } + let requester_group_id = requester_group_id.filter(|group| !group.trim().is_empty()); + let requester_group_active_devices = requester_group_active_devices.max(1); let devices_json = serde_json::to_string(&requester_devices)?; lock(&sync.conn).execute( "INSERT OR IGNORE INTO sync_pending_pairing @@ -1016,6 +1073,15 @@ fn pair_request_id(invite_id: &str, device_id: &str) -> String { format!("pair_{}", &digest[..16]) } +fn requester_group_conflict( + local_group_id: &str, + requester_group_id: Option<&str>, + requester_group_active_devices: usize, +) -> bool { + requester_group_id.is_some_and(|group| !group.trim().is_empty() && group != local_group_id) + && requester_group_active_devices > 1 +} + fn valid_pair_request( sync: &DeviceSync, invite_id: &str, @@ -1115,6 +1181,150 @@ enum PairAttempt { mod tests { use super::*; + static NEXT_TEST_DB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + + fn with_test_sync(test: impl FnOnce(&DeviceSync)) { + let conn = Connection::open_in_memory().unwrap(); + init_schema(&conn).unwrap(); + let unique = NEXT_TEST_DB.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let library_path = std::env::temp_dir().join(format!( + "furumi-desktop-devices-test-{}-{}-{}.sqlite3", + std::process::id(), + now_ms(), + unique + )); + let library = Arc::new(furumi_library::Library::open(&library_path).unwrap()); + let (events, _event_rx) = tokio::sync::mpsc::channel(4); + let sync = DeviceSync { + conn: Arc::new(Mutex::new(conn)), + library, + events, + playback: Arc::new(Mutex::new(PlaybackStore::default())), + sync_requested: Arc::new(tokio::sync::Notify::new()), + }; + sync.ensure_identity().unwrap(); + + test(&sync); + + drop(sync); + for suffix in ["", "-wal", "-shm"] { + let mut path = library_path.as_os_str().to_os_string(); + path.push(suffix); + let _ = std::fs::remove_file(path); + } + } + + fn test_profile(device_id: &str) -> DeviceProfileWire { + DeviceProfileWire { + device_id: device_id.into(), + name: device_id.into(), + client_version: CLIENT_VERSION.into(), + protocol_version: DEVICE_SYNC_PROTOCOL_VERSION, + endpoint_id: String::new(), + endpoint_ticket: String::new(), + revoked: false, + revoke_cutoff_seq: None, + updated_at_ms: now_ms(), + } + } + + #[test] + fn locally_finished_listen_enters_the_shared_history() { + with_test_sync(|sync| { + let content_id = + furumi_domain::ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap(); + let track = furumi_domain::Track { + key: furumi_domain::TrackKey::remote(content_id.clone()), + title: "Shared listen".into(), + artist: "Artist".into(), + artists: vec![furumi_domain::ArtistRef { + key: furumi_domain::ArtistKey::Federation { + peer_id: "peer".into(), + id: "artist".into(), + }, + name: "Artist".into(), + }], + featured_artists: Vec::new(), + release: "Release".into(), + release_id: furumi_domain::ReleaseKey::Federation { + peer_id: "peer".into(), + id: "release".into(), + }, + duration_seconds: 180.0, + track_number: Some(1), + disc_number: Some(1), + cover_uri: None, + audio_format: None, + audio_bitrate_kbps: None, + audio_sample_rate_hz: None, + audio_bit_depth: None, + file_size_bytes: None, + liked: false, + audio_source: furumi_domain::AudioSource::Federation { + peer_id: "peer".into(), + content_id, + }, + }; + + sync.record_listen( + "desktop-listen".into(), + &track, + now_ms(), + 180_000, + ListenEndReason::Finished, + ) + .unwrap(); + + let history = sync.library.listen_history(10).unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].listen_id, "desktop-listen"); + assert_eq!(history[0].title, "Shared listen"); + }); + } + + fn insert_pending_pairing(sync: &DeviceSync, requester_group_devices: &[DeviceProfileWire]) { + lock(&sync.conn) + .execute( + "INSERT INTO sync_pending_pairing + (request_id, device_id, name, client_version, endpoint_id, + endpoint_ticket, invite_id, created_at_ms, status, + requester_group_id, requester_group_active_devices, + requester_group_devices_json) + VALUES ('request', 'dev_requester', 'Requester', ?1, '', '', + 'invite', ?2, 'pending', 'grp_remote', 2, ?3)", + params![ + CLIENT_VERSION, + now_ms(), + serde_json::to_string(requester_group_devices).unwrap() + ], + ) + .unwrap(); + } + + fn device_known(sync: &DeviceSync, device_id: &str) -> bool { + lock(&sync.conn) + .query_row( + "SELECT 1 FROM sync_devices WHERE device_id = ?1", + [device_id], + |_| Ok(()), + ) + .optional() + .unwrap() + .is_some() + } + + fn device_trusted(sync: &DeviceSync, device_id: &str) -> bool { + lock(&sync.conn) + .query_row( + "SELECT trusted_at_ms IS NOT NULL FROM sync_devices WHERE device_id = ?1", + [device_id], + |row| row.get::<_, bool>(0), + ) + .optional() + .unwrap() + .unwrap_or(false) + } + #[test] fn pairing_request_ids_are_stable_and_scoped_to_the_device() { assert_eq!( @@ -1126,4 +1336,43 @@ mod tests { pair_request_id("invite", "device-b") ); } + + #[test] + fn group_choice_is_only_required_for_an_existing_different_group() { + assert!(requester_group_conflict("grp_local", Some("grp_remote"), 2)); + assert!(!requester_group_conflict( + "grp_local", + Some("grp_remote"), + 1 + )); + assert!(!requester_group_conflict("grp_local", Some("grp_local"), 3)); + assert!(!requester_group_conflict("grp_local", None, 3)); + assert!(!requester_group_conflict("grp_local", Some(" "), 3)); + } + + #[test] + fn pairing_choice_either_joins_the_requester_group_or_keeps_the_local_group() { + let requester_peer = test_profile("dev_requester_peer"); + + with_test_sync(|sync| { + let local_group = sync.ensure_identity().unwrap().group_id; + insert_pending_pairing(sync, std::slice::from_ref(&requester_peer)); + + sync.answer_pairing("request", true, false).unwrap(); + + assert_eq!(sync.ensure_identity().unwrap().group_id, local_group); + assert!(device_trusted(sync, "dev_requester")); + assert!(!device_known(sync, "dev_requester_peer")); + }); + + with_test_sync(|sync| { + insert_pending_pairing(sync, std::slice::from_ref(&requester_peer)); + + sync.answer_pairing("request", true, true).unwrap(); + + assert_eq!(sync.ensure_identity().unwrap().group_id, "grp_remote"); + assert!(device_trusted(sync, "dev_requester")); + assert!(device_known(sync, "dev_requester_peer")); + }); + } } diff --git a/crates/backend/src/devices/store.rs b/crates/backend/src/devices/store.rs index ccc4630..0359622 100644 --- a/crates/backend/src/devices/store.rs +++ b/crates/backend/src/devices/store.rs @@ -108,6 +108,7 @@ impl DeviceSync { .collect::>>()? }; let pending = { + let local_group_id = identity.group_id.as_str(); let mut stmt = conn.prepare( "SELECT request_id, device_id, name, client_version, requester_group_id, requester_group_active_devices @@ -115,14 +116,25 @@ impl DeviceSync { ORDER BY created_at_ms", )?; stmt.query_map([], |row| { + let requester_group_id = row.get::<_, Option>(4)?; + let requester_group_active_devices = + usize::try_from(row.get::<_, i64>(5)?.max(0)).unwrap_or(usize::MAX); + let group_conflict = requester_group_conflict( + local_group_id, + requester_group_id.as_deref(), + requester_group_active_devices, + ); Ok(PendingPairing { request_id: row.get(0)?, device_id: row.get(1)?, name: row.get(2)?, client_version: row.get(3)?, - requester_group_id: row.get(4)?, - requester_group_active_devices: usize::try_from(row.get::<_, i64>(5)?.max(0)) - .unwrap_or(usize::MAX), + requester_group_id: group_conflict.then_some(requester_group_id).flatten(), + requester_group_active_devices: if group_conflict { + requester_group_active_devices + } else { + 0 + }, }) })? .collect::>>()? @@ -480,8 +492,12 @@ impl DeviceSync { command, } => self.apply_playback_command(target_device_id, command, &op.op_id)?, SyncOpPayload::ListenRecorded { event } => { - self.library - .apply_listen_event(event, &op.origin_device_id)?; + if self + .library + .apply_listen_event(event, &op.origin_device_id)? + { + self.notify_library(); + } } } Ok(()) diff --git a/crates/backend/src/federation.rs b/crates/backend/src/federation.rs index f7805cb..62bd7cd 100644 --- a/crates/backend/src/federation.rs +++ b/crates/backend/src/federation.rs @@ -14,6 +14,8 @@ use furumi_domain::{ use music_dht::catalog::{ CATALOG_ALPN, CatalogArtist, CatalogImageHeader, CatalogRequest, CatalogResponse, }; +use music_dht::similarity_dht::SimilarityDht; +use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN; use music_dht::{ EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId, RendezvousConfig, @@ -82,29 +84,60 @@ const IMAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); pub struct Client { service: Arc, + similarity_dht: Arc, media_dir: PathBuf, } impl Client { - pub async fn start(data_dir: PathBuf, media_dir: PathBuf, network: &str) -> Result> { + pub async fn start( + data_dir: PathBuf, + media_dir: PathBuf, + network: &str, + similarity: Arc, + ) -> Result> { tokio::fs::create_dir_all(&data_dir).await?; tokio::fs::create_dir_all(&media_dir).await?; + let similarity_routing_path = data_dir.join("similarity-routing.sqlite3"); let config = MusicDhtConfig::builder() - .data_dir(data_dir) + .data_dir(&data_dir) .network_id(NetworkId::from_name(network)) .rendezvous(RendezvousConfig::default()) .stream_protocol(CATALOG_ALPN) .stream_protocol(AUDIO_ALPN) .stream_protocol(music_dht::device_sync::SYNC_ALPN_V1) .stream_protocol(music_dht::device_sync::SYNC_ALPN_V2) + .schema_independent_stream_protocol(crate::federation_similarity::SIMILARITY_ALPN) + .schema_independent_stream_protocol(SIMILARITY_DHT_ALPN) .build() .context("invalid federation configuration")?; let (service, mut events) = MusicDhtService::start(config) .await .context("starting federation node")?; tokio::spawn(async move { while events.recv().await.is_some() {} }); + let service = Arc::new(service); + let similarity_dht = SimilarityDht::open(Arc::clone(&service), similarity_routing_path) + .await + .context("starting similarity routing overlay")?; + let routing_acceptor = service + .stream_acceptor(SIMILARITY_DHT_ALPN) + .context("starting similarity routing listener")?; + tokio::spawn(Arc::clone(&similarity_dht).serve(routing_acceptor)); + tokio::spawn(Arc::clone(&similarity_dht).maintenance()); + tokio::spawn(crate::federation_similarity::sync_routes( + Arc::clone(&similarity_dht), + Arc::clone(&similarity), + )); + let similarity_acceptor = service + .stream_acceptor(crate::federation_similarity::SIMILARITY_ALPN) + .context("starting similarity listener")?; + tokio::spawn(crate::federation_similarity::serve( + similarity_acceptor, + similarity, + service.endpoint_id(), + )); Ok(Arc::new(Self { - service: Arc::new(service), + service, + similarity_dht, media_dir, })) } @@ -171,6 +204,33 @@ impl Client { Ok((results, stats)) } + pub async fn search_similar( + &self, + query: crate::similarity::QueryVector, + limit: usize, + minimum_score: f32, + max_tracks_per_artist: usize, + ) -> Result> { + let mut hits = crate::federation_similarity::search( + Arc::clone(&self.service), + Arc::clone(&self.similarity_dht), + query, + limit, + minimum_score, + max_tracks_per_artist, + ) + .await?; + let mut results = SearchResults { + tracks: hits.iter().map(|hit| hit.track.clone()).collect(), + ..SearchResults::default() + }; + self.fetch_artwork(&mut results).await; + for (hit, with_artwork) in hits.iter_mut().zip(results.tracks) { + hit.track = with_artwork; + } + Ok(hits) + } + /// Resolves a portable queue entry when another connected device only /// knows its stable audio content id. pub async fn track_by_content_id(&self, content_id: &str) -> Result { @@ -181,11 +241,17 @@ impl Client { .into_iter() .chain(outcome.network_results) .collect::>(); - convert_items(&items, own) + let mut track = convert_items(&items, own) .tracks .into_iter() .next() - .context("no federation peer currently publishes this track") + .context("no federation peer currently publishes this track")?; + if track.cover_uri.is_none() + && let Some(path) = self.artwork_for_track(&track).await + { + track.cover_uri = Some(path.to_string_lossy().into_owned()); + } + Ok(track) } pub async fn publish(&self, specs: Vec) -> Result<()> { diff --git a/crates/backend/src/federation_similarity.rs b/crates/backend/src/federation_similarity.rs new file mode 100644 index 0000000..1aa4263 --- /dev/null +++ b/crates/backend/src/federation_similarity.rs @@ -0,0 +1,378 @@ +//! Local-index adapter and DHT-routed peer client for Furumi similarity. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use furumi_domain::{ArtistKey, ArtistRef, AudioSource, ContentId, ReleaseKey, Track, TrackKey}; +use futures_util::stream::{self, StreamExt as _}; +use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse}; +use music_dht::similarity_dht::SimilarityDht; +use music_dht::{EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor}; + +use crate::similarity::{Manager, QueryVector}; + +pub use music_dht::similarity::SIMILARITY_ALPN; + +const MAX_QUERY_PEERS: usize = 48; +const QUERY_CONCURRENCY: usize = 8; +const QUERY_TIMEOUT: Duration = Duration::from_secs(5); +const ROUTING_TIMEOUT: Duration = Duration::from_secs(5); +const ROUTE_SYNC_INTERVAL: Duration = Duration::from_secs(30); +const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8; + +pub struct ScoredTrack { + pub track: Track, + pub score: f32, + pub embedding_signature: Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>, +} + +pub async fn serve(mut acceptor: StreamAcceptor, manager: Arc, own: EndpointId) { + while let Some(stream) = acceptor.accept().await { + let manager = Arc::clone(&manager); + tokio::spawn(async move { + let _ = serve_one(stream, manager, own).await; + }); + } +} + +async fn serve_one( + mut stream: music_dht::ByteStream, + manager: Arc, + own: EndpointId, +) -> Result<()> { + let request = wire::read_request(&mut stream).await?; + let response = if manager.network_allowed() { + let matches = tokio::task::spawn_blocking(move || { + manager.search_vector_for_peer(&request.profile_id, &request.vector, request.limit) + }) + .await + .context("local similarity task failed") + .and_then(|result| result); + match matches { + Ok(matches) => SimilarityResponse::success( + matches + .into_iter() + .filter_map(|found| { + let track = found.track; + let hit = SimilarityHit { + score: found.score, + item_id: hex_encode( + ItemId::derive( + &own, + ItemKind::Track, + &format!("track:{}", track.id), + ) + .as_bytes(), + ), + title: track.title, + artist_names: track + .artists + .into_iter() + .map(|artist| artist.name) + .collect(), + featured_artist_names: track + .featured_artists + .into_iter() + .map(|artist| artist.name) + .collect(), + year: track.release_year, + duration_seconds: Some( + crate::support::seconds_to_milliseconds(track.duration_seconds) + / 1_000, + ), + content_id: track.content_id, + release_title: Some(track.release_title), + track_number: track.track_number, + disc_number: track.disc_number, + embedding_signature: Some(found.embedding_signature), + }; + hit.validate().is_ok().then_some(hit) + }) + .collect(), + )?, + Err(error) => { + SimilarityResponse::refused(format!("similarity query is unavailable: {error:#}"))? + } + } + } else { + SimilarityResponse::refused("similarity federation is disabled or has no privacy consent")? + }; + wire::write_response(&mut stream, &response).await?; + stream.send.finish()?; + let _ = stream.send.stopped().await; + Ok(()) +} + +#[allow( + clippy::too_many_lines, + reason = "bounded peer fan-out and ranking policy" +)] +pub async fn search( + service: Arc, + routing: Arc, + query: QueryVector, + limit: usize, + minimum_score: f32, + max_tracks_per_artist: usize, +) -> Result> { + let own = service.endpoint_id(); + let routed = tokio::time::timeout( + ROUTING_TIMEOUT, + routing.find_peers(&query.profile_id, &query.vector, MAX_QUERY_PEERS), + ) + .await + .ok() + .and_then(Result::ok) + .unwrap_or_default(); + let mut seen = HashSet::new(); + let mut peers = routed + .into_iter() + .filter_map(|ticket| { + let owner = ticket.endpoint_id(); + (owner != own && seen.insert(owner)).then_some(QueryPeer { + owner, + ticket: Some(ticket), + }) + }) + .collect::>(); + for owner in service + .connected_peers() + .into_iter() + .chain(service.known_peers().into_iter().map(|peer| peer.peer_id)) + { + if owner != own && seen.insert(owner) { + peers.push(QueryPeer { + owner, + ticket: None, + }); + } + if peers.len() >= MAX_QUERY_PEERS { + break; + } + } + let query_signature = wire::embedding_signature(&query.vector)?; + let request = Arc::new(SimilarityRequest::new( + query.profile_id, + query.vector, + limit.clamp(1, wire::MAX_SIMILARITY_RESULTS), + )?); + let responses = stream::iter(peers.into_iter().map(|peer| { + let service = Arc::clone(&service); + let request = Arc::clone(&request); + async move { + tokio::time::timeout(QUERY_TIMEOUT, query_peer(service, peer, &request)) + .await + .map_err(|_| anyhow::anyhow!("similarity peer timed out"))? + } + })) + .buffer_unordered(QUERY_CONCURRENCY) + .collect::>() + .await; + + let mut hits = responses + .into_iter() + .filter_map(Result::ok) + .flatten() + .collect::>(); + hits.sort_by(|left, right| right.1.total_cmp(&left.1)); + let mut dedup = HashSet::new(); + let mut signatures = vec![query_signature]; + let mut artist_counts = HashMap::::new(); + let mut results = Vec::new(); + for (track, score, signature) in hits { + if score < minimum_score { + break; + } + if query.source_content_id.as_deref().is_some_and(|source| { + track + .key + .content_id() + .is_some_and(|id| id.as_str() == source) + }) { + continue; + } + let identity = track + .key + .content_id() + .map_or_else(|| format!("{:?}", track.key), |id| id.as_str().to_owned()); + if !dedup.insert(identity) { + continue; + } + if signature.is_some_and(|candidate| { + signatures.iter().any(|existing| { + wire::signature_distance(&candidate, existing) + <= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE + }) + }) { + continue; + } + let artist = track + .artists + .first() + .map(|artist| music_dht::normalize_name(&artist.name)) + .unwrap_or_default(); + let count = artist_counts.entry(artist.clone()).or_default(); + if !artist.is_empty() && *count >= max_tracks_per_artist { + continue; + } + *count += 1; + if let Some(signature) = signature { + signatures.push(signature); + } + results.push(ScoredTrack { + track, + score, + embedding_signature: signature, + }); + if results.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) { + break; + } + } + Ok(results) +} + +type PeerHits = Vec<(Track, f32, Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>)>; + +#[derive(Clone)] +struct QueryPeer { + owner: EndpointId, + ticket: Option, +} + +async fn query_peer( + service: Arc, + peer: QueryPeer, + request: &SimilarityRequest, +) -> Result { + let owner = peer.owner; + let mut stream = match peer.ticket { + Some(ticket) => service.open_stream_to(&ticket, SIMILARITY_ALPN).await, + None => service.open_stream(owner, SIMILARITY_ALPN).await, + }?; + let response = wire::exchange(&mut stream, request).await?; + anyhow::ensure!( + response.ok, + "peer refused similarity query: {}", + response.error.unwrap_or_default() + ); + Ok(response + .hits + .into_iter() + .filter_map(|hit| hit_to_track(owner, hit)) + .collect()) +} + +/// Keeps the routing overlay synchronized with the active durable index. +pub async fn sync_routes(routing: Arc, manager: Arc) { + let mut interval = tokio::time::interval(ROUTE_SYNC_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut published_marker: Option<(String, blake3::Hash)> = None; + loop { + interval.tick().await; + let manager = Arc::clone(&manager); + let loaded = tokio::task::spawn_blocking(move || manager.routing_signatures()).await; + let Ok(Ok(snapshot)) = loaded else { + continue; + }; + let Some((profile_id, signatures)) = snapshot else { + if published_marker.take().is_some() { + routing.clear_local_signatures(); + } + continue; + }; + let mut hasher = blake3::Hasher::new(); + for signature in &signatures { + hasher.update(signature); + } + let marker = (profile_id.clone(), hasher.finalize()); + if published_marker.as_ref() == Some(&marker) { + continue; + } + if routing + .sync_local_signatures(profile_id, signatures) + .await + .is_ok() + { + published_marker = Some(marker); + } + } +} + +fn hit_to_track( + owner: EndpointId, + hit: SimilarityHit, +) -> Option<(Track, f32, Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>)> { + let peer_id = owner.to_string(); + let content_id = hit + .content_id + .as_deref() + .and_then(|id| ContentId::parse(id).ok())?; + let refs = |names: Vec| { + names + .into_iter() + .map(|name| ArtistRef { + key: ArtistKey::Federation { + peer_id: peer_id.clone(), + id: music_dht::normalize_name(&name), + }, + name, + }) + .collect::>() + }; + let artists = refs(hit.artist_names); + let featured_artists = refs(hit.featured_artist_names); + let artist = artists + .iter() + .map(|artist| artist.name.as_str()) + .chain(featured_artists.iter().map(|artist| artist.name.as_str())) + .collect::>() + .join(", "); + let release = hit.release_title.unwrap_or_default(); + let score = hit.score; + let signature = hit.embedding_signature; + Some(( + Track { + key: TrackKey::federation(peer_id.clone(), hit.item_id, Some(content_id.clone())), + title: hit.title, + artist, + artists, + featured_artists, + release: release.clone(), + release_id: ReleaseKey::Federation { + peer_id: peer_id.clone(), + id: format!("name:{}", music_dht::normalize_name(&release)), + }, + duration_seconds: hit + .duration_seconds + .and_then(|value| u32::try_from(value).ok()) + .map_or(0.0, f64::from), + track_number: hit.track_number.and_then(|value| u32::try_from(value).ok()), + disc_number: hit.disc_number.and_then(|value| u32::try_from(value).ok()), + cover_uri: None, + audio_format: None, + audio_bitrate_kbps: None, + audio_sample_rate_hz: None, + audio_bit_depth: None, + file_size_bytes: None, + liked: false, + audio_source: AudioSource::Federation { + peer_id, + content_id, + }, + }, + score, + signature, + )) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output +} diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 48ef57f..021fb40 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -13,7 +13,7 @@ use furumi_backend_api::{ FederationActivitySnapshot, FederationDebugSnapshot, FederationOperation, LibrarySnapshot, PendingPairingSnapshot, PlaybackRepeat, PlaybackStatus, PlaylistSnapshot, RemoteData, RequestId, SearchResults, SearchSnapshot, SearchStats, SendCommandError, SettingsSnapshot, - VersionEntrySnapshot, + SimilarityStatusSnapshot, VersionEntrySnapshot, }; use furumi_domain::{ Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId, @@ -26,18 +26,20 @@ mod actor_devices; mod audio; mod devices; mod federation; +mod federation_similarity; mod settings; +mod similarity; mod streaming; mod support; use support::{ - apply_federated_metadata, expand_tilde, extrapolated_control_position, federation_specs, - find_catalog_track, library_snapshot, library_track, local_search_results, + apply_federated_metadata, extrapolated_control_position, federated_audio_directory, + federation_specs, find_catalog_track, library_snapshot, library_track, local_search_results, merge_release_preserving_local, merge_search_results, normalize_device_name, playback_state_acknowledges_command, portable_playback_placeholder, - remote_snapshot_has_authority, runtime_build_info, sanitize_filename, selected_track_position, - spawn_settings_worker, track_is_liked, track_to_library_fed, track_to_playback_track, - track_to_synced_fed, unix_time_ms, volume_percent, + remote_snapshot_has_authority, runtime_build_info, sanitize_filename, seconds_to_milliseconds, + selected_track_position, spawn_settings_worker, track_is_liked, track_to_library_fed, + track_to_playback_track, track_to_synced_fed, unix_time_ms, volume_percent, }; use settings::SettingsStore; @@ -46,6 +48,14 @@ const COMMAND_CAPACITY: usize = 64; const INTERNAL_CAPACITY: usize = 32; const CONTROL_COMMAND_ACK_TIMEOUT: Duration = Duration::from_secs(12); const CONTROL_POSITION_ACK_TOLERANCE_SECONDS: f64 = 3.0; +const DESKTOP_APPLICATION_ID: &str = "furumi-desktop"; +const SIMILARITY_RESULT_LIMIT: usize = 50; + +type SimilarityCandidate = ( + Track, + f32, + Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]>, +); #[derive(Clone)] pub struct BackendHandle { @@ -81,20 +91,26 @@ impl BackendHandle { /// /// Returns an I/O error when the runtime or its owner thread cannot be created. pub fn spawn_backend() -> Result { - let project_dirs = directories::ProjectDirs::from("cy", "hexor", "Furumi") + // The music library deliberately keeps using furumi_library::default_db_path() + // below. Everything else belongs to this client and must not reuse the TUI's + // device identity, settings, federation node, or caches. + let project_dirs = directories::ProjectDirs::from("cy", "hexor", DESKTOP_APPLICATION_ID) .ok_or(BackendStartupError::DataDirectoryUnavailable)?; let settings_path = project_dirs.data_local_dir().join("furumi-desktop.sqlite3"); let federation_data_dir = project_dirs.data_local_dir().join("federation"); let federation_media_dir = project_dirs.cache_dir().join("federation-media"); + let similarity_model_dir = project_dirs.cache_dir().join("similarity-models"); let devices_db_path = project_dirs .data_local_dir() .join("devices") .join("sync.sqlite3"); - let settings_store = SettingsStore::open(&settings_path)?; + let library_db_path = + furumi_library::default_db_path().map_err(BackendStartupError::Library)?; + let default_library_path = library_db_path.with_file_name("federation-media"); + let settings_store = SettingsStore::open(&settings_path, &default_library_path)?; let loaded_settings = settings_store.load()?; - let library_path = furumi_library::default_db_path().map_err(BackendStartupError::Library)?; let catalog = std::sync::Arc::new( - furumi_library::Library::open(&library_path).map_err(BackendStartupError::Library)?, + furumi_library::Library::open(&library_db_path).map_err(BackendStartupError::Library)?, ); let loaded_library = library_snapshot(&catalog).map_err(BackendStartupError::Library)?; let runtime = tokio::runtime::Builder::new_multi_thread() @@ -132,6 +148,7 @@ pub fn spawn_backend() -> Result { audio, federation_data_dir, federation_media_dir, + similarity_model_dir, devices, })); })?; @@ -218,6 +235,10 @@ enum InternalEvent { keys: Vec, cover_uri: Option, }, + HistoryTrackResolved { + content_id: String, + result: Result, + }, FederationDebugUpdated(FederationDebugSnapshot), DevicesChanged, DeviceLibraryChanged, @@ -226,6 +247,12 @@ enum InternalEvent { DeviceOperationFinished(Result), DevicePlaybackSnapshot(music_dht::device_sync::PlaybackSnapshot), DevicePlaybackCommand(music_dht::device_sync::PlaybackCommand), + SimilarityStatus(similarity::SimilarityStatus), + SimilarityProfileActivated(Option), + SimilaritySearchFinished { + source_title: String, + result: Result, String>, + }, } enum DeviceOperationResult { @@ -246,8 +273,10 @@ struct Actor { federation: Option>, federation_data_dir: std::path::PathBuf, federation_media_dir: std::path::PathBuf, + similarity: std::sync::Arc, ephemeral_audio: Option<(TrackKey, std::path::PathBuf)>, pending_queue_artwork: HashSet, + pending_history_resolutions: HashSet, federation_debug_pending: bool, devices: std::sync::Arc, device_service: Option>, @@ -256,6 +285,14 @@ struct Actor { active_device_name: String, control_anchor: Option, pending_control: Option, + listen_session: Option, +} + +#[derive(Debug, Clone)] +struct ListenSession { + id: String, + track: Track, + started_at_ms: i64, } #[derive(Debug, Clone)] @@ -285,6 +322,7 @@ struct ActorBootstrap { audio: audio::Controller, federation_data_dir: std::path::PathBuf, federation_media_dir: std::path::PathBuf, + similarity_model_dir: std::path::PathBuf, devices: std::sync::Arc, } @@ -307,6 +345,7 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime { audio, federation_data_dir, federation_media_dir, + similarity_model_dir, devices, } = bootstrap; let (settings_tx, settings_rx) = std_mpsc::channel(); @@ -336,6 +375,12 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime { settings_error, ..BackendSnapshot::default() }; + let similarity = similarity::Manager::new( + std::sync::Arc::clone(&catalog), + internal_tx.clone(), + initial_state.settings.similarity.clone(), + similarity_model_dir, + ); let mut actor = Actor { state: initial_state, library, @@ -349,8 +394,10 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime { federation: None, federation_data_dir, federation_media_dir, + similarity, ephemeral_audio: None, pending_queue_artwork: HashSet::new(), + pending_history_resolutions: HashSet::new(), federation_debug_pending: false, devices, device_service: None, @@ -359,7 +406,12 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime { active_device_name, control_anchor: None, pending_control: None, + listen_session: None, }; + actor.state.similarity_status = similarity_status_snapshot(&actor.similarity.status()); + if actor.state.settings.similarity.enabled { + actor.similarity.start(); + } actor.refresh_connected_devices(); ActorRuntime { actor, @@ -368,6 +420,22 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime { } } +fn similarity_status_snapshot(status: &similarity::SimilarityStatus) -> SimilarityStatusSnapshot { + SimilarityStatusSnapshot { + phase: status.phase.label().into(), + active_profile: status.active_profile.clone(), + target_profile: status.target_profile.clone(), + model: status.model.clone(), + total_tracks: status.total_tracks, + completed_tracks: status.completed_tracks, + failed_tracks: status.failed_tracks, + stored_vectors: status.stored_vectors, + stored_bytes: status.stored_bytes, + current_track: status.current_track.clone(), + error: status.last_error.clone(), + } +} + async fn run_actor(bootstrap: ActorBootstrap) { let ActorRuntime { mut actor, @@ -400,6 +468,7 @@ async fn run_actor(bootstrap: ActorBootstrap) { if let Some((_, token)) = actor.active_search.take() { token.cancel(); } + actor.finish_listen(music_dht::device_sync::ListenEndReason::Stopped); actor.audio.stop(); } @@ -416,6 +485,7 @@ impl Actor { let data_dir = self.federation_data_dir.clone(); let media_dir = self.federation_media_dir.clone(); let network = self.state.settings.network_id.trim().to_owned(); + let similarity = std::sync::Arc::clone(&self.similarity); let internal = self.internal.clone(); self.state.federation_activity = FederationActivitySnapshot { operation: FederationOperation::Idle, @@ -425,7 +495,7 @@ impl Actor { }; self.publish(); tokio::spawn(async move { - let result = federation::Client::start(data_dir, media_dir, &network) + let result = federation::Client::start(data_dir, media_dir, &network, similarity) .await .map_err(|error| format!("federation: {error:#}")); let _ = internal @@ -659,6 +729,7 @@ impl Actor { } } BackendCommand::Stop => { + self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped); if self.device_role == DevicePlaybackRole::Active { self.audio.stop(); } @@ -725,6 +796,36 @@ impl Actor { self.play_current(); } } + BackendCommand::MoveQueueItem { + item_id, + target_index, + } => { + if self.state.queue.move_item(item_id, target_index) { + self.send_control_state(false); + self.publish(); + } + } + BackendCommand::RemoveQueueItem { item_id } => { + if let Some(removed_current) = self.state.queue.remove_item(item_id) { + if removed_current { + self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped); + self.remove_ephemeral_audio(); + if self.state.queue.current().is_some() { + self.play_current(); + } else { + self.audio.stop(); + self.state.playback.status = PlaybackStatus::Stopped; + self.state.playback.position_seconds = 0.0; + self.state.playback.duration_seconds = 0.0; + self.send_control_state(true); + self.publish(); + } + } else { + self.send_control_state(false); + self.publish(); + } + } + } BackendCommand::PlayContext { tracks, selected } => { let tracks = self.resolve_tracks(&tracks); if !tracks.is_empty() { @@ -845,6 +946,8 @@ impl Actor { } } BackendCommand::Search { request_id, query } => self.start_search(request_id, query), + BackendCommand::SearchSimilar { track } => self.start_similarity_search(&track), + BackendCommand::ClearSimilarity => self.similarity.clear(), BackendCommand::CancelSearch { request_id } => self.cancel_search(request_id), BackendCommand::LoadArtist { request_id, @@ -857,11 +960,13 @@ impl Actor { artist_key, artist_name, } => self.load_detail(request_id, artist_key, artist_name, Some(key)), - BackendCommand::UpdateSettings(settings) => { + BackendCommand::UpdateSettings(mut settings) => { + settings.similarity = settings.similarity.normalized(); let federation_changed = self.state.settings.federation_enabled != settings.federation_enabled || self.state.settings.network_id != settings.network_id; let device_name_changed = self.state.settings.device_name != settings.device_name; + let similarity_changed = self.state.settings.similarity != settings.similarity; let pending_device_name = settings.device_name.clone(); self.state.settings = settings.clone(); self.state.settings_error = None; @@ -878,6 +983,9 @@ impl Actor { self.apply_local_device_name(&pending_device_name); self.schedule_device_name_publish(pending_device_name); } + if similarity_changed { + self.similarity.apply(&self.state.settings.similarity); + } } BackendCommand::Shutdown => {} } @@ -1147,6 +1255,7 @@ impl Actor { }); } self.federation = Some(client); + self.resolve_history_tracks(); self.state.federation_activity.pending = false; self.resolve_queue_artwork(); self.refresh_federation_debug(); @@ -1223,6 +1332,7 @@ impl Actor { .current() .is_some_and(|item| item.track.key.matches(&key)) { + self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped); self.state.playback.status = PlaybackStatus::Stopped; self.state.playback_error = Some(message); self.publish(); @@ -1270,6 +1380,7 @@ impl Actor { } } Err(message) => { + self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped); self.state.playback.status = PlaybackStatus::Stopped; self.state.playback_error = Some(message); self.publish(); @@ -1299,6 +1410,27 @@ impl Actor { self.publish(); } } + InternalEvent::HistoryTrackResolved { content_id, result } => { + self.pending_history_resolutions.remove(&content_id); + if let Ok(mut resolved) = result { + let mut changed = false; + for track in &mut self.library.recently_played { + if track + .key + .content_id() + .is_some_and(|id| id.as_str() == content_id) + { + resolved.liked = track.liked; + *track = resolved.clone(); + changed = true; + } + } + if changed { + self.state.library = RemoteData::Ready(self.library.clone()); + self.publish(); + } + } + } InternalEvent::FederationDebugUpdated(debug) => { self.federation_debug_pending = false; if self.state.federation_debug != debug { @@ -1311,6 +1443,8 @@ impl Actor { self.library = library.clone(); self.state.library = RemoteData::Ready(library); self.reconcile_likes(); + self.similarity.start(); + self.resolve_history_tracks(); } self.refresh_connected_devices(); self.publish(); @@ -1338,9 +1472,129 @@ impl Actor { InternalEvent::DevicePlaybackCommand(command) => { self.apply_device_playback_command(command); } + InternalEvent::SimilarityStatus(status) => { + self.state.similarity_status = similarity_status_snapshot(&status); + self.publish(); + } + InternalEvent::SimilarityProfileActivated(active_profile) => { + self.state.settings.similarity.active_profile = active_profile; + let _ = self.settings.send(self.state.settings.clone()); + self.publish(); + } + InternalEvent::SimilaritySearchFinished { + source_title, + result, + } => { + self.state.similarity_search.pending = false; + self.state.similarity_search.source_title = source_title; + match result { + Ok(mut tracks) => { + let liked = self + .catalog + .liked_content_ids() + .unwrap_or_default() + .into_iter() + .chain(self.catalog.fed_like_ids().unwrap_or_default()) + .collect::>(); + for track in &mut tracks { + track.liked = track_is_liked(track, &liked); + } + self.state.similarity_search.results = tracks; + self.state.similarity_search.error = None; + } + Err(error) => { + self.state.similarity_search.results.clear(); + self.state.similarity_search.error = Some(error); + } + } + self.publish(); + } } } + fn start_similarity_search(&mut self, key: &TrackKey) { + let Some(source) = self.track(key).cloned() else { + self.state.similarity_search.error = Some("Track is unavailable".into()); + self.publish(); + return; + }; + if !self.state.settings.similarity.enabled { + self.state.similarity_search.error = + Some("Enable Similarity search in Settings first".into()); + self.state.similarity_search.results.clear(); + self.publish(); + return; + } + let Some(track_id) = source.key.local_id().map(LocalTrackId::get) else { + self.state.similarity_search.error = + Some("Similarity search currently starts from a local track".into()); + self.state.similarity_search.results.clear(); + self.publish(); + return; + }; + self.state + .similarity_search + .source_title + .clone_from(&source.title); + self.state.similarity_search.results.clear(); + self.state.similarity_search.error = None; + self.state.similarity_search.pending = true; + self.publish(); + let manager = std::sync::Arc::clone(&self.similarity); + let federation = self.federation.clone(); + let settings = self.state.settings.similarity.clone(); + let internal = self.internal.clone(); + let source_title = source.title; + tokio::spawn(async move { + let local = tokio::task::spawn_blocking(move || manager.search_track(track_id, 50)) + .await + .map_err(|error| format!("similarity worker failed: {error}")) + .and_then(|result| result.map_err(|error| format!("{error:#}"))); + let result = match local { + Ok((local, query)) => { + let mut scored = local + .into_iter() + .map(|found| { + ( + library_track(found.track, ""), + found.score, + Some(found.embedding_signature), + ) + }) + .collect::>(); + if settings.federation_consent + && let Some(federation) = federation + && let Ok(remote) = federation + .search_similar( + query, + 50, + settings.minimum_score, + settings.max_tracks_per_artist, + ) + .await + { + scored.extend( + remote + .into_iter() + .map(|hit| (hit.track, hit.score, hit.embedding_signature)), + ); + } + Ok(rank_similarity_candidates( + scored, + settings.max_tracks_per_artist, + )) + } + Err(error) => Err(error), + }; + let _ = internal + .send(InternalEvent::SimilaritySearchFinished { + source_title, + result, + }) + .await; + }); + } + fn apply_remote_playback_snapshot( &mut self, snapshot: music_dht::device_sync::PlaybackSnapshot, @@ -1378,6 +1632,9 @@ impl Actor { self.pending_control = None; } + if self.device_role == DevicePlaybackRole::Active { + self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped); + } self.device_role = DevicePlaybackRole::Control; self.active_device_id.clone_from(&snapshot.device_id); self.active_device_name.clone_from(&snapshot.device_name); @@ -1445,6 +1702,7 @@ impl Actor { self.publish(); } audio::Event::Finished => { + self.finish_listen(music_dht::device_sync::ListenEndReason::Finished); self.remove_ephemeral_audio(); if self.advance_queue(true) { self.play_current(); @@ -1456,6 +1714,7 @@ impl Actor { } } audio::Event::Failed(message) => { + self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped); self.state.playback.status = PlaybackStatus::Stopped; self.state.playback_error = Some(message); self.publish(); @@ -1466,10 +1725,12 @@ impl Actor { fn play_current(&mut self) { self.remove_ephemeral_audio(); let Some(track) = self.state.queue.current().map(|item| item.track.clone()) else { + self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped); self.state.playback.status = PlaybackStatus::Stopped; self.publish(); return; }; + self.begin_listen(&track); self.state.playback.position_seconds = 0.0; self.state.playback.duration_seconds = track.duration_seconds.max(0.0); self.state.playback_error = None; @@ -1495,6 +1756,46 @@ impl Actor { self.publish(); } + fn begin_listen(&mut self, track: &Track) { + if self.device_role != DevicePlaybackRole::Active { + return; + } + if let Some(session) = &mut self.listen_session + && session.track.key.matches(&track.key) + { + session.track = track.clone(); + return; + } + self.finish_listen(music_dht::device_sync::ListenEndReason::Replaced); + if track.key.content_id().is_some() { + self.listen_session = Some(ListenSession { + id: devices::DeviceSync::new_listen_id(), + track: track.clone(), + started_at_ms: unix_time_ms(), + }); + } + } + + fn finish_listen(&mut self, ended_reason: music_dht::device_sync::ListenEndReason) { + let Some(session) = self.listen_session.take() else { + return; + }; + let listened_ms = if ended_reason == music_dht::device_sync::ListenEndReason::Finished { + seconds_to_milliseconds(session.track.duration_seconds) + } else { + seconds_to_milliseconds(self.state.playback.position_seconds) + }; + if let Err(error) = self.devices.record_listen( + session.id, + &session.track, + session.started_at_ms, + listened_ms, + ended_reason, + ) { + self.state.settings_error = Some(format!("listening history: {error:#}")); + } + } + fn shuffle_new_context(&mut self) { if self.state.playback.shuffle { self.state.queue.shuffle_upcoming(); @@ -1514,6 +1815,8 @@ impl Actor { fn start_federated_playback(&mut self, track: Track, peer_id: String, content_id: ContentId) { let Some(client) = self.federation.clone() else { + self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped); + self.state.playback.status = PlaybackStatus::Stopped; self.state.playback_error = Some("federation is still starting".into()); self.publish(); return; @@ -1548,11 +1851,11 @@ impl Actor { let key = track.key.clone(); let internal = self.internal.clone(); let keep = self.state.settings.save_federated_on_listen; - let directory = if keep { - expand_tilde(&self.state.settings.library_path) - } else { - self.federation_media_dir.join("stream-cache") - }; + let directory = federated_audio_directory( + &self.state.settings.library_path, + keep, + &self.federation_media_dir, + ); let stem = format!("fed-{}", sanitize_filename(&track.title)); tokio::spawn(async move { let (tx, mut rx) = mpsc::channel(4); @@ -1673,7 +1976,20 @@ impl Actor { } fn track(&self, key: &TrackKey) -> Option<&Track> { - find_catalog_track(&self.library, &self.state.search.results, key) + self.state + .queue + .items() + .iter() + .map(|item| &item.track) + .find(|track| track.key.matches(key)) + .or_else(|| find_catalog_track(&self.library, &self.state.search.results, key)) + .or_else(|| { + self.state + .similarity_search + .results + .iter() + .find(|track| track.key.matches(key)) + }) } fn resolve_tracks(&self, keys: &[TrackKey]) -> Vec { @@ -1688,6 +2004,7 @@ impl Actor { self.library = library.clone(); self.state.library = RemoteData::Ready(library); self.reconcile_likes(); + self.resolve_history_tracks(); } Err(error) => { self.state.settings_error = Some(format!("library: {error:#}")); @@ -1696,6 +2013,35 @@ impl Actor { self.publish(); } + fn resolve_history_tracks(&mut self) { + let Some(client) = self.federation.clone() else { + return; + }; + let unresolved = self + .library + .recently_played + .iter() + .filter(|track| track.key.local_id().is_none() && track.cover_uri.is_none()) + .filter_map(|track| track.key.content_id().map(|id| id.as_str().to_owned())) + .collect::>(); + for content_id in unresolved { + if !self.pending_history_resolutions.insert(content_id.clone()) { + continue; + } + let internal = self.internal.clone(); + let client = client.clone(); + tokio::spawn(async move { + let result = client + .track_by_content_id(&content_id) + .await + .map_err(|error| format!("history track lookup failed: {error:#}")); + let _ = internal + .send(InternalEvent::HistoryTrackResolved { content_id, result }) + .await; + }); + } + } + fn reconcile_likes(&mut self) { let liked = self .catalog @@ -1714,11 +2060,15 @@ impl Actor { ) { track.liked = track_is_liked(track, &liked); } + for track in &mut self.state.similarity_search.results { + track.liked = track_is_liked(track, &liked); + } let replacements = self .library .featured_releases .iter() .flat_map(|release| release.tracks.iter()) + .chain(self.library.recently_played.iter()) .chain( self.library .playlists @@ -1848,5 +2198,52 @@ impl Actor { } } +fn rank_similarity_candidates( + mut candidates: Vec, + max_tracks_per_artist: usize, +) -> Vec { + const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8; + + candidates.sort_by(|left, right| right.1.total_cmp(&left.1)); + let mut content = HashSet::new(); + let mut signatures = Vec::new(); + let mut artist_counts = HashMap::::new(); + let mut tracks = Vec::new(); + for (track, _, signature) in candidates { + let identity = track + .key + .content_id() + .map_or_else(|| format!("{:?}", track.key), |id| id.as_str().to_owned()); + if !content.insert(identity) { + continue; + } + if signature.is_some_and(|candidate| { + signatures.iter().any(|existing| { + music_dht::similarity::signature_distance(&candidate, existing) + <= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE + }) + }) { + continue; + } + let artist = track.artists.first().map_or_else( + || music_dht::normalize_name(&track.artist), + |artist| music_dht::normalize_name(&artist.name), + ); + let count = artist_counts.entry(artist.clone()).or_default(); + if !artist.is_empty() && *count >= max_tracks_per_artist.clamp(1, SIMILARITY_RESULT_LIMIT) { + continue; + } + *count += 1; + if let Some(signature) = signature { + signatures.push(signature); + } + tracks.push(track); + if tracks.len() >= SIMILARITY_RESULT_LIMIT { + break; + } + } + tracks +} + #[cfg(test)] mod tests; diff --git a/crates/backend/src/settings/mod.rs b/crates/backend/src/settings/mod.rs index 67e8b15..b0c2c60 100644 --- a/crates/backend/src/settings/mod.rs +++ b/crates/backend/src/settings/mod.rs @@ -4,6 +4,9 @@ use std::path::Path; use furumi_backend_api::SettingsSnapshot; use rusqlite::{Connection, OptionalExtension, params}; +const LEGACY_DEFAULT_LIBRARY_PATH: &str = "~/Music/Furumi"; +const PLATFORM_LIBRARY_PATH_MIGRATION: i64 = 4; + const MIGRATIONS: &[(i64, &str)] = &[ ( 1, @@ -28,6 +31,19 @@ const MIGRATIONS: &[(i64, &str)] = &[ 3, "ALTER TABLE app_settings ADD COLUMN device_name TEXT NOT NULL DEFAULT '';", ), + ( + 5, + r" + ALTER TABLE app_settings ADD COLUMN similarity_enabled INTEGER NOT NULL DEFAULT 0 CHECK (similarity_enabled IN (0, 1)); + ALTER TABLE app_settings ADD COLUMN similarity_model TEXT NOT NULL DEFAULT 'discogs-effnet-bsdynamic-1'; + ALTER TABLE app_settings ADD COLUMN similarity_profile TEXT NOT NULL DEFAULT 'furumi-full-track-v1'; + ALTER TABLE app_settings ADD COLUMN similarity_workers INTEGER NOT NULL DEFAULT 2; + ALTER TABLE app_settings ADD COLUMN similarity_minimum_score REAL NOT NULL DEFAULT 0.70; + ALTER TABLE app_settings ADD COLUMN similarity_max_tracks_per_artist INTEGER NOT NULL DEFAULT 5; + ALTER TABLE app_settings ADD COLUMN similarity_federation_consent INTEGER NOT NULL DEFAULT 0 CHECK (similarity_federation_consent IN (0, 1)); + ALTER TABLE app_settings ADD COLUMN similarity_active_profile TEXT; + ", + ), ]; pub struct SettingsStore { @@ -35,32 +51,47 @@ pub struct SettingsStore { } impl SettingsStore { - pub fn open(path: &Path) -> rusqlite::Result { + pub fn open(path: &Path, default_library_path: &Path) -> rusqlite::Result { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; } let connection = Connection::open(path)?; let mut store = Self { connection }; - store.migrate()?; + store.migrate(default_library_path)?; Ok(store) } #[cfg(test)] - fn in_memory() -> rusqlite::Result { + fn in_memory(default_library_path: &Path) -> rusqlite::Result { let connection = Connection::open_in_memory()?; let mut store = Self { connection }; - store.migrate()?; + store.migrate(default_library_path)?; Ok(store) } pub fn load(&self) -> rusqlite::Result { self.connection.query_row( "SELECT network_id, library_path, federation_enabled, language, - save_federated_on_listen, device_name + save_federated_on_listen, device_name, + similarity_enabled, similarity_model, similarity_profile, + similarity_workers, similarity_minimum_score, + similarity_max_tracks_per_artist, similarity_federation_consent, + similarity_active_profile FROM app_settings WHERE singleton_id = 1", [], |row| { + let similarity = furumi_backend_api::SimilaritySettingsSnapshot { + enabled: row.get::<_, i64>(6)? != 0, + model: row.get(7)?, + profile: row.get(8)?, + workers: usize::try_from(row.get::<_, i64>(9)?).unwrap_or(1), + minimum_score: row.get(10)?, + max_tracks_per_artist: usize::try_from(row.get::<_, i64>(11)?).unwrap_or(1), + federation_consent: row.get::<_, i64>(12)? != 0, + active_profile: row.get(13)?, + } + .normalized(); Ok(SettingsSnapshot { network_id: row.get(0)?, library_path: row.get(1)?, @@ -68,6 +99,7 @@ impl SettingsStore { language: row.get(3)?, save_federated_on_listen: row.get::<_, i64>(4)? != 0, device_name: row.get(5)?, + similarity, }) }, ) @@ -81,7 +113,15 @@ impl SettingsStore { federation_enabled = ?3, language = ?4, save_federated_on_listen = ?5, - device_name = ?6 + device_name = ?6, + similarity_enabled = ?7, + similarity_model = ?8, + similarity_profile = ?9, + similarity_workers = ?10, + similarity_minimum_score = ?11, + similarity_max_tracks_per_artist = ?12, + similarity_federation_consent = ?13, + similarity_active_profile = ?14 WHERE singleton_id = 1", params![ settings.network_id, @@ -90,12 +130,20 @@ impl SettingsStore { settings.language, i64::from(settings.save_federated_on_listen), settings.device_name, + i64::from(settings.similarity.enabled), + settings.similarity.model, + settings.similarity.profile, + i64::try_from(settings.similarity.workers).unwrap_or(16), + settings.similarity.minimum_score, + i64::try_from(settings.similarity.max_tracks_per_artist).unwrap_or(50), + i64::from(settings.similarity.federation_consent), + settings.similarity.active_profile, ], )?; Ok(()) } - fn migrate(&mut self) -> rusqlite::Result<()> { + fn migrate(&mut self, default_library_path: &Path) -> rusqlite::Result<()> { self.connection.execute_batch( "PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS schema_migrations ( @@ -119,14 +167,55 @@ impl SettingsStore { } let transaction = self.connection.transaction()?; transaction.execute_batch(sql)?; + if version == 5 { + transaction.execute( + "UPDATE app_settings SET similarity_workers = ?1 WHERE singleton_id = 1", + [i64::try_from( + furumi_backend_api::SimilaritySettingsSnapshot::default().workers, + ) + .unwrap_or(1)], + )?; + } transaction.execute( "INSERT INTO schema_migrations (version) VALUES (?1)", [version], )?; transaction.commit()?; } + self.migrate_platform_library_path(default_library_path)?; Ok(()) } + + fn migrate_platform_library_path( + &mut self, + default_library_path: &Path, + ) -> rusqlite::Result<()> { + let applied = self + .connection + .query_row( + "SELECT 1 FROM schema_migrations WHERE version = ?1", + [PLATFORM_LIBRARY_PATH_MIGRATION], + |_| Ok(()), + ) + .optional()? + .is_some(); + if applied { + return Ok(()); + } + let default_library_path = default_library_path.to_string_lossy(); + let transaction = self.connection.transaction()?; + transaction.execute( + "UPDATE app_settings + SET library_path = ?1 + WHERE library_path = ?2 OR trim(library_path) = ''", + params![default_library_path.as_ref(), LEGACY_DEFAULT_LIBRARY_PATH], + )?; + transaction.execute( + "INSERT INTO schema_migrations (version) VALUES (?1)", + [PLATFORM_LIBRARY_PATH_MIGRATION], + )?; + transaction.commit() + } } #[cfg(test)] @@ -135,19 +224,58 @@ mod tests { #[test] fn migration_creates_defaults_and_settings_round_trip() { - let store = SettingsStore::in_memory().unwrap(); + let default_library_path = Path::new("/platform/furumi/federation-media"); + let store = SettingsStore::in_memory(default_library_path).unwrap(); let mut settings = store.load().unwrap(); assert_eq!(settings.network_id, "furumi"); + assert_eq!( + settings.library_path, + default_library_path.to_string_lossy() + ); assert!(settings.federation_enabled); assert!(settings.save_federated_on_listen); assert!(settings.device_name.is_empty()); + assert!(!settings.similarity.enabled); + assert_eq!( + settings.similarity.workers, + furumi_backend_api::SimilaritySettingsSnapshot::default().workers + ); + assert!((settings.similarity.minimum_score - 0.70).abs() < f32::EPSILON); settings.network_id = "friends".into(); settings.device_name = "Studio Mac".into(); settings.library_path = "/music/library".into(); settings.federation_enabled = false; + settings.similarity.enabled = true; + settings.similarity.workers = 7; + settings.similarity.minimum_score = 0.82; + settings.similarity.max_tracks_per_artist = 9; + settings.similarity.federation_consent = true; + settings.similarity.active_profile = Some("sim1:test".into()); store.save(&settings).unwrap(); assert_eq!(store.load().unwrap(), settings); } + + #[test] + fn platform_default_migration_preserves_a_custom_library_path() { + let first_default = Path::new("/first/furumi/federation-media"); + let mut store = SettingsStore::in_memory(first_default).unwrap(); + let mut settings = store.load().unwrap(); + settings.library_path = "/custom/music".into(); + store.save(&settings).unwrap(); + store + .connection + .execute( + "DELETE FROM schema_migrations WHERE version = ?1", + [PLATFORM_LIBRARY_PATH_MIGRATION], + ) + .unwrap(); + + store + .migrate(Path::new("/second/furumi/federation-media")) + .unwrap(); + + assert_eq!(store.load().unwrap().library_path, "/custom/music"); + } } diff --git a/crates/backend/src/similarity.rs b/crates/backend/src/similarity.rs new file mode 100644 index 0000000..eee8559 --- /dev/null +++ b/crates/backend/src/similarity.rs @@ -0,0 +1,1096 @@ +//! Local, offline-first music embeddings and exact cosine search. +//! +//! `SQLite` owns the durable vectors. The in-memory index is deliberately +//! replaceable: it is rebuilt for the active profile and never becomes a +//! second source of truth. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_precision_loss, + clippy::cast_sign_loss, + reason = "bounded DSP index and sample-coordinate conversions" +)] + +use std::collections::{HashMap, VecDeque}; +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Instant; + +use anyhow::{Context as _, Result}; +use futures_util::StreamExt as _; +use rodio::{Decoder, Source as _}; +use rustfft::FftPlanner; +use rustfft::num_complex::Complex; +use sha2::{Digest as _, Sha256}; +use tokio::io::AsyncWriteExt as _; +use tract_onnx::prelude::*; +use tract_onnx::tract_core::dims; + +use crate::InternalEvent; +use furumi_backend_api::SimilaritySettingsSnapshot as SimilaritySettings; +use furumi_library::TrackItem; +use furumi_library::{Library, StoredEmbedding}; + +const SAMPLE_RATE: usize = 16_000; +const FRAME_SIZE: usize = 512; +const HOP_SIZE: usize = 256; +const MEL_BANDS: usize = 96; +const PATCH_FRAMES: usize = 128; +const PATCH_HOP: usize = 62; +const EMBEDDING_DIMENSIONS: usize = 1280; +const MODEL_BATCH: usize = 8; +const MAX_MODEL_BYTES: usize = 64 * 1024 * 1024; +const RESULT_LIMIT: usize = 50; +const PEER_CANDIDATE_MAX_PER_ARTIST: usize = 10; +const NEAR_DUPLICATE_COSINE: f32 = 0.995; +const FULL_TRACK_MAX_SECONDS: u32 = 5 * 60; +const LONG_TRACK_WINDOW_SECONDS: u32 = 60; +const RESAMPLE_HALF_TAPS: isize = 8; + +pub const DEFAULT_MODEL_ID: &str = "discogs-effnet-bsdynamic-1"; +pub const DEFAULT_PROFILE_ID: &str = "furumi-full-track-v1"; + +#[derive(Debug, Clone, Copy)] +pub struct ProfileSpec { + pub id: &'static str, +} + +pub const PROFILES: &[ProfileSpec] = &[ProfileSpec { + id: DEFAULT_PROFILE_ID, +}]; + +#[derive(Debug, Clone, Copy)] +pub struct ModelSpec { + pub id: &'static str, + pub version: &'static str, + pub filename: &'static str, + pub url: &'static str, + pub sha256: &'static str, + pub dimensions: usize, +} + +pub const MODELS: &[ModelSpec] = &[ModelSpec { + id: DEFAULT_MODEL_ID, + version: "1", + filename: "discogs-effnet-bsdynamic-1.onnx", + url: "https://essentia.upf.edu/models/feature-extractors/discogs-effnet/discogs-effnet-bsdynamic-1.onnx", + sha256: "a280825b334797cf677939db8cd5762c0392aedd0ca6415dbc1cd083f045e43c", + dimensions: EMBEDDING_DIMENSIONS, +}]; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Phase { + #[default] + Disabled, + Downloading, + Loading, + Processing, + Ready, + Error, +} + +impl Phase { + pub fn label(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::Downloading => "downloading model", + Self::Loading => "loading model", + Self::Processing => "processing", + Self::Ready => "ready", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SimilarityStatus { + pub phase: Phase, + pub active_profile: Option, + pub target_profile: Option, + pub model: String, + pub total_tracks: usize, + pub completed_tracks: usize, + pub failed_tracks: usize, + pub stored_vectors: usize, + pub stored_bytes: u64, + pub current_track: Option, + pub last_error: Option, +} + +#[derive(Debug, Clone)] +pub struct QueryVector { + pub profile_id: String, + pub vector: Vec, + pub source_content_id: Option, +} + +#[derive(Debug, Clone)] +pub struct SimilarTrack { + pub track: TrackItem, + pub score: f32, + pub embedding_signature: [u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES], +} + +#[derive(Default)] +struct Index { + profile_id: Option, + entries: Vec, +} + +type RunnableModel = Arc; + +pub struct Manager { + library: Arc, + events: tokio::sync::mpsc::Sender, + settings: Mutex, + workers: AtomicUsize, + generation: AtomicU64, + pipeline_running: AtomicBool, + rescan_requested: AtomicBool, + status: Mutex, + index: RwLock, + model: Mutex>, + model_dir: PathBuf, +} + +impl Manager { + pub fn new( + library: Arc, + events: tokio::sync::mpsc::Sender, + settings: SimilaritySettings, + model_dir: PathBuf, + ) -> Arc { + let mut index = Index::default(); + if let Some(profile_id) = settings.active_profile.as_deref() + && let Ok(entries) = library.load_similarity_index(profile_id) + { + index.profile_id = Some(profile_id.to_string()); + index.entries = entries; + } + let target_profile = model_by_id(&settings.model) + .filter(|_| profile_by_id(&settings.profile).is_some()) + .map(|model| profile_fingerprint(model, &settings.profile)); + let restored_profile_is_current = index.profile_id == target_profile; + let status = SimilarityStatus { + phase: if !settings.enabled { + Phase::Disabled + } else if restored_profile_is_current { + Phase::Ready + } else { + Phase::Loading + }, + active_profile: index.profile_id.clone(), + target_profile, + model: settings.model.clone(), + ..SimilarityStatus::default() + }; + Arc::new(Self { + library, + events, + workers: AtomicUsize::new(settings.workers.clamp(1, 16)), + generation: AtomicU64::new(0), + pipeline_running: AtomicBool::new(false), + rescan_requested: AtomicBool::new(false), + settings: Mutex::new(settings), + status: Mutex::new(status), + index: RwLock::new(index), + model: Mutex::new(None), + model_dir, + }) + } + + pub fn settings(&self) -> SimilaritySettings { + lock(&self.settings).clone() + } + + pub fn status(&self) -> SimilarityStatus { + lock(&self.status).clone() + } + + pub fn network_allowed(&self) -> bool { + let settings = lock(&self.settings); + settings.enabled && settings.federation_consent + } + + /// Returns the compact signatures advertised by the similarity-routing + /// overlay. Nothing is exposed until both processing and the explicit + /// federation consent are active. + pub(crate) fn routing_signatures(&self) -> Result)>> { + if !self.network_allowed() { + return Ok(None); + } + let status = self.status(); + let Some(profile_id) = status.active_profile else { + return Ok(None); + }; + if status.phase != Phase::Ready { + return Ok(None); + } + let signatures = self.library.similarity_routing_signatures(&profile_id)?; + Ok(Some((profile_id, signatures))) + } + + pub fn apply(self: &Arc, settings: &SimilaritySettings) { + self.workers + .store(settings.workers.clamp(1, 16), Ordering::Release); + let previous = std::mem::replace(&mut *lock(&self.settings), settings.clone()); + if !settings.enabled { + self.generation.fetch_add(1, Ordering::AcqRel); + self.update_status(|status| { + status.phase = Phase::Disabled; + status.current_track = None; + status.target_profile = None; + status.last_error = None; + }); + return; + } + if !previous.enabled + || previous.model != settings.model + || previous.profile != settings.profile + { + self.generation.fetch_add(1, Ordering::AcqRel); + self.start(); + } + } + + /// Requests a scan without cancelling useful work already in progress. + /// Bursts of library-change notifications collapse into one follow-up + /// pass, so metadata refreshes cannot repeatedly restart the model. + pub fn start(self: &Arc) { + self.rescan_requested.store(true, Ordering::Release); + if self.pipeline_running.swap(true, Ordering::AcqRel) { + return; + } + let this = Arc::clone(self); + tokio::spawn(async move { + loop { + // This pass covers every notification received before it + // starts. A notification during the pass requests one more. + this.rescan_requested.store(false, Ordering::Release); + let generation = this.generation.load(Ordering::Acquire); + if let Err(err) = this.run_pipeline(generation).await + && this.generation.load(Ordering::Acquire) == generation + { + this.update_status(|status| { + status.phase = Phase::Error; + status.current_track = None; + status.last_error = Some(format!("{err:#}")); + }); + } + + if this.rescan_requested.load(Ordering::Acquire) { + continue; + } + + this.pipeline_running.store(false, Ordering::Release); + // Close the small race between checking the request flag and + // releasing ownership of the worker. If another worker has + // already claimed it, that worker owns the pending pass. + if this.rescan_requested.swap(false, Ordering::AcqRel) + && !this.pipeline_running.swap(true, Ordering::AcqRel) + { + continue; + } + break; + } + }); + } + + pub fn clear(self: &Arc) { + self.generation.fetch_add(1, Ordering::AcqRel); + let this = Arc::clone(self); + tokio::spawn(async move { + let library = Arc::clone(&this.library); + let result = tokio::task::spawn_blocking(move || library.clear_similarity_embeddings()) + .await + .context("embedding clear task failed") + .and_then(|result| result); + match result { + Ok(()) => { + *write(&this.index) = Index::default(); + lock(&this.settings).active_profile = None; + this.update_status(|status| { + *status = SimilarityStatus { + phase: if this.settings().enabled { + Phase::Loading + } else { + Phase::Disabled + }, + model: this.settings().model, + ..SimilarityStatus::default() + }; + }); + this.publish_profile_activation(None); + if this.settings().enabled { + this.start(); + } + } + Err(err) => this.update_status(|status| { + status.phase = Phase::Error; + status.last_error = Some(format!("clear failed: {err:#}")); + }), + } + }); + } + + pub fn query_for_track(&self, track_id: i64) -> Result { + let profile_id = read(&self.index) + .profile_id + .clone() + .context("no similarity profile is ready yet")?; + let vector = self + .library + .similarity_embedding(track_id, &profile_id)? + .context("this track has not been processed yet")?; + let source_track = self.library.tracks_by_ids(&[track_id])?.into_iter().next(); + let source_content_id = source_track.as_ref().and_then(|track| { + track + .content_id + .clone() + .or_else(|| furumi_library::audio_content_id(&track.file_path)) + }); + Ok(QueryVector { + profile_id, + vector, + source_content_id, + }) + } + + pub fn search_track( + &self, + track_id: i64, + limit: usize, + ) -> Result<(Vec, QueryVector)> { + let query = self.query_for_track(track_id)?; + let matches = self.search_vector( + &query.profile_id, + &query.vector, + Some(track_id), + query.source_content_id.as_deref(), + limit, + )?; + Ok((matches, query)) + } + + pub fn search_vector( + &self, + profile_id: &str, + vector: &[f32], + exclude_track_id: Option, + exclude_content_id: Option<&str>, + limit: usize, + ) -> Result> { + let settings = lock(&self.settings); + let minimum_score = settings.minimum_score; + let max_tracks_per_artist = settings.max_tracks_per_artist; + drop(settings); + self.search_vector_with_policy( + profile_id, + vector, + exclude_track_id, + exclude_content_id, + limit, + minimum_score, + max_tracks_per_artist, + ) + } + + /// Returns a wider, policy-neutral candidate set to a remote requester. + /// The requester applies its own score threshold and artist diversity + /// limit; neither value is part of embedding compatibility. + pub(crate) fn search_vector_for_peer( + &self, + profile_id: &str, + vector: &[f32], + limit: usize, + ) -> Result> { + self.search_vector_with_policy( + profile_id, + vector, + None, + None, + limit, + -1.0, + PEER_CANDIDATE_MAX_PER_ARTIST, + ) + } + + #[allow(clippy::too_many_arguments)] + fn search_vector_with_policy( + &self, + profile_id: &str, + vector: &[f32], + exclude_track_id: Option, + exclude_content_id: Option<&str>, + limit: usize, + minimum_score: f32, + max_tracks_per_artist: usize, + ) -> Result> { + anyhow::ensure!( + !vector.is_empty() && vector.len() <= 4096, + "wrong embedding dimensions" + ); + anyhow::ensure!( + vector.iter().all(|value| value.is_finite()), + "invalid embedding" + ); + let index = read(&self.index); + anyhow::ensure!( + index.profile_id.as_deref() == Some(profile_id), + "the requested similarity profile is not active" + ); + let mut scores: Vec<(i64, f32, &str, &[f32])> = index + .entries + .iter() + .filter(|entry| { + Some(entry.track_id) != exclude_track_id && entry.vector.len() == vector.len() + }) + .map(|entry| { + ( + entry.track_id, + dot(vector, &entry.vector), + entry.artist_key.as_str(), + entry.vector.as_slice(), + ) + }) + .filter(|(_, score, _, _)| score.is_finite() && *score >= minimum_score) + .collect(); + scores.sort_by(|left, right| right.1.total_cmp(&left.1)); + + // Pull a wider candidate set, then cap each primary artist so a large + // discography cannot fill the whole result page. + let mut artist_counts: HashMap = HashMap::new(); + let mut kept_vectors = vec![vector]; + let mut selected = Vec::new(); + for (track_id, score, artist, candidate_vector) in scores { + if is_near_duplicate(candidate_vector, &kept_vectors) { + continue; + } + let count = artist_counts.entry(artist.to_string()).or_default(); + if !artist.is_empty() && *count >= max_tracks_per_artist { + continue; + } + *count += 1; + let embedding_signature = music_dht::similarity::embedding_signature(candidate_vector)?; + kept_vectors.push(candidate_vector); + selected.push((track_id, score, embedding_signature)); + if selected.len() >= limit.min(RESULT_LIMIT) { + break; + } + } + drop(index); + + let ids: Vec = selected.iter().map(|(id, _, _)| *id).collect(); + let tracks = self.library.tracks_by_ids(&ids)?; + let by_id: HashMap = + tracks.into_iter().map(|track| (track.id, track)).collect(); + Ok(selected + .into_iter() + .filter_map(|(id, score, signature)| { + by_id + .get(&id) + .cloned() + .map(|track| (track, score, signature)) + }) + .filter(|(track, _, _)| { + exclude_content_id.is_none_or(|source| track.content_id.as_deref() != Some(source)) + }) + .map(|(track, score, embedding_signature)| SimilarTrack { + track, + score, + embedding_signature, + }) + .collect()) + } + + async fn run_pipeline(self: &Arc, generation: u64) -> Result<()> { + let settings = self.settings(); + if !settings.enabled { + return Ok(()); + } + let spec = model_by_id(&settings.model) + .with_context(|| format!("unknown similarity model '{}'", settings.model))?; + anyhow::ensure!( + profile_by_id(&settings.profile).is_some(), + "unknown preprocessing profile '{}'", + settings.profile + ); + let profile_id = profile_fingerprint(spec, &settings.profile); + self.library.ensure_similarity_profile( + &profile_id, + spec.id, + spec.version, + spec.sha256, + &settings.profile, + spec.dimensions, + )?; + let stats = self.library.similarity_storage_stats(&profile_id)?; + self.update_status(|status| { + status.target_profile = Some(profile_id.clone()); + status.model = spec.id.to_string(); + status.total_tracks = stats.total_tracks; + status.completed_tracks = stats.embedded_tracks; + status.stored_vectors = stats.stored_vectors; + status.stored_bytes = stats.stored_bytes; + status.failed_tracks = 0; + status.current_track = None; + status.last_error = None; + }); + + let mut pending: VecDeque<_> = self.library.pending_similarity_tracks(&profile_id)?.into(); + if pending.is_empty() { + self.ensure_generation(generation)?; + return self.activate_profile(profile_id); + } + + let model_path = self.ensure_model(spec, generation).await?; + self.ensure_generation(generation)?; + let model = self.load_model(&profile_id, &model_path).await?; + self.ensure_generation(generation)?; + self.update_status(|status| status.phase = Phase::Processing); + + let mut jobs = tokio::task::JoinSet::new(); + while !pending.is_empty() || !jobs.is_empty() { + self.ensure_generation(generation)?; + let workers = self.workers.load(Ordering::Acquire).clamp(1, 16); + while jobs.len() < workers { + let Some(track) = pending.pop_front() else { + break; + }; + self.update_status(|status| status.current_track = Some(track.title.clone())); + let library = Arc::clone(&self.library); + let model = Arc::clone(&model); + let profile_id = profile_id.clone(); + jobs.spawn_blocking(move || { + let started = Instant::now(); + let result = + embed_track(&model, Path::new(&track.file_path), track.duration_seconds) + .and_then(|vector| { + library.store_similarity_embedding(&track, &profile_id, &vector) + }); + (track, result, started.elapsed()) + }); + } + let Some(result) = jobs.join_next().await else { + continue; + }; + let (track, result, _elapsed) = result.context("embedding worker panicked")?; + self.ensure_generation(generation)?; + match result { + Ok(()) => { + self.update_status(|status| status.completed_tracks += 1); + } + Err(err) => { + self.update_status(|status| { + status.failed_tracks += 1; + status.last_error = Some(format!("{}: {err:#}", track.title)); + }); + } + } + } + self.ensure_generation(generation)?; + self.activate_profile(profile_id) + } + + fn activate_profile(&self, profile_id: String) -> Result<()> { + let entries = self.library.load_similarity_index(&profile_id)?; + let total_tracks = self + .library + .similarity_storage_stats(&profile_id)? + .total_tracks; + anyhow::ensure!( + total_tracks == 0 || !entries.is_empty(), + "no tracks could be processed" + ); + *write(&self.index) = Index { + profile_id: Some(profile_id.clone()), + entries, + }; + let profile_changed = { + let mut settings = lock(&self.settings); + let changed = settings.active_profile.as_deref() != Some(&profile_id); + settings.active_profile = Some(profile_id.clone()); + changed + }; + let stats = self.library.similarity_storage_stats(&profile_id)?; + self.update_status(|status| { + status.phase = Phase::Ready; + status.active_profile = Some(profile_id.clone()); + status.target_profile = Some(profile_id.clone()); + status.total_tracks = stats.total_tracks; + status.completed_tracks = stats.embedded_tracks; + status.stored_vectors = stats.stored_vectors; + status.stored_bytes = stats.stored_bytes; + status.current_track = None; + }); + if profile_changed { + self.publish_profile_activation(Some(profile_id)); + } + Ok(()) + } + + fn ensure_generation(&self, generation: u64) -> Result<()> { + anyhow::ensure!( + self.generation.load(Ordering::Acquire) == generation, + "similarity processing superseded by newer settings" + ); + Ok(()) + } + + async fn ensure_model(&self, spec: &ModelSpec, generation: u64) -> Result { + tokio::fs::create_dir_all(&self.model_dir).await?; + let path = self.model_dir.join(spec.filename); + if path.exists() { + let verify_path = path.clone(); + let expected = spec.sha256.to_string(); + let valid = tokio::task::spawn_blocking(move || sha256_file(&verify_path)) + .await + .context("model hash task failed")?? + == expected; + if valid { + return Ok(path); + } + tokio::fs::remove_file(&path).await?; + } + + self.update_status(|status| status.phase = Phase::Downloading); + let response = reqwest::get(spec.url).await?.error_for_status()?; + let tmp = path.with_extension(format!("part-{}-{generation}", std::process::id())); + let mut file = tokio::fs::File::create(&tmp).await?; + let mut hasher = Sha256::new(); + let mut received = 0usize; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + received = received.saturating_add(chunk.len()); + anyhow::ensure!( + received <= MAX_MODEL_BYTES, + "model download exceeds size limit" + ); + hasher.update(&chunk); + file.write_all(&chunk).await?; + } + file.flush().await?; + drop(file); + let actual = format!("{:x}", hasher.finalize()); + if actual != spec.sha256 { + let _ = tokio::fs::remove_file(&tmp).await; + anyhow::bail!("downloaded model hash mismatch"); + } + if let Err(err) = tokio::fs::rename(&tmp, &path).await { + // A superseding pipeline may have installed the same verified + // artifact first. This is expected on platforms where rename + // does not replace an existing destination. + if path.exists() { + let _ = tokio::fs::remove_file(&tmp).await; + } else { + return Err(err.into()); + } + } + Ok(path) + } + + async fn load_model(&self, profile_id: &str, path: &Path) -> Result { + if let Some((cached_profile, model)) = lock(&self.model).as_ref() + && cached_profile == profile_id + { + return Ok(Arc::clone(model)); + } + self.update_status(|status| status.phase = Phase::Loading); + let path = path.to_path_buf(); + let model = tokio::task::spawn_blocking(move || load_onnx(&path)) + .await + .context("model loading task failed")??; + *lock(&self.model) = Some((profile_id.to_string(), Arc::clone(&model))); + Ok(model) + } + + fn update_status(&self, update: impl FnOnce(&mut SimilarityStatus)) { + let snapshot = { + let mut status = lock(&self.status); + let previous = status.clone(); + update(&mut status); + (*status != previous).then(|| status.clone()) + }; + if let Some(snapshot) = snapshot { + let _ = self + .events + .try_send(InternalEvent::SimilarityStatus(snapshot)); + } + } + + fn publish_profile_activation(&self, profile_id: Option) { + let event = InternalEvent::SimilarityProfileActivated(profile_id); + match self.events.try_send(event) { + Ok(()) | Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {} + Err(tokio::sync::mpsc::error::TrySendError::Full(event)) => { + let events = self.events.clone(); + tokio::spawn(async move { + let _ = events.send(event).await; + }); + } + } + } +} + +pub fn model_by_id(id: &str) -> Option<&'static ModelSpec> { + MODELS.iter().find(|model| model.id == id) +} + +pub fn profile_by_id(id: &str) -> Option<&'static ProfileSpec> { + PROFILES.iter().find(|profile| profile.id == id) +} + +pub fn profile_fingerprint(model: &ModelSpec, profile: &str) -> String { + let contract = format!( + "furumi-similarity-v1\nmodel={}\nversion={}\nsha256={}\nprofile={}\ninput={}-mono-windowed-sinc16\nselection=full-to-{}s-else-first-middle-last-{}s\nframe={}\nhop={}\nmel=slaney-{}-unit-tri\npatch={}\npatch-hop={}\naggregate=mean-l2\ndimensions={}", + model.id, + model.version, + model.sha256, + profile, + SAMPLE_RATE, + FULL_TRACK_MAX_SECONDS, + LONG_TRACK_WINDOW_SECONDS, + FRAME_SIZE, + HOP_SIZE, + MEL_BANDS, + PATCH_FRAMES, + PATCH_HOP, + model.dimensions + ); + format!("sim1:{}", blake3::hash(contract.as_bytes()).to_hex()) +} + +fn load_onnx(path: &Path) -> Result { + let model = tract_onnx::onnx().model_for_path(path)?; + let batch = model.sym("batch_size"); + let model = model + .with_input_fact(0, f32::fact(dims!(batch, PATCH_FRAMES, MEL_BANDS)).into())? + .into_optimized()? + .into_runnable()?; + Ok(model) +} + +fn embed_track(model: &RunnableModel, path: &Path, duration_seconds: f64) -> Result> { + let signal = decode_mono_16k(path, duration_seconds)?; + let mel = mel_spectrogram(&signal)?; + anyhow::ensure!( + mel.len() >= PATCH_FRAMES, + "track is too short for the model" + ); + let starts: Vec = (0..=mel.len() - PATCH_FRAMES).step_by(PATCH_HOP).collect(); + let mut sum = vec![0.0f32; EMBEDDING_DIMENSIONS]; + let mut count = 0usize; + for batch in starts.chunks(MODEL_BATCH) { + let mut input = vec![0.0f32; MODEL_BATCH * PATCH_FRAMES * MEL_BANDS]; + for (batch_index, &start) in batch.iter().enumerate() { + let offset = batch_index * PATCH_FRAMES * MEL_BANDS; + for frame in 0..PATCH_FRAMES { + let dst = offset + frame * MEL_BANDS; + input[dst..dst + MEL_BANDS].copy_from_slice(&mel[start + frame]); + } + } + let tensor = Tensor::from_shape(&[MODEL_BATCH, PATCH_FRAMES, MEL_BANDS], &input)?; + let outputs = model.run(tvec!(tensor.into_tvalue()))?; + let embedding = outputs + .iter() + .find(|output| output.len() == MODEL_BATCH * EMBEDDING_DIMENSIONS) + .context("model did not return its 1280-dimensional embedding output")? + .to_plain_array_view::()?; + let values = embedding + .as_slice() + .context("model embedding output is not contiguous")?; + for batch_index in 0..batch.len() { + let row = &values + [batch_index * EMBEDDING_DIMENSIONS..(batch_index + 1) * EMBEDDING_DIMENSIONS]; + for (total, value) in sum.iter_mut().zip(row) { + *total += *value; + } + count += 1; + } + } + anyhow::ensure!(count > 0, "model produced no patches"); + for value in &mut sum { + *value /= count as f32; + } + normalize(&mut sum)?; + Ok(sum) +} + +fn decode_mono_16k(path: &Path, duration_seconds: f64) -> Result> { + if duration_seconds.is_finite() && duration_seconds > f64::from(FULL_TRACK_MAX_SECONDS) { + let window = f64::from(LONG_TRACK_WINDOW_SECONDS); + let starts = [ + 0.0, + (duration_seconds / 2.0 - window / 2.0).max(0.0), + (duration_seconds - window).max(0.0), + ]; + let mut selected = Vec::new(); + for start in starts { + selected.extend(decode_mono_window(path, start, Some(window))?); + } + anyhow::ensure!(!selected.is_empty(), "decoded track is empty"); + return Ok(selected); + } + decode_mono_window(path, 0.0, None) +} + +fn decode_mono_window( + path: &Path, + start_seconds: f64, + length_seconds: Option, +) -> Result> { + let file = File::open(path).with_context(|| format!("opening {}", path.display()))?; + let mut decoder = + Decoder::try_from(file).with_context(|| format!("decoding {}", path.display()))?; + let channels = decoder.channels().get() as usize; + let source_rate = decoder.sample_rate().get() as usize; + if start_seconds > 0.0 { + decoder + .try_seek(std::time::Duration::from_secs_f64(start_seconds)) + .with_context(|| format!("seeking {}", path.display()))?; + } + let max_samples = + length_seconds.map(|seconds| (seconds * source_rate as f64).ceil() as usize * channels); + let mut mono = Vec::new(); + let mut channel_sum = 0.0f32; + let mut channel_index = 0usize; + for (sample_index, sample) in decoder.enumerate() { + if max_samples.is_some_and(|limit| sample_index >= limit) { + break; + } + channel_sum += sample; + channel_index += 1; + if channel_index == channels { + mono.push(channel_sum / channels as f32); + channel_sum = 0.0; + channel_index = 0; + } + } + anyhow::ensure!(!mono.is_empty(), "decoded track is empty"); + if source_rate == SAMPLE_RATE { + return Ok(mono); + } + Ok(resample_sinc(&mono, source_rate, SAMPLE_RATE)) +} + +fn resample_sinc(input: &[f32], source_rate: usize, target_rate: usize) -> Vec { + if input.len() < 2 || source_rate == 0 { + return input.to_vec(); + } + let output_len = input + .len() + .saturating_mul(target_rate) + .checked_div(source_rate) + .unwrap_or(0) + .max(1); + let ratio = source_rate as f64 / target_rate as f64; + let cutoff = (target_rate as f64 / source_rate as f64).min(1.0) * 0.95; + (0..output_len) + .map(|index| { + let position = index as f64 * ratio; + let center = position.floor() as isize; + let mut value = 0.0f64; + let mut weight_sum = 0.0f64; + for sample_index in center - RESAMPLE_HALF_TAPS + 1..=center + RESAMPLE_HALF_TAPS { + if sample_index < 0 || sample_index >= input.len() as isize { + continue; + } + let distance = position - sample_index as f64; + let phase = std::f64::consts::PI * distance * cutoff; + let sinc = if phase.abs() < 1e-12 { + 1.0 + } else { + phase.sin() / phase + }; + let window_position = distance / RESAMPLE_HALF_TAPS as f64; + let window = if window_position.abs() <= 1.0 { + 0.5 + 0.5 * (std::f64::consts::PI * window_position).cos() + } else { + 0.0 + }; + let weight = cutoff * sinc * window; + value += f64::from(input[sample_index as usize]) * weight; + weight_sum += weight; + } + if weight_sum.abs() < 1e-12 { + input[center.clamp(0, input.len() as isize - 1) as usize] + } else { + (value / weight_sum) as f32 + } + }) + .collect() +} + +fn mel_spectrogram(signal: &[f32]) -> Result> { + let frame_count = 1 + signal + .len() + .saturating_sub(FRAME_SIZE / 2) + .div_ceil(HOP_SIZE); + let filters = mel_filters(); + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(FRAME_SIZE); + let mut mel = Vec::with_capacity(frame_count); + let mut spectrum = vec![Complex::new(0.0f32, 0.0); FRAME_SIZE]; + for frame_index in 0..frame_count { + let start = frame_index as isize * HOP_SIZE as isize - (FRAME_SIZE / 2) as isize; + for (index, value) in spectrum.iter_mut().enumerate() { + let source = start + index as isize; + let sample = if source >= 0 { + signal.get(source as usize).copied().unwrap_or(0.0) + } else { + 0.0 + }; + let window = 0.5 + - 0.5 * (2.0 * std::f32::consts::PI * index as f32 / (FRAME_SIZE - 1) as f32).cos(); + *value = Complex::new(sample * window, 0.0); + } + fft.process(&mut spectrum); + let powers: Vec = spectrum[..=FRAME_SIZE / 2] + .iter() + .map(Complex::norm_sqr) + .collect(); + let mut bands = [0.0f32; MEL_BANDS]; + for (band, weights) in filters.iter().enumerate() { + let energy: f32 = powers + .iter() + .zip(weights) + .map(|(power, weight)| power * weight) + .sum(); + bands[band] = (1.0 + 10_000.0 * energy.max(0.0)).log10(); + } + mel.push(bands); + } + Ok(mel) +} + +fn mel_filters() -> Vec> { + let low = hz_to_mel_slaney(0.0); + let high = hz_to_mel_slaney((SAMPLE_RATE / 2) as f32); + let points: Vec = (0..MEL_BANDS + 2) + .map(|index| mel_to_hz_slaney(low + (high - low) * index as f32 / (MEL_BANDS + 1) as f32)) + .collect(); + let frequency_scale = (SAMPLE_RATE as f32 / 2.0) / (FRAME_SIZE / 2) as f32; + (0..MEL_BANDS) + .map(|band| { + let left = points[band]; + let center = points[band + 1]; + let right = points[band + 2]; + let area = (right - left) / 2.0; + (0..=FRAME_SIZE / 2) + .map(|bin| { + let frequency = bin as f32 * frequency_scale; + let triangle = if frequency < left || frequency > right { + 0.0 + } else if frequency < center { + (frequency - left) / (center - left) + } else { + (right - frequency) / (right - center) + }; + triangle.max(0.0) / area + }) + .collect() + }) + .collect() +} + +fn hz_to_mel_slaney(hz: f32) -> f32 { + if hz < 1000.0 { + hz / (200.0 / 3.0) + } else { + 15.0 + 27.0 * (hz / 1000.0).ln() / 6.4f32.ln() + } +} + +fn mel_to_hz_slaney(mel: f32) -> f32 { + if mel < 15.0 { + mel * (200.0 / 3.0) + } else { + 1000.0 * (6.4f32.ln() * (mel - 15.0) / 27.0).exp() + } +} + +fn normalize(vector: &mut [f32]) -> Result<()> { + let norm = vector.iter().map(|value| value * value).sum::().sqrt(); + anyhow::ensure!( + norm.is_finite() && norm > f32::EPSILON, + "zero or invalid embedding" + ); + for value in vector { + *value /= norm; + } + Ok(()) +} + +fn dot(left: &[f32], right: &[f32]) -> f32 { + left.iter().zip(right).map(|(a, b)| a * b).sum() +} + +fn is_near_duplicate(candidate: &[f32], kept: &[&[f32]]) -> bool { + kept.iter() + .any(|existing| dot(candidate, existing) >= NEAR_DUPLICATE_COSINE) +} + +fn sha256_file(path: &Path) -> Result { + use std::io::Read as _; + + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn read(lock: &RwLock) -> std::sync::RwLockReadGuard<'_, T> { + lock.read() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn write(lock: &RwLock) -> std::sync::RwLockWriteGuard<'_, T> { + lock.write() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_fingerprint_is_stable_and_contract_specific() { + let model = &MODELS[0]; + let first = profile_fingerprint(model, DEFAULT_PROFILE_ID); + assert_eq!(first, profile_fingerprint(model, DEFAULT_PROFILE_ID)); + assert_ne!(first, profile_fingerprint(model, "another-profile")); + } + + #[test] + fn vectors_are_normalized_before_indexing() { + let mut vector = vec![3.0, 4.0]; + normalize(&mut vector).unwrap(); + assert!((dot(&vector, &vector) - 1.0).abs() < 1e-6); + } + + #[test] + fn near_duplicate_embeddings_are_filtered() { + let query = [1.0, 0.0, 0.0]; + let near_duplicate = [0.99995, 0.01, 0.0]; + let distinct = [0.0, 1.0, 0.0]; + assert!(is_near_duplicate(&near_duplicate, &[&query])); + assert!(!is_near_duplicate(&distinct, &[&query])); + } +} diff --git a/crates/backend/src/support.rs b/crates/backend/src/support.rs index 972f856..d61031b 100644 --- a/crates/backend/src/support.rs +++ b/crates/backend/src/support.rs @@ -15,6 +15,18 @@ pub(super) fn expand_tilde(value: &str) -> std::path::PathBuf { } } +pub(super) fn federated_audio_directory( + library_path: &str, + keep: bool, + federation_cache_dir: &std::path::Path, +) -> std::path::PathBuf { + if keep { + expand_tilde(library_path) + } else { + federation_cache_dir.join("stream-cache") + } +} + pub(super) fn normalize_device_name(value: &str) -> String { let value = value.trim(); if value.is_empty() { @@ -31,10 +43,18 @@ pub(super) fn selected_track_position(tracks: &[Track], selected: &TrackKey) -> .unwrap_or(0) } +pub(super) fn seconds_to_milliseconds(seconds: f64) -> i64 { + if !seconds.is_finite() || seconds <= 0.0 { + return 0; + } + let duration = Duration::from_secs_f64(seconds.min(Duration::MAX.as_secs_f64())); + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) +} + pub(super) fn runtime_build_info() -> BuildInfoSnapshot { use music_dht::capabilities::{ CATALOG_ID, CapabilityManifest, DEVICE_SYNC_ID, FEDERATION_NET_ID, MUSIC_DHT_ID, - RENDEZVOUS_ID, TICKET_ID, + RENDEZVOUS_ID, SIMILARITY_DHT_ID, SIMILARITY_ID, TICKET_ID, }; let manifest = CapabilityManifest::frid("furumi-desktop", env!("CARGO_PKG_VERSION")); @@ -70,6 +90,8 @@ pub(super) fn runtime_build_info() -> BuildInfoSnapshot { protocol("Rendezvous", RENDEZVOUS_ID), protocol("Music DHT", MUSIC_DHT_ID), protocol("Catalog", CATALOG_ID), + protocol("Similarity search", SIMILARITY_ID), + protocol("Similarity routing", SIMILARITY_DHT_ID), VersionEntrySnapshot { name: "Audio transfer".into(), version: federation::AUDIO_PROTOCOL_VERSION.to_string(), @@ -98,6 +120,7 @@ pub(super) fn find_catalog_track<'a>( .featured_releases .iter() .flat_map(|release| release.tracks.iter()) + .chain(library.recently_played.iter()) .chain( library .playlists @@ -785,10 +808,22 @@ pub(super) fn library_snapshot( tracks, }); } - let recently_played = releases - .iter() - .flat_map(|release| release.tracks.iter().cloned()) - .take(12) + let recently_played = catalog + .listen_history(500)? + .into_iter() + .filter_map(|entry| { + let content_id = ContentId::parse(entry.content_id.clone()).ok()?; + let mut track = catalog + .track_by_content_id(content_id.as_str()) + .ok() + .flatten() + .map_or_else( + || history_placeholder(&entry, content_id), + |track| library_track(track, ""), + ); + track.liked = track_is_liked(&track, &liked_ids); + Some(track) + }) .collect(); let mut playlists = Vec::new(); for card in catalog.playlists()? { @@ -816,6 +851,43 @@ pub(super) fn library_snapshot( }) } +fn history_placeholder(entry: &furumi_library::ListenHistoryEntry, content_id: ContentId) -> Track { + let peer_id = "history".to_owned(); + let artist = ArtistRef { + key: ArtistKey::Federation { + peer_id: peer_id.clone(), + id: format!("name:{}", music_dht::normalize_name(&entry.artist)), + }, + name: entry.artist.clone(), + }; + Track { + key: TrackKey::remote(content_id.clone()), + title: entry.title.clone(), + artist: entry.artist.clone(), + artists: vec![artist], + featured_artists: Vec::new(), + release: String::new(), + release_id: ReleaseKey::Federation { + peer_id: peer_id.clone(), + id: format!("history:{}", entry.listen_id), + }, + duration_seconds: 0.0, + track_number: None, + disc_number: None, + cover_uri: None, + audio_format: None, + audio_bitrate_kbps: None, + audio_sample_rate_hz: None, + audio_bit_depth: None, + file_size_bytes: None, + liked: false, + audio_source: AudioSource::Federation { + peer_id, + content_id, + }, + } +} + pub(super) fn track_is_liked(track: &Track, liked_ids: &HashSet) -> bool { track .key diff --git a/crates/backend/src/tests.rs b/crates/backend/src/tests.rs index c20233a..f82cd85 100644 --- a/crates/backend/src/tests.rs +++ b/crates/backend/src/tests.rs @@ -1,5 +1,19 @@ use super::*; +#[test] +fn configured_library_path_controls_permanent_federated_audio_storage() { + let cache = std::path::Path::new("/cache/furumi-desktop/federation-media"); + + assert_eq!( + federated_audio_directory("/chosen/music", true, cache), + std::path::Path::new("/chosen/music") + ); + assert_eq!( + federated_audio_directory("/chosen/music", false, cache), + cache.join("stream-cache") + ); +} + fn merge_test_track(key: TrackKey, audio_source: AudioSource) -> Track { Track { key, @@ -26,6 +40,57 @@ fn merge_test_track(key: TrackKey, audio_source: AudioSource) -> Track { } } +fn similarity_test_candidate( + id: i64, + artist: &str, + score: f32, + signature: u8, +) -> SimilarityCandidate { + let mut track = merge_test_track( + TrackKey::local(LocalTrackId::new(id)), + AudioSource::LocalFile(format!("{id}.flac").into()), + ); + track.title = format!("Track {id}"); + track.artist = artist.into(); + track.artists[0].name = artist.into(); + ( + track, + score, + Some([signature; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]), + ) +} + +#[test] +fn similarity_results_apply_one_artist_cap_after_combining_sources() { + let tracks = rank_similarity_candidates( + vec![ + similarity_test_candidate(1, "same artist", 0.70, 1), + similarity_test_candidate(2, "other artist", 0.90, 2), + similarity_test_candidate(3, "same artist", 0.80, 3), + similarity_test_candidate(4, "same artist", 0.60, 4), + ], + 1, + ); + + assert_eq!(tracks.len(), 2); + assert_eq!(tracks[0].title, "Track 2"); + assert_eq!(tracks[1].title, "Track 3"); +} + +#[test] +fn similarity_results_drop_cross_source_near_duplicates() { + let tracks = rank_similarity_candidates( + vec![ + similarity_test_candidate(1, "first", 0.90, 7), + similarity_test_candidate(2, "second", 0.80, 7), + ], + 5, + ); + + assert_eq!(tracks.len(), 1); + assert_eq!(tracks[0].title, "Track 1"); +} + #[test] fn merging_search_results_deduplicates_source_keys() { let artist = Artist { diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 1877124..1b6c7bd 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -393,6 +393,44 @@ impl Queue { self.play_next_end = Some(insertion + count); } + /// Moves one concrete queue occurrence to a final visual position. + pub fn move_item(&mut self, id: QueueItemId, target_index: usize) -> bool { + let Some(source_index) = self.items.iter().position(|item| item.id == id) else { + return false; + }; + let target_index = target_index.min(self.items.len().saturating_sub(1)); + if source_index == target_index { + return false; + } + let current_id = self.current().map(|item| item.id); + let item = self.items.remove(source_index); + self.items.insert(target_index, item); + self.current = + current_id.and_then(|current| self.items.iter().position(|item| item.id == current)); + self.play_next_end = None; + self.original_order = None; + true + } + + /// Removes one concrete queue occurrence and keeps the nearest item selected. + /// + /// Returns whether the removed occurrence was the current one. + pub fn remove_item(&mut self, id: QueueItemId) -> Option { + let index = self.items.iter().position(|item| item.id == id)?; + let removed_current = self.current == Some(index); + self.items.remove(index); + self.current = match self.current { + None => None, + Some(_) if self.items.is_empty() => None, + Some(current) if index < current => Some(current - 1), + Some(current) if index == current => Some(index.min(self.items.len() - 1)), + Some(current) => Some(current), + }; + self.play_next_end = None; + self.original_order = None; + Some(removed_current) + } + pub fn replace_matching_track(&mut self, replacement: &Track) { for item in &mut self.items { if item.track.key.matches(&replacement.key) { @@ -589,6 +627,39 @@ mod tests { ); } + #[test] + fn moving_queue_items_preserves_the_current_occurrence() { + let mut queue = Queue::default(); + queue.replace_context((1..=4).map(track).collect(), 1); + let moved = queue.items()[0].id; + let current = queue.current().unwrap().id; + + assert!(queue.move_item(moved, 3)); + + assert_eq!(queue.current().unwrap().id, current); + assert_eq!(queue.current_index(), Some(0)); + assert_eq!( + queue + .items() + .iter() + .map(|item| item.track.key.local_id().unwrap().get()) + .collect::>(), + vec![2, 3, 4, 1] + ); + } + + #[test] + fn removing_current_queue_item_selects_the_next_occurrence() { + let mut queue = Queue::default(); + queue.replace_context((1..=3).map(track).collect(), 1); + let current = queue.current().unwrap().id; + + assert_eq!(queue.remove_item(current), Some(true)); + + assert_eq!(queue.current_index(), Some(1)); + assert_eq!(queue.current().unwrap().track.title, "3"); + } + #[test] fn shuffle_preserves_current_track_and_can_restore_the_tail() { let mut queue = Queue::default(); diff --git a/crates/ui/assets/history.svg b/crates/ui/assets/history.svg new file mode 100644 index 0000000..e14a9de --- /dev/null +++ b/crates/ui/assets/history.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 7a3df0a..1ea55de 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -29,7 +29,7 @@ slint::include_modules!(); mod render; use render::{ - breadcrumb_screen, contributor_lines, find_artist_key, find_release_key, model, + breadcrumb_screen, contributor_lines, find_release_key, model, parse_artist_key, parse_track_key, release_artist_credits, render, render_catalog, render_current_track, render_playback, render_queue, render_search, render_shell, render_track_info, selected_release, track_context, @@ -99,6 +99,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc>, backend: &Ba let screen = match target.as_str() { "search" => Screen::Search, "library" => Screen::Library, + "history" => Screen::History, _ => Screen::Home, }; dispatch_action(&window, &state, &backend, UiAction::Navigate(screen)); @@ -144,6 +145,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc>, backend: &Ba "add-end" => UiAction::AddToEnd(vec![key]), "information" => UiAction::ShowTrackInfo(key), "playlist" => UiAction::ShowPlaylistPicker(key), + "similar" => UiAction::SearchSimilar(key), // The remaining presentation actions terminate here until // their backend capabilities are introduced. _ => return, @@ -152,6 +154,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc>, backend: &Ba } }); bind_playlist_callbacks(window, state, backend); + bind_queue_callbacks(window, state, backend); bind_device_callbacks(window, state, backend); bind_settings_callbacks(window, state, backend); window.on_dismiss_error({ @@ -236,6 +239,9 @@ fn bind_playlist_callbacks( let backend = backend.clone(); move || dispatch_action(&window, &state, &backend, UiAction::ClosePlaylistPicker) }); +} + +fn bind_queue_callbacks(window: &AppWindow, state: &Arc>, backend: &BackendHandle) { window.on_play_queue_item({ let window = window.as_weak(); let state = Arc::clone(state); @@ -251,6 +257,41 @@ fn bind_playlist_callbacks( } } }); + window.on_move_queue_item({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |id, target_index| { + let (Ok(id), Ok(target_index)) = (id.parse::(), usize::try_from(target_index)) + else { + return; + }; + dispatch_action( + &window, + &state, + &backend, + UiAction::MoveQueueItem { + item_id: QueueItemId::new(id), + target_index, + }, + ); + } + }); + window.on_remove_queue_item({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |id| { + if let Ok(id) = id.parse::() { + dispatch_action( + &window, + &state, + &backend, + UiAction::RemoveQueueItem(QueueItemId::new(id)), + ); + } + } + }); } fn bind_catalog_callbacks( @@ -295,7 +336,7 @@ fn bind_catalog_callbacks( let state = Arc::clone(state); let backend = backend.clone(); move |target| { - let screen = with_state(&state, |state| breadcrumb_screen(state, &target)); + let screen = breadcrumb_screen(&target); if let Some(screen) = screen { dispatch_action(&window, &state, &backend, UiAction::Navigate(screen)); } @@ -306,7 +347,7 @@ fn bind_catalog_callbacks( let state = Arc::clone(state); let backend = backend.clone(); move |key| { - let artist = with_state(&state, |state| find_artist_key(state, &key)); + let artist = parse_artist_key(&key); if let Some(key) = artist { dispatch_action( &window, @@ -519,6 +560,7 @@ fn bind_settings_callbacks( ); } }); + bind_similarity_settings_callbacks(window, state, backend); window.on_language_changed({ let window = window.as_weak(); let state = Arc::clone(state); @@ -534,6 +576,118 @@ fn bind_settings_callbacks( }); } +#[allow( + clippy::too_many_lines, + reason = "one explicit binding per similarity setting keeps callback ownership obvious" +)] +fn bind_similarity_settings_callbacks( + window: &AppWindow, + state: &Arc>, + backend: &BackendHandle, +) { + window.on_similarity_enabled_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |enabled| { + dispatch_action( + &window, + &state, + &backend, + UiAction::SimilarityEnabledChanged(enabled), + ); + } + }); + window.on_similarity_model_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |model| { + dispatch_action( + &window, + &state, + &backend, + UiAction::SimilarityModelChanged(model.to_string()), + ); + } + }); + window.on_similarity_profile_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |profile| { + dispatch_action( + &window, + &state, + &backend, + UiAction::SimilarityProfileChanged(profile.to_string()), + ); + } + }); + window.on_similarity_workers_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |workers| { + if let Ok(workers) = usize::try_from(workers) { + dispatch_action( + &window, + &state, + &backend, + UiAction::SimilarityWorkersChanged(workers), + ); + } + } + }); + window.on_similarity_minimum_score_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |minimum_score| { + dispatch_action( + &window, + &state, + &backend, + UiAction::SimilarityMinimumScoreChanged(minimum_score), + ); + } + }); + window.on_similarity_max_tracks_per_artist_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |maximum| { + if let Ok(maximum) = usize::try_from(maximum) { + dispatch_action( + &window, + &state, + &backend, + UiAction::SimilarityMaxTracksPerArtistChanged(maximum), + ); + } + } + }); + window.on_similarity_federation_consent_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |consent| { + dispatch_action( + &window, + &state, + &backend, + UiAction::SimilarityFederationConsentChanged(consent), + ); + } + }); + window.on_clear_similarity({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::ClearSimilarity) + }); +} + fn bind_library_picker(window: &AppWindow, state: &Arc>, backend: &BackendHandle) { window.on_choose_library_path({ let window = window.as_weak(); @@ -717,11 +871,13 @@ fn dispatch_event(window: &AppWindow, state: &Arc>, event: AppEv || previous.federation_debug != state.backend.federation_debug || previous.connected_devices != state.backend.connected_devices || previous.settings != state.backend.settings + || previous.similarity_status != state.backend.similarity_status || previous.playback_error != state.backend.playback_error || previous.settings_error != state.backend.settings_error; let catalog_changed = previous.library != state.backend.library || previous.search != state.backend.search || previous.queue != state.backend.queue; + let similarity_changed = previous.similarity_search != state.backend.similarity_search; let queue_changed = previous.queue != state.backend.queue; if shell_changed { render_shell(window, state); @@ -735,6 +891,9 @@ fn dispatch_event(window: &AppWindow, state: &Arc>, event: AppEv render_queue(window, state); render_current_track(window, state); } + if similarity_changed { + render::render_similarity(window, state); + } render_playback(window, state); }); } diff --git a/crates/ui/src/render.rs b/crates/ui/src/render.rs index 7f4cb3e..70ad0ba 100644 --- a/crates/ui/src/render.rs +++ b/crates/ui/src/render.rs @@ -3,6 +3,7 @@ pub(super) fn render(window: &AppWindow, state: &AppState) { render_shell(window, state); render_catalog(window, state); render_search(window, state); + render_similarity(window, state); render_queue(window, state); render_current_track(window, state); render_playback(window, state); @@ -15,7 +16,7 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) { window.set_search_label(strings.search.into()); window.set_library_label(strings.library.into()); window.set_queue_label(strings.queue.into()); - window.set_recent_label(strings.recently_played.into()); + window.set_history_label(strings.listening_history.into()); window.set_featured_label(strings.made_for_listening.into()); window.set_search_placeholder(strings.search_placeholder.into()); window.set_active_screen( @@ -23,6 +24,8 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) { Screen::Home => "home", Screen::Search => "search", Screen::Library => "library", + Screen::History => "history", + Screen::Similarity => "similarity", Screen::Artist(_) => "artist", Screen::Release(_, _) => "release", Screen::Playlist(_) => "playlist", @@ -40,6 +43,46 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) { window.set_library_path(state.backend.settings.library_path.clone().into()); window.set_federation_enabled(state.backend.settings.federation_enabled); window.set_save_federated_on_listen(state.backend.settings.save_federated_on_listen); + let similarity = &state.backend.settings.similarity; + window.set_similarity_enabled(similarity.enabled); + window.set_similarity_model(similarity.model.clone().into()); + window.set_similarity_models(model(vec![SharedString::from( + "discogs-effnet-bsdynamic-1", + )])); + window.set_similarity_profile(similarity.profile.clone().into()); + window.set_similarity_profiles(model(vec![SharedString::from("furumi-full-track-v1")])); + window.set_similarity_workers(i32::try_from(similarity.workers).unwrap_or(16)); + window.set_similarity_minimum_score(similarity.minimum_score); + window.set_similarity_max_tracks_per_artist( + i32::try_from(similarity.max_tracks_per_artist).unwrap_or(50), + ); + window.set_similarity_federation_consent(similarity.federation_consent); + let status = &state.backend.similarity_status; + window.set_similarity_status_phase(status.phase.clone().into()); + window.set_similarity_status_progress( + format!("{} / {}", status.completed_tracks, status.total_tracks).into(), + ); + window.set_similarity_status_storage( + format!( + "{} / {}", + status.stored_vectors, + compact_bytes(status.stored_bytes) + ) + .into(), + ); + window.set_similarity_status_current( + status + .current_track + .clone() + .or_else(|| status.error.clone()) + .unwrap_or_else(|| { + status.active_profile.as_deref().map_or_else( + || "Index is not ready".into(), + |profile| format!("Active: {profile}"), + ) + }) + .into(), + ); window.set_selected_language(state.backend.settings.language.clone().into()); window.set_available_languages(model(vec![SharedString::from("English")])); window.set_search_query(state.frontend.search_query.clone().into()); @@ -47,6 +90,7 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) { render_device_shell(window, state); let (network_busy, network_text) = federation_status(state); window.set_federation_status_busy(network_busy); + window.set_federation_status_running(state.backend.federation_debug.running); window.set_federation_status_text(network_text.into()); render_federation_debug(window, state); render_build_info(window, state); @@ -62,6 +106,18 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) { render_track_info(window, state); } +fn compact_bytes(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = 1024 * KIB; + if bytes >= MIB { + format!("{}.{:01} MiB", bytes / MIB, bytes % MIB * 10 / MIB) + } else if bytes >= KIB { + format!("{}.{:01} KiB", bytes / KIB, bytes % KIB * 10 / KIB) + } else { + format!("{bytes} B") + } +} + fn render_federation_debug(window: &AppWindow, state: &AppState) { let debug = &state.backend.federation_debug; window.set_federation_debug_node( @@ -184,13 +240,27 @@ fn render_device_shell(window: &AppWindow, state: &AppState) { devices .pending_pairings .iter() - .map(|pending| PairingView { - request_id: pending.request_id.clone().into(), - name: pending.name.clone().into(), - details: format!("Furumi {} · {}", pending.client_version, pending.device_id) - .into(), - group_conflict: pending.requester_group_id.is_some() - && pending.requester_group_active_devices > 1, + .map(|pending| { + let group_conflict = pending.requester_group_id.is_some() + && pending.requester_group_active_devices > 1; + PairingView { + request_id: pending.request_id.clone().into(), + name: pending.name.clone().into(), + details: format!("Furumi {} · {}", pending.client_version, pending.device_id) + .into(), + group_conflict, + group_summary: pending + .requester_group_id + .as_ref() + .map_or_else(String::new, |group_id| { + format!( + "Their group · {} active devices · {}", + pending.requester_group_active_devices, + group_id.chars().take(24).collect::() + ) + }) + .into(), + } }) .collect(), )); @@ -403,12 +473,18 @@ fn find_track<'a>(state: &'a AppState, key: &TrackKey) -> Option<&'a Track> { .flat_map(|library| library.featured_releases.iter()) .flat_map(|release| release.tracks.iter()), ) + .chain( + ready_library(state) + .into_iter() + .flat_map(|library| library.recently_played.iter()), + ) .chain( ready_library(state) .into_iter() .flat_map(|library| library.playlists.iter()) .flat_map(|playlist| playlist.tracks.iter()), ) + .chain(state.backend.similarity_search.results.iter()) .find(|track| track.key.matches(key)) } @@ -462,11 +538,12 @@ pub(super) fn track_context(state: &AppState, context: &str) -> Vec { let tracks: Vec = match context { "release" => selected_release(state).map_or_else(Vec::new, |release| release.tracks), "search" => state.backend.search.results.tracks.clone(), - "recent" => ready_library(state) + "history" => ready_library(state) .into_iter() .flat_map(|library| library.recently_played.iter()) .cloned() .collect(), + "similarity" => state.backend.similarity_search.results.clone(), "artist-featured" => selected_artist(state).map_or_else(Vec::new, |artist| { ready_library(state) .into_iter() @@ -492,17 +569,18 @@ pub(super) fn track_context(state: &AppState, context: &str) -> Vec { tracks.into_iter().map(|track| track.key).collect() } -pub(super) fn breadcrumb_screen(state: &AppState, target: &SharedString) -> Option { +pub(super) fn breadcrumb_screen(target: &SharedString) -> Option { match target.as_str() { "home" => Some(Screen::Home), "search" => Some(Screen::Search), "library" => Some(Screen::Library), + "history" => Some(Screen::History), value if value.starts_with("playlist:") => value .trim_start_matches("playlist:") .parse::() .ok() .map(Screen::Playlist), - _ => find_artist_key(state, target).map(Screen::Artist), + _ => parse_artist_key(target).map(Screen::Artist), } } @@ -526,6 +604,14 @@ fn breadcrumbs(state: &AppState) -> Vec { crumb("Home".into(), "home".into(), true), crumb("Library".into(), String::new(), false), ], + Screen::History => vec![ + crumb("Home".into(), "home".into(), true), + crumb("Listening history".into(), String::new(), false), + ], + Screen::Similarity => vec![ + crumb("Home".into(), "home".into(), true), + crumb("Similar tracks".into(), String::new(), false), + ], Screen::Artist(_) => vec![ crumb("Home".into(), "home".into(), true), crumb( @@ -588,7 +674,11 @@ pub(super) fn render_catalog(window: &AppWindow, state: &AppState) { }); window.set_releases(model(releases)); let artists = library.map_or_else(Vec::new, |library| { - library.artists.iter().map(artist_to_view).collect() + library + .artists + .iter() + .map(|artist| artist_to_view(artist, &library.featured_releases)) + .collect() }); window.set_artists(model(artists)); @@ -660,10 +750,10 @@ pub(super) fn render_catalog(window: &AppWindow, state: &AppState) { render_catalog_detail(window, state, selected_artist); - let recent = library.map_or_else(Vec::new, |library| { + let history = library.map_or_else(Vec::new, |library| { tracks_to_views(&library.recently_played, state) }); - window.set_tracks(model(recent)); + window.set_history_tracks(model(history)); let playlist = selected_playlist(state); window.set_playlist_title( playlist @@ -741,6 +831,7 @@ pub(super) fn render_queue(window: &AppWindow, state: &AppState) { let (artwork, has_artwork) = load_artwork(item.track.cover_uri.as_deref()); QueueView { key: item.id.get().to_string().into(), + track_key: format_track_key(&item.track.key).into(), title: item.track.title.clone().into(), artist: item.track.artist.clone().into(), artist_key: item @@ -753,6 +844,7 @@ pub(super) fn render_queue(window: &AppWindow, state: &AppState) { release: item.track.release.clone().into(), release_key: format_release_key(&item.track.release_id).into(), active: state.backend.queue.current_index() == Some(index), + liked: item.track.liked, artwork, has_artwork, } @@ -809,7 +901,12 @@ pub(super) fn render_current_track(window: &AppWindow, state: &AppState) { pub(super) fn render_search(window: &AppWindow, state: &AppState) { let search = &state.backend.search; window.set_search_artists(model( - search.results.artists.iter().map(artist_to_view).collect(), + search + .results + .artists + .iter() + .map(|artist| artist_to_view(artist, &search.results.releases)) + .collect(), )); window.set_search_releases(model( search @@ -822,6 +919,14 @@ pub(super) fn render_search(window: &AppWindow, state: &AppState) { window.set_search_results(model(tracks_to_views(&search.results.tracks, state))); } +pub(super) fn render_similarity(window: &AppWindow, state: &AppState) { + let search = &state.backend.similarity_search; + window.set_similarity_source_title(search.source_title.clone().into()); + window.set_similarity_pending(search.pending); + window.set_similarity_error(search.error.clone().unwrap_or_default().into()); + window.set_similarity_tracks(model(tracks_to_views(&search.results, state))); +} + fn search_duration_label(milliseconds: u64) -> String { if milliseconds < 1_000 { format!("{milliseconds} ms") @@ -1028,8 +1133,13 @@ pub(super) fn contributor_lines(artists: &[ArtistRef], width: f32) -> Vec ArtistView { - let (artwork, has_artwork) = load_artwork(artist.artwork.uri.as_deref()); +fn artist_to_view(artist: &Artist, releases: &[Release]) -> ArtistView { + let artwork_uri = artist + .artwork + .uri + .as_deref() + .or_else(|| fallback_artist_artwork(artist, releases)); + let (artwork, has_artwork) = load_artwork(artwork_uri); ArtistView { key: format_artist_key(&artist.key).into(), name: artist.name.clone().into(), @@ -1044,6 +1154,30 @@ fn artist_to_view(artist: &Artist) -> ArtistView { } } +fn fallback_artist_artwork<'a>(artist: &Artist, releases: &'a [Release]) -> Option<&'a str> { + let belongs_to_artist = |release: &&Release| { + release + .artists + .iter() + .any(|candidate| candidate.key == artist.key) + && release.artwork.uri.is_some() + }; + let mut candidates = releases + .iter() + .filter(|release| release.is_album()) + .filter(belongs_to_artist) + .collect::>(); + if candidates.is_empty() { + candidates.extend(releases.iter().filter(belongs_to_artist)); + } + let hash = artist.name.bytes().fold(0_usize, |hash, byte| { + hash.wrapping_mul(31) ^ usize::from(byte) + }); + candidates + .get(hash % candidates.len().max(1)) + .and_then(|release| release.artwork.uri.as_deref()) +} + fn release_to_view(release: &Release) -> ReleaseView { let (artwork, has_artwork) = load_artwork(release.artwork.uri.as_deref()); ReleaseView { @@ -1296,16 +1430,21 @@ fn format_release_key(key: &ReleaseKey) -> String { } } -pub(super) fn find_artist_key(state: &AppState, value: &SharedString) -> Option { - let local = match &state.backend.library { - RemoteData::Ready(library) => library.artists.as_slice(), - _ => &[], - }; - local - .iter() - .chain(state.backend.search.results.artists.iter()) - .find(|artist| format_artist_key(&artist.key) == value.as_str()) - .map(|artist| artist.key.clone()) +pub(super) fn parse_artist_key(value: &SharedString) -> Option { + if let Some(local) = value.strip_prefix("local-artist:") { + return local + .parse::() + .ok() + .map(|id| ArtistKey::local(furumi_domain::ArtistId::new(id))); + } + let (peer_id, id) = value.strip_prefix("fed-artist:")?.split_once(':')?; + if peer_id.is_empty() || id.is_empty() { + return None; + } + Some(ArtistKey::Federation { + peer_id: peer_id.into(), + id: id.into(), + }) } pub(super) fn find_release_key(state: &AppState, value: &SharedString) -> Option { @@ -1380,6 +1519,51 @@ mod tests { assert_eq!(decoded.size().height, 1); } + #[test] + fn artist_link_key_is_resolved_without_a_top_level_artist_row() { + let expected = ArtistKey::Federation { + peer_id: "peer-a".into(), + id: "guest:artist".into(), + }; + let encoded: SharedString = format_artist_key(&expected).into(); + + assert_eq!(parse_artist_key(&encoded), Some(expected)); + } + + #[test] + fn artist_without_an_image_uses_one_of_its_album_covers() { + let key = ArtistKey::local(furumi_domain::ArtistId::new(12)); + let artist = Artist { + key: key.clone(), + source: CatalogSource::Local, + name: "Artist".into(), + artwork: furumi_domain::Artwork::default(), + release_count: 1, + track_count: 1, + }; + let release = Release { + key: ReleaseKey::local(furumi_domain::ReleaseId::new(8)), + source: CatalogSource::Local, + title: "Album".into(), + artists: vec![ArtistRef { + key, + name: "Artist".into(), + }], + featured_artists: Vec::new(), + release_type: "album".into(), + year: None, + artwork: furumi_domain::Artwork { + uri: Some("/music/album-cover.jpg".into()), + }, + tracks: Vec::new(), + }; + + assert_eq!( + fallback_artist_artwork(&artist, &[release]), + Some("/music/album-cover.jpg") + ); + } + #[test] fn release_credits_include_artists_found_only_on_tracks() { let pasha = ArtistRef { diff --git a/crates/ui/ui/app.slint b/crates/ui/ui/app.slint index d59ff5b..a64d8e9 100644 --- a/crates/ui/ui/app.slint +++ b/crates/ui/ui/app.slint @@ -1,6 +1,37 @@ import { Button, CheckBox, ComboBox, LineEdit, ScrollView, Slider } from "std-widgets.slint"; import { ArtistLinkLineView, ArtistLinkView, ArtistView, BreadcrumbView, DeviceView, PairingView, PlaylistView, QueueView, ReleaseView, TrackView, VersionView } from "models.slint"; -import { AlbumGrid, ArtistGrid, ArtistLinksRow, BuildInfoCard, DeviceRow, FederationBadge, IconButton, InfoField, LinkText, MarqueeText, NavButton, PlaylistNavButton, SearchArtistGrid, SearchField, SearchReleaseGrid, StaticArtistList, TrackInfoArtists, TrackList } from "components.slint"; +import { AlbumGrid, ArtistGrid, ArtistLinksRow, BuildInfoCard, DeviceRow, FederationBadge, IconButton, InfoField, LinkText, MarqueeText, NavButton, PlaylistNavButton, RecommendedButton, SearchArtistGrid, SearchField, SearchReleaseGrid, StaticArtistList, TrackActionButton, TrackInfoArtists, TrackList, TrackMenuItem } from "components.slint"; + +component FederationStateIndicator inherits Rectangle { + in property busy; + in property running; + width: 12px; + height: 100%; + horizontal-stretch: 0; + background: transparent; + + if !root.busy: Rectangle { + width: 9px; + height: 9px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + border-radius: self.width / 2; + background: root.running ? #55dbaa : #697080; + } + + if root.busy: pulse := Rectangle { + private property phase: floor(1 * mod(animation-tick(), 2s) / 250ms); + private property level: self.phase < 4 ? self.phase : 7 - self.phase; + width: 12px - self.level * 2px; + height: self.width; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + border-radius: self.width / 2; + border-width: 2px; + border-color: #55dbaa; + background: transparent; + } +} export component AppWindow inherits Window { title: "Furumi Desktop"; @@ -17,7 +48,7 @@ export component AppWindow inherits Window { in property search-label: "Search"; in property library-label: "Your library"; in property queue-label: "Queue"; - in property recent-label: "Recently played"; + in property history-label: "Listening history"; in property featured-label: "Made for listening"; in property search-placeholder: "Artists, albums or tracks"; in-out property search-query; @@ -29,6 +60,19 @@ export component AppWindow inherits Window { in property library-path; in property federation-enabled; in property save-federated-on-listen; + in property similarity-enabled; + in property similarity-model; + in property <[string]> similarity-models; + in property similarity-profile; + in property <[string]> similarity-profiles; + in property similarity-workers: 1; + in property similarity-minimum-score: 0.7; + in property similarity-max-tracks-per-artist: 5; + in property similarity-federation-consent; + in property similarity-status-phase: "disabled"; + in property similarity-status-progress: "0 / 0"; + in property similarity-status-storage: "0 vectors"; + in property similarity-status-current; in property selected-language: "English"; in property <[string]> available-languages; in property <[ReleaseView]> releases; @@ -44,8 +88,15 @@ export component AppWindow inherits Window { in property detail-release-type; in property detail-artwork; in property detail-has-artwork; - in property <[TrackView]> tracks; + in property <[TrackView]> history-tracks; + in property <[TrackView]> similarity-tracks; + in property similarity-source-title; + in property similarity-pending; + in property similarity-error; in property <[QueueView]> queue-items; + private property queue-drag-source: -1; + private property queue-drag-target: -1; + private property queue-drag-offset: 0px; in property <[PlaylistView]> playlists; in property <[TrackView]> playlist-tracks; in property playlist-title; @@ -63,6 +114,7 @@ export component AppWindow inherits Window { in property <[ArtistView]> search-artists; in property <[ReleaseView]> search-releases; in property federation-status-busy; + in property federation-status-running; in property federation-status-text: "Federation ready"; in property federation-debug-node: "Stopped"; in property federation-debug-peers: "0 connected · 0 known"; @@ -125,6 +177,8 @@ export component AppWindow inherits Window { callback play-track(string); callback play-track-context(string, string); callback play-queue-item(string); + callback move-queue-item(string, int); + callback remove-queue-item(string); callback track-action(string, string); callback open-playlist(string); callback create-playlist(string); @@ -141,6 +195,14 @@ export component AppWindow inherits Window { callback choose-library-path; callback federation-changed(bool); callback save-federated-on-listen-changed(bool); + callback similarity-enabled-changed(bool); + callback similarity-model-changed(string); + callback similarity-profile-changed(string); + callback similarity-workers-changed(int); + callback similarity-minimum-score-changed(float); + callback similarity-max-tracks-per-artist-changed(int); + callback similarity-federation-consent-changed(bool); + callback clear-similarity; callback language-changed(string); callback dismiss-error; callback close-track-info; @@ -171,6 +233,7 @@ export component AppWindow inherits Window { NavButton { label: root.home-label; icon-source: @image-url("../assets/home.svg"); active: root.active-screen == "home"; clicked => root.navigate("home"); } NavButton { label: root.search-label; icon-source: @image-url("../assets/search.svg"); active: root.active-screen == "search"; clicked => root.navigate("search"); } NavButton { label: root.library-label; icon-source: @image-url("../assets/library.svg"); active: root.active-screen == "library"; clicked => root.navigate("library"); } + NavButton { label: root.history-label; icon-source: @image-url("../assets/history.svg"); active: root.active-screen == "history"; clicked => root.navigate("history"); } Rectangle { height: 12px; background: transparent; } HorizontalLayout { height: 28px; @@ -197,14 +260,6 @@ export component AppWindow inherits Window { } } NavButton { label: "Settings"; icon-source: @image-url("../assets/settings.svg"); active: root.settings-open; clicked => root.toggle-settings(); } - Rectangle { - height: 58px; border-radius: 9px; background: #171a22; - HorizontalLayout { - padding: 10px; spacing: 10px; - Rectangle { width: 34px; height: 34px; border-radius: 17px; background: #353a49; Text { text: "F"; color: #fff; font-weight: 700; horizontal-alignment: center; vertical-alignment: center; } } - VerticalLayout { alignment: center; Text { text: "Local library"; color: #e9eaf0; font-size: 12px; font-weight: 600; } Text { text: "Desktop player"; color: #777d8d; font-size: 10px; } } - } - } } new-playlist-popup := PopupWindow { @@ -269,7 +324,7 @@ export component AppWindow inherits Window { border-width: 1px; HorizontalLayout { padding-left: 10px; padding-right: 10px; spacing: 6px; - Text { text: root.federation-status-busy ? "◌" : "●"; color: root.federation-status-busy ? #55dbaa : #697080; font-size: 10px; vertical-alignment: center; } + FederationStateIndicator { busy: root.federation-status-busy; running: root.federation-status-running; } status-label := Text { text: root.federation-status-text; color: #9da3b2; font-size: 10px; overflow: elide; vertical-alignment: center; } } } @@ -285,9 +340,18 @@ export component AppWindow inherits Window { Text { text: "Good evening"; color: #f5f6fa; font-size: 28px; font-weight: 800; } Text { text: "Artists"; color: #f5f6fa; font-size: 18px; font-weight: 750; } ArtistGrid { artists: root.artists; open(key) => root.open-artist(key); } - Rectangle { height: 8px; background: transparent; } - Text { text: root.recent-label; color: #f5f6fa; font-size: 18px; font-weight: 750; } - TrackList { tracks: root.tracks; play(key) => root.play-track-context("recent", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); } + } + } + + if root.active-screen == "history": ScrollView { + viewport-width: self.width; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + width: parent.width; spacing: 17px; alignment: start; + Text { height: 46px; vertical-stretch: 0; text: root.history-label; color: #f5f6fa; font-size: 30px; font-weight: 800; vertical-alignment: center; } + Text { height: 18px; vertical-stretch: 0; text: root.history-tracks.length + (root.history-tracks.length == 1 ? " listen" : " listens"); color: #858b9c; font-size: 12px; vertical-alignment: center; } + TrackList { tracks: root.history-tracks; show-artwork: true; play(key) => root.play-track-context("history", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); } } } @@ -302,6 +366,20 @@ export component AppWindow inherits Window { } } + if root.active-screen == "similarity": ScrollView { + viewport-width: self.width; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + width: parent.width; spacing: 17px; alignment: start; + Text { height: 46px; vertical-stretch: 0; text: "Similar to “" + root.similarity-source-title + "”"; color: #f5f6fa; font-size: 30px; font-weight: 800; overflow: elide; vertical-alignment: center; } + if root.similarity-pending: Text { height: 22px; text: "Searching local index and federation…"; color: #55dbaa; font-size: 12px; vertical-alignment: center; } + if root.similarity-error != "": Text { height: 40px; text: root.similarity-error; color: #ff8d98; font-size: 12px; wrap: word-wrap; } + if !root.similarity-pending && root.similarity-error == "" && root.similarity-tracks.length == 0: Text { height: 28px; text: "No matching tracks found."; color: #858b9c; font-size: 12px; vertical-alignment: center; } + TrackList { tracks: root.similarity-tracks; show-artwork: true; play(key) => root.play-track-context("similarity", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); } + } + } + if root.active-screen == "playlist": ScrollView { viewport-width: self.width; horizontal-scrollbar-policy: ScrollBarPolicy.always-off; @@ -405,7 +483,7 @@ export component AppWindow inherits Window { } if root.queue-open: queue-panel := Rectangle { - width: 292px; + width: 370px; background: #11141b; border-color: #252936; border-width: 1px; @@ -425,24 +503,132 @@ export component AppWindow inherits Window { viewport-width: self.width; horizontal-scrollbar-policy: ScrollBarPolicy.always-off; vertical-scrollbar-policy: ScrollBarPolicy.always-off; - VerticalLayout { + mouse-drag-pan-enabled: root.queue-drag-source < 0; + queue-list := Rectangle { width: parent.width; - spacing: 4px; - alignment: start; - for item in root.queue-items: Rectangle { - height: 52px; border-radius: 7px; background: item.active ? #242a35 : transparent; - TouchArea { double-clicked => root.play-queue-item(item.key); } - HorizontalLayout { - padding: 7px; spacing: 10px; - Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } } - Rectangle { - width: 190px; height: parent.height; background: transparent; clip: true; - VerticalLayout { - x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center; - MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; } - LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); } + height: root.queue-items.length > 0 ? root.queue-items.length * 56px - 4px : 0px; + background: transparent; + for item[index] in root.queue-items: queue-row := Rectangle { + private property visual-index: root.queue-drag-source < 0 ? index + : index == root.queue-drag-source ? index + : root.queue-drag-source < root.queue-drag-target && index > root.queue-drag-source && index <= root.queue-drag-target ? index - 1 + : root.queue-drag-target < root.queue-drag-source && index >= root.queue-drag-target && index < root.queue-drag-source ? index + 1 + : index; + x: 0px; + y: self.visual-index * 56px; + width: parent.width; + height: 52px; + background: transparent; + animate y { duration: 110ms; easing: ease-out; } + queue-drag := TouchArea { + private property start-y; + mouse-cursor: self.pressed ? grabbing : grab; + pointer-event(event) => { + if event.kind == PointerEventKind.cancel { + if root.queue-drag-source == index { + root.queue-drag-source = -1; + root.queue-drag-target = -1; + root.queue-drag-offset = 0px; + } + return; + } + if event.button != PointerEventButton.left { + return; + } + if event.kind == PointerEventKind.down { + self.start-y = self.mouse-y; + root.queue-drag-source = index; + root.queue-drag-target = index; + root.queue-drag-offset = 0px; + } else if event.kind == PointerEventKind.up { + if root.queue-drag-source == index && root.queue-drag-target != index { + root.move-queue-item(item.key, root.queue-drag-target); + } + root.queue-drag-source = -1; + root.queue-drag-target = -1; + root.queue-drag-offset = 0px; } } + moved => { + if self.pressed && root.queue-drag-source == index { + root.queue-drag-offset = self.mouse-y - self.start-y; + root.queue-drag-target = max(0, min(root.queue-items.length - 1, index + round(root.queue-drag-offset / 56px))); + } + } + double-clicked => root.play-queue-item(item.key); + } + row-visual := Rectangle { + width: parent.width; + height: parent.height; + opacity: root.queue-drag-source == index ? 0 : 1; + border-radius: 7px; + background: item.active ? #242a35 : queue-drag.has-hover ? #1b1f29 : transparent; + HorizontalLayout { + padding: 7px; spacing: 10px; + Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } } + Rectangle { + horizontal-stretch: 1; height: parent.height; background: transparent; clip: true; + VerticalLayout { + x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center; + MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; } + LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); } + } + } + TrackActionButton { active: item.liked; icon-source: @image-url("../assets/heart.svg"); clicked => root.track-action("like", item.track-key); } + TrackActionButton { icon-source: @image-url("../assets/more.svg"); clicked => queue-menu.show(); } + IconButton { width: 28px; height: 28px; icon-source: @image-url("../assets/close.svg"); clicked => root.remove-queue-item(item.key); } + } + } + queue-menu := PopupWindow { + x: queue-row.width - self.width - 8px; + y: 46px; + width: 184px; + height: 118px; + close-policy: close-on-click-outside; + Rectangle { + border-radius: 8px; + background: #222631; + border-color: #3a3f4d; + border-width: 1px; + drop-shadow-color: #00000080; + drop-shadow-blur: 14px; + drop-shadow-offset-y: 4px; + VerticalLayout { + padding: 6px; spacing: 1px; + TrackMenuItem { icon-source: @image-url("../assets/info.svg"); label: "Track information"; clicked => { queue-menu.close(); root.track-action("information", item.track-key); } } + TrackMenuItem { icon-source: @image-url("../assets/search.svg"); label: "Find similar"; clicked => { queue-menu.close(); root.track-action("similar", item.track-key); } } + TrackMenuItem { icon-source: @image-url("../assets/playlist-add.svg"); label: "Add to playlist"; clicked => { queue-menu.close(); root.track-action("playlist", item.track-key); } } + } + } + } + } + if root.queue-drag-source >= 0: drag-preview := Rectangle { + private property item: root.queue-items[root.queue-drag-source]; + x: 0px; + y: max(0px, min(parent.height - self.height, root.queue-drag-source * 56px + root.queue-drag-offset)); + width: parent.width; + height: 52px; + border-radius: 7px; + background: #29362f; + border-width: 1px; + border-color: #55dbaa; + drop-shadow-color: #000000a0; + drop-shadow-blur: 16px; + drop-shadow-offset-y: 5px; + HorizontalLayout { + padding: 7px; spacing: 10px; + Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if drag-preview.item.has-artwork: Image { source: drag-preview.item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } } + Rectangle { + horizontal-stretch: 1; height: parent.height; background: transparent; clip: true; + VerticalLayout { + x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center; + MarqueeText { width: parent.width; height: 18px; label: drag-preview.item.title; text-color: drag-preview.item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; } + Text { width: parent.width; height: 15px; text: drag-preview.item.artist; color: #777d8d; font-size: 10px; overflow: elide; vertical-alignment: center; } + } + } + Rectangle { width: 28px; height: 28px; border-radius: 6px; background: drag-preview.item.liked ? #255845 : transparent; Image { source: @image-url("../assets/heart.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } } + Rectangle { width: 28px; height: 28px; Image { source: @image-url("../assets/more.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } } + Rectangle { width: 28px; height: 28px; Image { source: @image-url("../assets/close.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } } } } } @@ -565,6 +751,28 @@ export component AppWindow inherits Window { vertical-scrollbar-policy: ScrollBarPolicy.always-off; VerticalLayout { spacing: 4px; + for pairing in root.pending-pairings: Rectangle { + height: pairing.group-conflict ? 142px : 76px; border-radius: 7px; background: #252a35; + VerticalLayout { + padding: 8px; spacing: 5px; + Text { text: pairing.name + " wants to connect"; color: #f0f1f5; font-size: 11px; font-weight: 650; overflow: elide; } + Text { text: pairing.details; color: #7f8797; font-size: 9px; overflow: elide; } + if pairing.group-conflict: Text { text: pairing.group-summary; color: #d9a9d0; font-size: 9px; overflow: elide; } + if pairing.group-conflict: Text { text: "Join theirs to keep its current peers syncing."; color: #9ca4b4; font-size: 9px; } + if pairing.group-conflict: Text { text: "Keep this group moves only this device; its old peers stop syncing."; color: #9ca4b4; font-size: 9px; } + if pairing.group-conflict: HorizontalLayout { + spacing: 7px; + RecommendedButton { text: "Join their group"; clicked => root.answer-pairing(pairing.request-id, true, true); } + Button { text: "Keep this group"; clicked => root.answer-pairing(pairing.request-id, true, false); } + Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); } + } + if !pairing.group-conflict: HorizontalLayout { + spacing: 7px; + Button { text: "Accept"; clicked => root.answer-pairing(pairing.request-id, true, false); } + Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); } + } + } + } for device in root.connected-devices: DeviceRow { visible: !device.active; height: !device.active ? 48px : 0px; @@ -572,23 +780,31 @@ export component AppWindow inherits Window { selectable: !device.revoked && (device.online || device.is-self); selected(id) => root.select-device(id); } - for pairing in root.pending-pairings: Rectangle { - height: 76px; border-radius: 7px; background: #252a35; - VerticalLayout { - padding: 8px; spacing: 5px; - Text { text: pairing.name + " wants to connect"; color: #f0f1f5; font-size: 11px; font-weight: 650; overflow: elide; } - Text { text: pairing.details; color: #7f8797; font-size: 9px; overflow: elide; } - HorizontalLayout { - spacing: 7px; - Button { text: "Accept"; clicked => root.answer-pairing(pairing.request-id, true, pairing.group-conflict); } - Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); } - } - } - } } } Rectangle { height: 1px; background: #303541; } - if root.device-invite != "": Text { text: root.device-invite; color: #9ca4b4; font-size: 9px; wrap: word-wrap; max-height: 36px; overflow: elide; } + if root.device-invite != "": HorizontalLayout { + height: 32px; spacing: 8px; + invite-code-frame := Rectangle { + horizontal-stretch: 1; + min-width: 0px; + height: 32px; + clip: true; + invite-code := LineEdit { + width: parent.width; + height: parent.height; + text: root.device-invite; + read-only: true; + } + } + Button { + text: "Copy"; + clicked => { + invite-code.select-all(); + invite-code.copy(); + } + } + } HorizontalLayout { spacing: 8px; device-link := LineEdit { horizontal-stretch: 1; placeholder-text: "Paste frid://i/… invite"; } @@ -705,7 +921,7 @@ export component AppWindow inherits Window { spacing: 8px; LineEdit { text: root.library-path; - placeholder-text: "~/Music/Furumi"; + placeholder-text: "Choose a music directory"; horizontal-stretch: 1; edited => root.library-path-changed(self.text); } @@ -747,6 +963,109 @@ export component AppWindow inherits Window { } } } + Rectangle { height: 1px; background: #2a2e38; } + Text { height: 22px; text: "Similarity search"; color: #f0f2f7; font-size: 15px; font-weight: 750; vertical-alignment: center; } + HorizontalLayout { + height: 52px; + VerticalLayout { + Text { text: "Find similar tracks"; color: #e8eaf0; font-size: 13px; font-weight: 650; } + Text { text: "Build a private local audio index. Downloads the selected model when enabled."; color: #818797; font-size: 11px; } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + CheckBox { + text: "Enabled"; + checked: root.similarity-enabled; + toggled => root.similarity-enabled-changed(self.checked); + } + } + HorizontalLayout { + height: 44px; spacing: 12px; + VerticalLayout { + horizontal-stretch: 1; + Text { text: "Embedding model"; color: #e8eaf0; font-size: 12px; font-weight: 650; } + Text { text: "CC BY-NC-SA 4.0 (or proprietary from MTG)"; color: #818797; font-size: 10px; } + } + ComboBox { + width: 244px; + model: root.similarity-models; + current-value: root.similarity-model; + selected(value) => root.similarity-model-changed(value); + } + } + HorizontalLayout { + height: 44px; spacing: 12px; + VerticalLayout { + horizontal-stretch: 1; + Text { text: "Preprocessing profile"; color: #e8eaf0; font-size: 12px; font-weight: 650; } + Text { text: "Full track; long tracks use balanced windows."; color: #818797; font-size: 10px; } + } + ComboBox { + width: 244px; + model: root.similarity-profiles; + current-value: root.similarity-profile; + selected(value) => root.similarity-profile-changed(value); + } + } + VerticalLayout { + spacing: 6px; + HorizontalLayout { + height: 20px; + Text { horizontal-stretch: 1; text: "Minimum similarity"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; } + Text { text: round(root.similarity-minimum-score * 100) + "%"; color: #aeb4c2; font-size: 11px; vertical-alignment: center; } + } + Slider { height: 18px; minimum: 0; maximum: 1; value: root.similarity-minimum-score; changed(value) => root.similarity-minimum-score-changed(value); } + } + VerticalLayout { + spacing: 6px; + HorizontalLayout { + height: 20px; + Text { horizontal-stretch: 1; text: "Maximum tracks per artist"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; } + Text { text: root.similarity-max-tracks-per-artist; color: #aeb4c2; font-size: 11px; vertical-alignment: center; } + } + Slider { height: 18px; minimum: 1; maximum: 50; value: root.similarity-max-tracks-per-artist; changed(value) => root.similarity-max-tracks-per-artist-changed(round(value)); } + } + VerticalLayout { + spacing: 6px; + HorizontalLayout { + height: 20px; + Text { horizontal-stretch: 1; text: "Background workers"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; } + Text { text: root.similarity-workers; color: #aeb4c2; font-size: 11px; vertical-alignment: center; } + } + Slider { height: 18px; minimum: 1; maximum: 16; value: root.similarity-workers; changed(value) => root.similarity-workers-changed(round(value)); } + } + HorizontalLayout { + height: 62px; + VerticalLayout { + Text { text: "Search federation too"; color: #e8eaf0; font-size: 13px; font-weight: 650; } + Text { text: "Send an anonymous numeric embedding to peers. No account identity is included."; color: #818797; font-size: 10px; } + Text { text: "A peer may infer the kind of music being searched."; color: #d4ad62; font-size: 10px; } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + CheckBox { + text: "I agree"; + checked: root.similarity-federation-consent; + toggled => root.similarity-federation-consent-changed(self.checked); + } + } + Rectangle { + min-height: 112px; max-height: 112px; vertical-stretch: 0; + border-radius: 9px; background: #11151d; border-width: 1px; border-color: #2c3240; + VerticalLayout { + padding: 11px; spacing: 5px; + HorizontalLayout { + height: 20px; + Text { horizontal-stretch: 1; text: "Similarity index"; color: #dfe2ea; font-size: 11px; font-weight: 700; vertical-alignment: center; } + Text { text: root.similarity-status-phase; color: root.similarity-enabled ? #55dbaa : #777d8d; font-size: 10px; vertical-alignment: center; } + } + Text { height: 17px; text: "Progress " + root.similarity-status-progress + " Stored " + root.similarity-status-storage; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; } + Text { height: 17px; text: root.similarity-status-current; color: #697080; font-size: 10px; overflow: elide; vertical-alignment: center; } + HorizontalLayout { + height: 28px; + Rectangle { horizontal-stretch: 1; background: transparent; } + Button { text: "Clear stored embeddings"; clicked => root.clear-similarity(); } + } + } + } HorizontalLayout { height: 52px; VerticalLayout { diff --git a/crates/ui/ui/components.slint b/crates/ui/ui/components.slint index b796d2e..77f1c70 100644 --- a/crates/ui/ui/components.slint +++ b/crates/ui/ui/components.slint @@ -15,6 +15,49 @@ export component IconButton inherits Rectangle { touch := TouchArea { enabled: root.enabled; clicked => root.clicked(); } } +export component RecommendedButton inherits Rectangle { + in property text; + in property enabled: true; + callback clicked; + min-width: label.preferred-width + 24px; + min-height: 32px; + horizontal-stretch: 0; + vertical-stretch: 0; + border-radius: 4px; + border-width: 1px; + border-color: root.enabled ? #65d7a9 : #49645a; + background: !root.enabled ? #31443d : touch.pressed ? #267657 : touch.has-hover ? #3aa77d : #318e6b; + opacity: root.enabled ? 1 : 0.55; + accessible-role: button; + accessible-enabled: root.enabled; + accessible-label: root.text; + accessible-action-default => { root.clicked(); } + forward-focus: focus; + + label := Text { + text: root.text; + color: #ffffff; + font-size: 11px; + font-weight: 700; + horizontal-alignment: center; + vertical-alignment: center; + } + touch := TouchArea { + enabled: root.enabled; + clicked => root.clicked(); + } + focus := FocusScope { + enabled: root.enabled; + key-pressed(event) => { + if event.text == " " || event.text == "\n" { + root.clicked(); + return accept; + } + return reject; + } + } +} + export component DeviceRow inherits Rectangle { in property item; in property selectable: false; @@ -146,9 +189,14 @@ export component PlaylistNavButton inherits Rectangle { background: root.active ? #252936 : touch.has-hover ? #1a1d27 : transparent; HorizontalLayout { padding-left: 10px; padding-right: 8px; spacing: 9px; - Image { - source: root.item.is-likes ? @image-url("../assets/heart.svg") : @image-url("../assets/playlist-add.svg"); - width: 15px; height: 15px; + Rectangle { + width: 15px; + background: transparent; + Image { + source: root.item.is-likes ? @image-url("../assets/heart.svg") : @image-url("../assets/playlist-add.svg"); + width: 15px; height: 15px; + y: (parent.height - self.height) / 2; + } } Text { horizontal-stretch: 1; text: root.item.title; color: root.active ? #f5f6fa : #a4a9b7; font-size: 12px; overflow: elide; vertical-alignment: center; } } @@ -629,7 +677,7 @@ export component TrackRow inherits Rectangle { VerticalLayout { padding: 6px; spacing: 1px; TrackMenuItem { icon-source: @image-url("../assets/info.svg"); label: "Track information"; clicked => { menu.close(); root.action("information", root.item.key); } } - TrackMenuItem { icon-source: @image-url("../assets/share.svg"); label: "Share"; clicked => { menu.close(); root.action("share", root.item.key); } } + TrackMenuItem { icon-source: @image-url("../assets/search.svg"); label: "Find similar"; clicked => { menu.close(); root.action("similar", root.item.key); } } TrackMenuItem { icon-source: @image-url("../assets/playlist-add.svg"); label: "Add to playlist"; clicked => { menu.close(); root.action("playlist", root.item.key); } } } } diff --git a/crates/ui/ui/models.slint b/crates/ui/ui/models.slint index 7800fe7..1d1c08a 100644 --- a/crates/ui/ui/models.slint +++ b/crates/ui/ui/models.slint @@ -55,12 +55,14 @@ export struct TrackView { export struct QueueView { key: string, + track-key: string, title: string, artist: string, artist-key: string, release: string, release-key: string, active: bool, + liked: bool, artwork: image, has-artwork: bool, } @@ -87,4 +89,5 @@ export struct PairingView { name: string, details: string, group-conflict: bool, + group-summary: string, } diff --git a/shell.nix b/shell.nix index 7c0a35b..e972b9c 100644 --- a/shell.nix +++ b/shell.nix @@ -35,5 +35,10 @@ pkgs.mkShell { xorg.libXrandr ]); + # The software renderer is the reliable default in a NixOS development + # shell: it avoids mixing the shell's Vulkan loader with the host's graphics + # driver stack. Override this when explicitly testing another renderer. + SLINT_BACKEND = "winit-software"; + RUST_BACKTRACE = "1"; }