Fixed UI bugs

This commit is contained in:
Aleksandr Bogomiakov
2026-08-14 02:14:46 +01:00
parent 2dc40c2c3f
commit 0d7c9b9f5e
24 changed files with 4351 additions and 158 deletions
+47 -7
View File
@@ -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.
Generated
+616 -6
View File
@@ -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",
+7 -1
View File
@@ -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"
+199 -17
View File
@@ -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<ArtistKey>),
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<TrackKey>,
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<Effect> {
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<Effect> {
.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<Effect> {
}
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<Effect> {
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<Effect> {
}
}
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();
+89 -2
View File
@@ -222,7 +222,7 @@ pub struct BuildInfoSnapshot {
pub protocols: Vec<VersionEntrySnapshot>,
}
#[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<String>,
}
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<String>,
pub target_profile: Option<String>,
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<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SimilaritySearchSnapshot {
pub source_title: String,
pub results: Vec<Track>,
pub pending: bool,
pub error: Option<String>,
}
#[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<String>,
pub settings_error: Option<String>,
}
@@ -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<TrackKey>,
selected: TrackKey,
+6
View File
@@ -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
+1
View File
@@ -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);
+253 -4
View File
@@ -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::<Vec<_>>();
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"));
});
}
}
+21 -5
View File
@@ -108,6 +108,7 @@ impl DeviceSync {
.collect::<rusqlite::Result<Vec<_>>>()?
};
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<String>>(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::<rusqlite::Result<Vec<_>>>()?
@@ -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(())
+71 -5
View File
@@ -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<MusicDhtService>,
similarity_dht: Arc<SimilarityDht>,
media_dir: PathBuf,
}
impl Client {
pub async fn start(data_dir: PathBuf, media_dir: PathBuf, network: &str) -> Result<Arc<Self>> {
pub async fn start(
data_dir: PathBuf,
media_dir: PathBuf,
network: &str,
similarity: Arc<crate::similarity::Manager>,
) -> Result<Arc<Self>> {
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<Vec<crate::federation_similarity::ScoredTrack>> {
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<Track> {
@@ -181,11 +241,17 @@ impl Client {
.into_iter()
.chain(outcome.network_results)
.collect::<Vec<_>>();
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<ItemSpec>) -> Result<()> {
+378
View File
@@ -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<Manager>, 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<Manager>,
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<MusicDhtService>,
routing: Arc<SimilarityDht>,
query: QueryVector,
limit: usize,
minimum_score: f32,
max_tracks_per_artist: usize,
) -> Result<Vec<ScoredTrack>> {
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::<Vec<_>>();
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::<Vec<_>>()
.await;
let mut hits = responses
.into_iter()
.filter_map(Result::ok)
.flatten()
.collect::<Vec<_>>();
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::<String, usize>::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<PeerTicket>,
}
async fn query_peer(
service: Arc<MusicDhtService>,
peer: QueryPeer,
request: &SimilarityRequest,
) -> Result<PeerHits> {
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<SimilarityDht>, manager: Arc<Manager>) {
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<String>| {
names
.into_iter()
.map(|name| ArtistRef {
key: ArtistKey::Federation {
peer_id: peer_id.clone(),
id: music_dht::normalize_name(&name),
},
name,
})
.collect::<Vec<_>>()
};
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::<Vec<_>>()
.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
}
+415 -18
View File
@@ -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<BackendHandle, BackendStartupError> {
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<BackendHandle, BackendStartupError> {
audio,
federation_data_dir,
federation_media_dir,
similarity_model_dir,
devices,
}));
})?;
@@ -218,6 +235,10 @@ enum InternalEvent {
keys: Vec<TrackKey>,
cover_uri: Option<String>,
},
HistoryTrackResolved {
content_id: String,
result: Result<Track, String>,
},
FederationDebugUpdated(FederationDebugSnapshot),
DevicesChanged,
DeviceLibraryChanged,
@@ -226,6 +247,12 @@ enum InternalEvent {
DeviceOperationFinished(Result<DeviceOperationResult, String>),
DevicePlaybackSnapshot(music_dht::device_sync::PlaybackSnapshot),
DevicePlaybackCommand(music_dht::device_sync::PlaybackCommand),
SimilarityStatus(similarity::SimilarityStatus),
SimilarityProfileActivated(Option<String>),
SimilaritySearchFinished {
source_title: String,
result: Result<Vec<Track>, String>,
},
}
enum DeviceOperationResult {
@@ -246,8 +273,10 @@ struct Actor {
federation: Option<std::sync::Arc<federation::Client>>,
federation_data_dir: std::path::PathBuf,
federation_media_dir: std::path::PathBuf,
similarity: std::sync::Arc<similarity::Manager>,
ephemeral_audio: Option<(TrackKey, std::path::PathBuf)>,
pending_queue_artwork: HashSet<String>,
pending_history_resolutions: HashSet<String>,
federation_debug_pending: bool,
devices: std::sync::Arc<devices::DeviceSync>,
device_service: Option<std::sync::Arc<music_dht::MusicDhtService>>,
@@ -256,6 +285,14 @@ struct Actor {
active_device_name: String,
control_anchor: Option<ControlPlaybackAnchor>,
pending_control: Option<PendingControlState>,
listen_session: Option<ListenSession>,
}
#[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<devices::DeviceSync>,
}
@@ -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::<HashSet<_>>();
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::<Vec<_>>();
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<Track> {
@@ -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::<HashSet<_>>();
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<SimilarityCandidate>,
max_tracks_per_artist: usize,
) -> Vec<Track> {
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::<String, usize>::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;
+136 -8
View File
@@ -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<Self> {
pub fn open(path: &Path, default_library_path: &Path) -> rusqlite::Result<Self> {
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<Self> {
fn in_memory(default_library_path: &Path) -> rusqlite::Result<Self> {
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<SettingsSnapshot> {
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");
}
}
File diff suppressed because it is too large Load Diff
+77 -5
View File
@@ -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<String>) -> bool {
track
.key
+65
View File
@@ -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 {
+71
View File
@@ -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<bool> {
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<_>>(),
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();
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 3-6.7"/>
<path d="M3 4.5v5h5"/>
<path d="M12 7.5V12l3 2"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+162 -3
View File
@@ -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<Mutex<AppState>>, 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<Mutex<AppState>>, 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<Mutex<AppState>>, 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<Mutex<AppState>>, 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::<u64>(), 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::<u64>() {
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<Mutex<AppState>>,
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<Mutex<AppState>>, backend: &BackendHandle) {
window.on_choose_library_path({
let window = window.as_weak();
@@ -717,11 +871,13 @@ fn dispatch_event(window: &AppWindow, state: &Arc<Mutex<AppState>>, 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<Mutex<AppState>>, event: AppEv
render_queue(window, state);
render_current_track(window, state);
}
if similarity_changed {
render::render_similarity(window, state);
}
render_playback(window, state);
});
}
+211 -27
View File
@@ -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::<String>()
)
})
.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<TrackKey> {
let tracks: Vec<Track> = 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<TrackKey> {
tracks.into_iter().map(|track| track.key).collect()
}
pub(super) fn breadcrumb_screen(state: &AppState, target: &SharedString) -> Option<Screen> {
pub(super) fn breadcrumb_screen(target: &SharedString) -> Option<Screen> {
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::<i64>()
.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<BreadcrumbView> {
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<Artist
.collect()
}
fn artist_to_view(artist: &Artist) -> 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::<Vec<_>>();
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<ArtistKey> {
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<ArtistKey> {
if let Some(local) = value.strip_prefix("local-artist:") {
return local
.parse::<i64>()
.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<ReleaseKey> {
@@ -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 {
+365 -46
View File
@@ -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 <bool> busy;
in property <bool> 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 <int> phase: floor(1 * mod(animation-tick(), 2s) / 250ms);
private property <int> 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 <string> search-label: "Search";
in property <string> library-label: "Your library";
in property <string> queue-label: "Queue";
in property <string> recent-label: "Recently played";
in property <string> history-label: "Listening history";
in property <string> featured-label: "Made for listening";
in property <string> search-placeholder: "Artists, albums or tracks";
in-out property <string> search-query;
@@ -29,6 +60,19 @@ export component AppWindow inherits Window {
in property <string> library-path;
in property <bool> federation-enabled;
in property <bool> save-federated-on-listen;
in property <bool> similarity-enabled;
in property <string> similarity-model;
in property <[string]> similarity-models;
in property <string> similarity-profile;
in property <[string]> similarity-profiles;
in property <int> similarity-workers: 1;
in property <float> similarity-minimum-score: 0.7;
in property <int> similarity-max-tracks-per-artist: 5;
in property <bool> similarity-federation-consent;
in property <string> similarity-status-phase: "disabled";
in property <string> similarity-status-progress: "0 / 0";
in property <string> similarity-status-storage: "0 vectors";
in property <string> similarity-status-current;
in property <string> selected-language: "English";
in property <[string]> available-languages;
in property <[ReleaseView]> releases;
@@ -44,8 +88,15 @@ export component AppWindow inherits Window {
in property <string> detail-release-type;
in property <image> detail-artwork;
in property <bool> detail-has-artwork;
in property <[TrackView]> tracks;
in property <[TrackView]> history-tracks;
in property <[TrackView]> similarity-tracks;
in property <string> similarity-source-title;
in property <bool> similarity-pending;
in property <string> similarity-error;
in property <[QueueView]> queue-items;
private property <int> queue-drag-source: -1;
private property <int> queue-drag-target: -1;
private property <length> queue-drag-offset: 0px;
in property <[PlaylistView]> playlists;
in property <[TrackView]> playlist-tracks;
in property <string> playlist-title;
@@ -63,6 +114,7 @@ export component AppWindow inherits Window {
in property <[ArtistView]> search-artists;
in property <[ReleaseView]> search-releases;
in property <bool> federation-status-busy;
in property <bool> federation-status-running;
in property <string> federation-status-text: "Federation ready";
in property <string> federation-debug-node: "Stopped";
in property <string> 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 <int> 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 <length> 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 <QueueView> 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 {
+52 -4
View File
@@ -15,6 +15,49 @@ export component IconButton inherits Rectangle {
touch := TouchArea { enabled: root.enabled; clicked => root.clicked(); }
}
export component RecommendedButton inherits Rectangle {
in property <string> text;
in property <bool> 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 <DeviceView> item;
in property <bool> 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); } }
}
}
+3
View File
@@ -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,
}
+5
View File
@@ -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";
}